updates to most features
Build Images and Deploy / Update-PROD-Stack (push) Successful in 28s

This commit is contained in:
2026-02-28 00:24:08 -05:00
parent 4255d95c68
commit 30f0c98102
10 changed files with 253 additions and 97 deletions
+1 -3
View File
@@ -1,12 +1,10 @@
version: "3.8"
services: services:
loot-hunt: loot-hunt:
image: reg.dev.nervesocket.com/loot-hunt:latest image: reg.dev.nervesocket.com/loot-hunt:latest
container_name: loot-hunt container_name: loot-hunt
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3000}:3000" - "6233:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
+100 -5
View File
@@ -490,28 +490,123 @@ tr:hover {
/* ─── Responsive ──────────────────────────────────────── */ /* ─── Responsive ──────────────────────────────────────── */
@media (max-width: 600px) { @media (max-width: 600px) {
.navbar { .navbar {
padding: 0 0.75rem; padding: 0 0.5rem;
height: 52px;
} }
.container { .navbar-brand {
padding: 1rem; font-size: 1.15rem;
}
.navbar-nav {
gap: 0;
}
.navbar-nav a {
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
}
.container,
.container-narrow {
padding: 0.75rem;
}
.hero {
padding: 1.5rem 0.5rem;
} }
.hero h1 { .hero h1 {
font-size: 1.8rem; font-size: 1.6rem;
}
.hero p {
font-size: 0.95rem;
}
.card {
padding: 1rem;
border-radius: 10px;
}
.card-header {
font-size: 1.05rem;
} }
.stats-row { .stats-row {
flex-direction: column; gap: 0.5rem;
}
.stat-box {
min-width: 0;
padding: 0.75rem 0.5rem;
}
.stat-box .value {
font-size: 1.3rem;
}
.stat-box .label {
font-size: 0.7rem;
} }
.package-grid { .package-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.6rem;
}
.package-card {
padding: 0.75rem;
}
.package-card .card-num {
font-size: 1.2rem;
} }
.hunt-card { .hunt-card {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem;
}
.scan-result h1 {
font-size: 1.5rem;
}
.scan-result .emoji {
font-size: 3rem;
}
.points-badge.large {
font-size: 1.5rem;
padding: 0.4rem 1rem;
}
.package-hero .card-number {
font-size: 2rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.85rem;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 0.5rem 0.4rem;
}
.points-badge {
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
}
.footer {
padding: 1rem;
font-size: 0.75rem;
} }
} }
+9
View File
@@ -141,6 +141,15 @@ const Packages = {
`).get(shortName, uniqueCode); `).get(shortName, uniqueCode);
}, },
findByHuntAndCardNumber(shortName, cardNumber) {
return db.prepare(`
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date, h.package_count
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
WHERE h.short_name = ? COLLATE NOCASE AND p.card_number = ?
`).get(shortName, parseInt(cardNumber, 10));
},
getByHunt(huntId) { getByHunt(huntId) {
return db.prepare(` return db.prepare(`
SELECT p.*, SELECT p.*,
+27
View File
@@ -32,6 +32,33 @@ router.get('/leaderboard', (req, res) => {
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard }); res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
}); });
// ─── Package profile (by card number — no secret code exposed) ────
router.get('/hunt/:shortName/:cardNumber', (req, res) => {
const { shortName, cardNumber } = req.params;
if (!/^\d+$/.test(cardNumber)) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
const fullPkg = Packages.getProfile(pkg.id);
const scanHistory = Packages.getScanHistory(pkg.id);
const isFirstScanner = req.session && req.session.userId && fullPkg.first_scanned_by === req.session.userId;
const isLastScanner = req.session && req.session.userId && fullPkg.last_scanned_by === req.session.userId;
res.render('loot/profile', {
title: `Package ${fullPkg.card_number} of ${pkg.package_count} - ${fullPkg.hunt_name}`,
pkg: fullPkg,
scanHistory,
isFirstScanner,
isLastScanner,
packages_total: pkg.package_count
});
});
// ─── Browse all hunts ───────────────────────────────────── // ─── Browse all hunts ─────────────────────────────────────
router.get('/hunts', (req, res) => { router.get('/hunts', (req, res) => {
const hunts = Hunts.getAll(); const hunts = Hunts.getAll();
+10 -21
View File
@@ -61,40 +61,29 @@ router.get('/:shortName/:code', (req, res) => {
// Reload package with full profile // Reload package with full profile
const fullPkg = Packages.getProfile(pkg.id); const fullPkg = Packages.getProfile(pkg.id);
const scanHistory = Packages.getScanHistory(pkg.id); const scanHistory = Packages.getScanHistory(pkg.id);
const hunt = Hunts.findById(fullPkg.hunt_id);
const isFirstScanner = fullPkg.first_scanned_by === req.session.userId; const isFirstScanner = fullPkg.first_scanned_by === req.session.userId;
const isLastScanner = fullPkg.last_scanned_by === req.session.userId; const isLastScanner = fullPkg.last_scanned_by === req.session.userId;
res.render('loot/scanned', { res.render('loot/scanned', {
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`, title: `Package ${fullPkg.card_number} of ${hunt.package_count} - ${fullPkg.hunt_name}`,
pkg: fullPkg, pkg: fullPkg,
scanResult: result, scanResult: result,
scanHistory, scanHistory,
isFirstScanner, isFirstScanner,
isLastScanner isLastScanner,
packages_total: hunt.package_count
}); });
}); });
// ─── View package profile (non-scan view) ───────────────── // ─── Redirect old profile URLs to new route ──────────────
router.get('/:shortName/:code/profile', (req, res) => { router.get('/:shortName/:code/profile', (req, res) => {
const { shortName, code } = req.params; const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code); const pkg = Packages.findByHuntAndCode(shortName, code);
if (!pkg) { if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' }); return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
} }
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
const fullPkg = Packages.getProfile(pkg.id);
const scanHistory = Packages.getScanHistory(pkg.id);
const isFirstScanner = req.session.userId && fullPkg.first_scanned_by === req.session.userId;
const isLastScanner = req.session.userId && fullPkg.last_scanned_by === req.session.userId;
res.render('loot/profile', {
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
pkg: fullPkg,
scanHistory,
isFirstScanner,
isLastScanner
});
}); });
// ─── Upload first-scan image ────────────────────────────── // ─── Upload first-scan image ──────────────────────────────
@@ -111,12 +100,12 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req
} }
if (!req.file) { if (!req.file) {
return res.redirect(`/loot/${shortName}/${code}/profile`); return res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
} }
const imagePath = `/uploads/${req.file.filename}`; const imagePath = `/uploads/${req.file.filename}`;
Packages.updateFirstScanImage(pkg.id, imagePath); Packages.updateFirstScanImage(pkg.id, imagePath);
res.redirect(`/loot/${shortName}/${code}/profile`); res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
}); });
// ─── Update hint/message ────────────────────────────────── // ─── Update hint/message ──────────────────────────────────
@@ -134,7 +123,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);
res.redirect(`/loot/${shortName}/${code}/profile`); res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
}); });
module.exports = router; module.exports = router;
+95 -54
View File
@@ -3,75 +3,116 @@ const QRCode = require('qrcode');
/** /**
* Generate a printable PDF with QR codes for all packages in a hunt. * Generate a printable PDF with QR codes for all packages in a hunt.
* Layout: 3 cards per page, each card has QR on left and info on right. * Layout: Avery 5371 business card — 10 cards per page (2 columns × 5 rows).
* Double-sided: front has QR + info, back has centred monospace message.
* All text on cards uses Courier (monospace).
*/ */
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) { async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
// Avery 5371 dimensions on US Letter (all in points, 72pt = 1in)
const pageW = 612; // 8.5 in
const pageH = 792; // 11 in
const cardW = 252; // 3.5 in
const cardH = 144; // 2 in
const cols = 2;
const rows = 5;
const cardsPerPage = cols * rows;
// Centering the grid on the page
const gridW = cols * cardW; // 504
const gridH = rows * cardH; // 720
const offsetX = (pageW - gridW) / 2; // 54
const offsetY = (pageH - gridH) / 2; // 36
const qrSize = 108; // fits nicely in 2 in height with padding
const totalPackages = packages.length;
const doc = new PDFDocument({ const doc = new PDFDocument({
size: 'LETTER', size: 'LETTER',
margins: { top: 36, bottom: 36, left: 36, right: 36 } margins: { top: 0, bottom: 0, left: 0, right: 0 }
}); });
doc.pipe(outputStream); doc.pipe(outputStream);
const pageWidth = 612 - 72; // letter width minus margins // Process cards in chunks of cardsPerPage
const cardHeight = 200; const pageCount = Math.ceil(packages.length / cardsPerPage);
const qrSize = 160;
const cardsPerPage = 3;
for (let i = 0; i < packages.length; i++) { for (let p = 0; p < pageCount; p++) {
const pkg = packages[i]; const startIdx = p * cardsPerPage;
const cardIndex = i % cardsPerPage; const pagePackages = packages.slice(startIdx, startIdx + cardsPerPage);
if (i > 0 && cardIndex === 0) { // ──── FRONT PAGE ────
doc.addPage(); if (p > 0) doc.addPage();
for (let c = 0; c < pagePackages.length; c++) {
const pkg = pagePackages[c];
const col = c % cols;
const row = Math.floor(c / cols);
const x = offsetX + col * cardW;
const y = offsetY + row * cardH;
// QR code
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
const qrDataUrl = await QRCode.toDataURL(url, {
width: qrSize * 2,
margin: 1,
errorCorrectionLevel: 'M'
});
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
const qrX = x + 10;
const qrY = y + (cardH - qrSize) / 2;
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
// Text column (monospace)
const textX = x + qrSize + 20;
const textW = cardW - qrSize - 30;
const textBlockH = 64;
const textY = y + (cardH - textBlockH) / 2;
doc.fontSize(18).font('Courier-Bold').fillColor('#1a1a1a');
doc.text(`${pkg.card_number} of ${totalPackages}`, textX, textY, { width: textW, align: 'center' });
doc.fontSize(12).font('Courier').fillColor('#666666');
doc.text(hunt.short_name, textX, textY + 24, { width: textW, align: 'center' });
doc.fontSize(14).font('Courier-Bold').fillColor('#333333');
doc.text(pkg.unique_code, textX, textY + 44, { width: textW, align: 'center' });
} }
const yOffset = 36 + cardIndex * (cardHeight + 24); // ──── BACK PAGE (mirrored column order for double-sided printing) ────
doc.addPage();
// Draw card border const backLines = [
doc.save(); 'THIS CARD IS UNIQUE',
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc'); '',
doc.restore(); 'IF YOU DO NOT USE IT',
'PLEASE PUT IT SOMEWHERE',
'INTERESTING FOR THE',
'NEXT PERSON TO FIND',
'',
'PLEASE DO NOT',
'THROW ME AWAY'
];
const lineHeight = 11;
const blockH = backLines.length * lineHeight;
// Generate QR code as data URL for (let c = 0; c < pagePackages.length; c++) {
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`; // Mirror columns: col 0 ↔ col 1 so backs align when flipped
const qrDataUrl = await QRCode.toDataURL(url, { const col = c % cols;
width: qrSize, const mirroredCol = cols - 1 - col;
margin: 1, const row = Math.floor(c / cols);
errorCorrectionLevel: 'M' const x = offsetX + mirroredCol * cardW;
}); const y = offsetY + row * cardH;
// Convert data URL to buffer for PDFKit const startY = y + (cardH - blockH) / 2;
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
// QR code on the left doc.font('Courier-Bold').fontSize(9).fillColor('#333333');
const qrX = 48; backLines.forEach((line, li) => {
const qrY = yOffset + (cardHeight - qrSize) / 2; doc.text(line, x, startY + li * lineHeight, {
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize }); width: cardW,
align: 'center'
// Info on the right });
const textX = 48 + qrSize + 24; });
const textY = yOffset + 20; }
// Card number
doc.fontSize(28).font('Helvetica-Bold').fillColor('#1a1a1a');
doc.text(`#${pkg.card_number}`, textX, textY, { width: pageWidth - qrSize - 60 });
// Hunt short name
doc.fontSize(16).font('Helvetica').fillColor('#666666');
doc.text(hunt.short_name, textX, textY + 38, { width: pageWidth - qrSize - 60 });
// Unique code
doc.fontSize(22).font('Helvetica-Bold').fillColor('#333333');
doc.text(pkg.unique_code, textX, textY + 62, { width: pageWidth - qrSize - 60 });
// Dashed line separator
doc.fontSize(9).font('Helvetica').fillColor('#999999');
doc.text(url, textX, textY + 100, { width: pageWidth - qrSize - 60 });
// Hunt name at bottom of card
doc.fontSize(11).font('Helvetica').fillColor('#444444');
doc.text(hunt.name, textX, textY + 128, { width: pageWidth - qrSize - 60 });
} }
doc.end(); doc.end();
+1 -1
View File
@@ -61,7 +61,7 @@
<td><%= pkg.first_scanner_name || '&mdash;' %></td> <td><%= pkg.first_scanner_name || '&mdash;' %></td>
<td><%= pkg.last_scanner_name || '&mdash;' %></td> <td><%= pkg.last_scanner_name || '&mdash;' %></td>
<td> <td>
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-sm btn-outline">View</a> <a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>
</td> </td>
</tr> </tr>
<% }) %> <% }) %>
+2 -3
View File
@@ -40,10 +40,9 @@
<div class="package-grid"> <div class="package-grid">
<% packages.forEach(pkg => { %> <% packages.forEach(pkg => { %>
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile" <a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>"
class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>"> class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>">
<div class="card-num">#<%= pkg.card_number %></div> <div class="card-num"><%= pkg.card_number %> of <%= hunt.package_count %></div>
<div class="code"><%= pkg.unique_code %></div>
<div class="scan-info"> <div class="scan-info">
<% if (pkg.scan_count > 0) { %> <% if (pkg.scan_count > 0) { %>
&#x2705; Found &middot; <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %> &#x2705; Found &middot; <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
+4 -5
View File
@@ -7,9 +7,8 @@
<div class="card"> <div class="card">
<div class="package-hero"> <div class="package-hero">
<div class="card-number">#<%= pkg.card_number %></div> <div class="card-number"><%= pkg.card_number %> of <%= packages_total || '' %></div>
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div> <div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div> </div>
<div class="stats-row"> <div class="stats-row">
@@ -18,11 +17,11 @@
<div class="label">Total Scans</div> <div class="label">Total Scans</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="value"><%= pkg.first_scanner_name || '&mdash;' %></div> <div class="value"><%= pkg.first_scanner_name || '---' %></div>
<div class="label">First Finder</div> <div class="label">First Finder</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="value"><%= pkg.last_scanner_name || '&mdash;' %></div> <div class="value"><%= pkg.last_scanner_name || '---' %></div>
<div class="label">Most Recent</div> <div class="label">Most Recent</div>
</div> </div>
</div> </div>
@@ -98,7 +97,7 @@
<% } %> <% } %>
<div style="text-align: center; margin-top: 1rem;"> <div style="text-align: center; margin-top: 1rem;">
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">View All Packages</a> <a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">All Packages</a>
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a> <a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
</div> </div>
</div> </div>
+4 -5
View File
@@ -21,9 +21,8 @@
<div class="card"> <div class="card">
<div class="package-hero"> <div class="package-hero">
<div class="card-number">#<%= pkg.card_number %></div> <div class="card-number"><%= pkg.card_number %> of <%= packages_total || '' %></div>
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div> <div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div> </div>
<div class="stats-row"> <div class="stats-row">
@@ -32,11 +31,11 @@
<div class="label">Total Scans</div> <div class="label">Total Scans</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="value"><%= pkg.first_scanner_name || '&mdash;' %></div> <div class="value"><%= pkg.first_scanner_name || '---' %></div>
<div class="label">First Finder</div> <div class="label">First Finder</div>
</div> </div>
<div class="stat-box"> <div class="stat-box">
<div class="value"><%= pkg.last_scanner_name || '&mdash;' %></div> <div class="value"><%= pkg.last_scanner_name || '---' %></div>
<div class="label">Most Recent</div> <div class="label">Most Recent</div>
</div> </div>
</div> </div>
@@ -114,7 +113,7 @@
<% } %> <% } %>
<div style="text-align: center; margin-top: 1rem;"> <div style="text-align: center; margin-top: 1rem;">
<a href="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-outline">View Package Profile</a> <a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" class="btn btn-outline">Package Profile</a>
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a> <a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a>
</div> </div>
</div> </div>