adding player profiles
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s

This commit is contained in:
2026-02-28 00:51:43 -05:00
parent 30f0c98102
commit 79ee7064a8
11 changed files with 256 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 || '&mdash;' %></td>
<td><%= pkg.last_scanner_name || '&mdash;' %></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>

View File

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

View File

@@ -25,7 +25,7 @@
<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>
<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>

View File

@@ -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">&#x1F4AC; 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>

View File

@@ -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">&#x1F4AC; 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>

View File

@@ -9,10 +9,14 @@
<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>
<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>
<% } %>

View 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;">&#x1F464; <%= 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') %>