adding player profiles
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s
This commit is contained in:
@@ -52,6 +52,26 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Hamburger toggle — hidden on desktop */
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.nav-toggle span {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: #b2bec3;
|
||||
border-radius: 2px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -488,30 +508,59 @@ tr:hover {
|
||||
}
|
||||
|
||||
/* ─── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
@media (max-width: 700px) {
|
||||
/* Hamburger visible on mobile */
|
||||
.nav-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 0 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 52px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.navbar-nav {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
background: var(--darker);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 0.5rem 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
.navbar-nav.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navbar-nav li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar-nav a {
|
||||
display: block;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.navbar-nav a:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container,
|
||||
.container-narrow {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
@@ -524,6 +573,7 @@ tr:hover {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
@@ -533,6 +583,7 @@ tr:hover {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -550,6 +601,7 @@ tr:hover {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Package grid */
|
||||
.package-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.6rem;
|
||||
@@ -563,6 +615,7 @@ tr:hover {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Hunt cards */
|
||||
.hunt-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -570,6 +623,7 @@ tr:hover {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* Scan result */
|
||||
.scan-result h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -587,11 +641,13 @@ tr:hover {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Tables — horizontal scroll on mobile */
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -605,8 +661,23 @@ tr:hover {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Touch-friendly form controls */
|
||||
.form-control {
|
||||
font-size: 1rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,48 @@ const Users = {
|
||||
const totalPoints = this.getTotalPoints(userId);
|
||||
const scanCount = db.prepare('SELECT COUNT(*) as count FROM scans WHERE user_id = ? AND points_awarded > 0').get(userId).count;
|
||||
return { ...user, totalPoints, scanCount };
|
||||
},
|
||||
|
||||
getRecentScans(userId, limit = 20) {
|
||||
return db.prepare(`
|
||||
SELECT s.points_awarded, s.scanned_at,
|
||||
p.card_number, p.unique_code,
|
||||
h.name as hunt_name, h.short_name as hunt_short_name, h.package_count
|
||||
FROM scans s
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
WHERE s.user_id = ?
|
||||
ORDER BY s.scanned_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit);
|
||||
},
|
||||
|
||||
getHuntBreakdown(userId) {
|
||||
return db.prepare(`
|
||||
SELECT h.name as hunt_name, h.short_name as hunt_short_name,
|
||||
COUNT(s.id) as scans, SUM(s.points_awarded) as points
|
||||
FROM scans s
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
WHERE s.user_id = ? AND s.points_awarded > 0
|
||||
GROUP BY h.id
|
||||
ORDER BY points DESC
|
||||
`).all(userId);
|
||||
},
|
||||
|
||||
getRank(userId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT user_id, SUM(points_awarded) as total
|
||||
FROM scans WHERE points_awarded > 0
|
||||
GROUP BY user_id
|
||||
ORDER BY total DESC
|
||||
`).all();
|
||||
const idx = rows.findIndex(r => r.user_id === userId);
|
||||
return idx >= 0 ? idx + 1 : null;
|
||||
},
|
||||
|
||||
getTotalPlayerCount() {
|
||||
return db.prepare('SELECT COUNT(DISTINCT user_id) as count FROM scans WHERE points_awarded > 0').get().count;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Hunts, Packages, Scans } = require('../models');
|
||||
const { Hunts, Packages, Scans, Users } = require('../models');
|
||||
|
||||
// ─── Hunt profile ─────────────────────────────────────────
|
||||
router.get('/hunt/:shortName', (req, res) => {
|
||||
@@ -59,6 +59,29 @@ router.get('/hunt/:shortName/:cardNumber', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── User profile ─────────────────────────────────────────
|
||||
router.get('/player/:username', (req, res) => {
|
||||
const user = Users.findByUsername(req.params.username);
|
||||
if (!user) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Player not found.' });
|
||||
}
|
||||
|
||||
const profile = Users.getProfile(user.id);
|
||||
const recentScans = Users.getRecentScans(user.id);
|
||||
const huntBreakdown = Users.getHuntBreakdown(user.id);
|
||||
const rank = Users.getRank(user.id);
|
||||
const totalPlayers = Users.getTotalPlayerCount();
|
||||
|
||||
res.render('player/profile', {
|
||||
title: `${user.username}'s Profile`,
|
||||
profile,
|
||||
recentScans,
|
||||
huntBreakdown,
|
||||
rank,
|
||||
totalPlayers
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Browse all hunts ─────────────────────────────────────
|
||||
router.get('/hunts', (req, res) => {
|
||||
const hunts = Hunts.getAll();
|
||||
|
||||
@@ -68,13 +68,13 @@ async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||
const textBlockH = 64;
|
||||
const textY = y + (cardH - textBlockH) / 2;
|
||||
|
||||
doc.fontSize(18).font('Courier-Bold').fillColor('#1a1a1a');
|
||||
doc.fontSize(18).font('Helvetica-Bold').fillColor('#1a1a1a');
|
||||
doc.text(`${pkg.card_number} of ${totalPackages}`, textX, textY, { width: textW, align: 'center' });
|
||||
|
||||
doc.fontSize(12).font('Courier').fillColor('#666666');
|
||||
doc.fontSize(12).font('Helvetica').fillColor('#666666');
|
||||
doc.text(hunt.short_name, textX, textY + 24, { width: textW, align: 'center' });
|
||||
|
||||
doc.fontSize(14).font('Courier-Bold').fillColor('#333333');
|
||||
doc.fontSize(14).font('Helvetica-Bold').fillColor('#333333');
|
||||
doc.text(pkg.unique_code, textX, textY + 44, { width: textW, align: 'center' });
|
||||
}
|
||||
|
||||
@@ -84,15 +84,13 @@ async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||
const backLines = [
|
||||
'THIS CARD IS UNIQUE',
|
||||
'',
|
||||
'IF YOU DO NOT USE IT',
|
||||
'PLEASE PUT IT SOMEWHERE',
|
||||
'INTERESTING FOR THE',
|
||||
'NEXT PERSON TO FIND',
|
||||
'IF YOU DO NOT USE IT PLEASE',
|
||||
'PUT IT SOMEWHERE INTERESTING',
|
||||
'FOR THE NEXT PERSON TO FIND',
|
||||
'',
|
||||
'PLEASE DO NOT',
|
||||
'THROW ME AWAY'
|
||||
'PLEASE DO NOT THROW ME AWAY'
|
||||
];
|
||||
const lineHeight = 11;
|
||||
const lineHeight = 16;
|
||||
const blockH = backLines.length * lineHeight;
|
||||
|
||||
for (let c = 0; c < pagePackages.length; c++) {
|
||||
@@ -105,10 +103,10 @@ async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||
|
||||
const startY = y + (cardH - blockH) / 2;
|
||||
|
||||
doc.font('Courier-Bold').fontSize(9).fillColor('#333333');
|
||||
doc.font('Courier-Bold').fontSize(12).fillColor('#333333');
|
||||
backLines.forEach((line, li) => {
|
||||
doc.text(line, x, startY + li * lineHeight, {
|
||||
width: cardW,
|
||||
doc.text(line, x + 10, startY + li * lineHeight, {
|
||||
width: cardW - 20,
|
||||
align: 'center'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
<td><strong><%= pkg.card_number %></strong></td>
|
||||
<td style="font-family: monospace;"><%= pkg.unique_code %></td>
|
||||
<td><%= pkg.scan_count %></td>
|
||||
<td><%= pkg.first_scanner_name || '—' %></td>
|
||||
<td><%= pkg.last_scanner_name || '—' %></td>
|
||||
<td><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></td>
|
||||
<td><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></td>
|
||||
<td>
|
||||
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>
|
||||
</td>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>"><%= i + 1 %></td>
|
||||
<td><strong><%= entry.username %></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><%= entry.scans %></td>
|
||||
</tr>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<td class="rank-cell rank-<%= i + 1 %>">
|
||||
<% if (i === 0) { %>🥇<% } else if (i === 1) { %>🥈<% } else if (i === 2) { %>🥉<% } else { %><%= i + 1 %><% } %>
|
||||
</td>
|
||||
<td><strong><%= entry.username %></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><%= entry.scans %></td>
|
||||
</tr>
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.first_scanner_name || '---' %></div>
|
||||
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
|
||||
<div class="label">First Finder</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.last_scanner_name || '---' %></div>
|
||||
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
|
||||
<div class="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
@@ -85,7 +85,7 @@
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></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>
|
||||
</tr>
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.first_scanner_name || '---' %></div>
|
||||
<div class="value"><% if (pkg.first_scanner_name) { %><a href="/player/<%= pkg.first_scanner_name %>"><%= pkg.first_scanner_name %></a><% } else { %>---<% } %></div>
|
||||
<div class="label">First Finder</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.last_scanner_name || '---' %></div>
|
||||
<div class="value"><% if (pkg.last_scanner_name) { %><a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a><% } else { %>---<% } %></div>
|
||||
<div class="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <a href="/player/<%= pkg.last_scanner_name %>"><%= pkg.last_scanner_name %></a></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
@@ -101,7 +101,7 @@
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></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>
|
||||
</tr>
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<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>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/hunts">Hunts</a></li>
|
||||
<li><a href="/leaderboard">Leaderboard</a></li>
|
||||
<% if (currentUser) { %>
|
||||
<li><a href="/player/<%= currentUser.username %>">My Profile</a></li>
|
||||
<% if (currentUser.isAdmin) { %>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<% } %>
|
||||
|
||||
87
src/views/player/profile.ejs
Normal file
87
src/views/player/profile.ejs
Normal file
@@ -0,0 +1,87 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="text-align: center; margin-bottom: 1.5rem;">
|
||||
<h1 style="margin-bottom: 0.25rem;">👤 <%= profile.username %></h1>
|
||||
<p style="color: var(--muted); margin: 0;">Joined <%= new Date(profile.created_at).toLocaleDateString() %></p>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= profile.totalPoints %></div>
|
||||
<div class="label">Total Points</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= profile.scanCount %></div>
|
||||
<div class="label">Finds</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= rank ? '#' + rank : '---' %></div>
|
||||
<div class="label">Rank<% if (totalPlayers) { %> / <%= totalPlayers %><% } %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (huntBreakdown.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Hunts Participated</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hunt</th>
|
||||
<th>Finds</th>
|
||||
<th>Points</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% huntBreakdown.forEach(h => { %>
|
||||
<tr>
|
||||
<td><a href="/hunt/<%= h.hunt_short_name %>"><%= h.hunt_name %></a></td>
|
||||
<td><%= h.scans %></td>
|
||||
<td><span class="points-badge">+<%= h.points %></span></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (recentScans.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Recent Activity</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Hunt</th>
|
||||
<th>Points</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% recentScans.forEach(scan => { %>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card" style="text-align: center; color: var(--muted); padding: 2rem;">
|
||||
No scans yet. Get out there and find some loot!
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/leaderboard" class="btn btn-outline">Global Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
Reference in New Issue
Block a user