diff --git a/public/css/style.css b/public/css/style.css index f0b67f8..6b97f38 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; + } } diff --git a/src/models/index.js b/src/models/index.js index 840bc8a..fcf8889 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -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; } }; diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 64d2ae0..ea35f65 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -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(); diff --git a/src/utils/pdf.js b/src/utils/pdf.js index 3b1c1f2..155ab46 100644 --- a/src/utils/pdf.js +++ b/src/utils/pdf.js @@ -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' }); }); diff --git a/src/views/admin/manage-hunt.ejs b/src/views/admin/manage-hunt.ejs index 881f112..5c2a675 100644 --- a/src/views/admin/manage-hunt.ejs +++ b/src/views/admin/manage-hunt.ejs @@ -58,8 +58,8 @@
"<%= pkg.last_scan_hint %>"
-Left by <%= pkg.last_scanner_name %>
+Left by <%= pkg.last_scanner_name %>
<% } else { %>No hint has been left yet.
<% } %> @@ -85,7 +85,7 @@ <% scanHistory.forEach((scan, i) => { %>"<%= pkg.last_scan_hint %>"
-Left by <%= pkg.last_scanner_name %>
+Left by <%= pkg.last_scanner_name %>
<% } else { %>No hint has been left yet.
<% } %> @@ -101,7 +101,7 @@ <% scanHistory.forEach((scan, i) => { %>