updates to most features
All checks were successful
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

View File

@@ -1,12 +1,10 @@
version: "3.8"
services:
loot-hunt:
image: reg.dev.nervesocket.com/loot-hunt:latest
container_name: loot-hunt
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
- "6233:3000"
environment:
- NODE_ENV=production
- PORT=3000

View File

@@ -490,28 +490,123 @@ tr:hover {
/* ─── Responsive ──────────────────────────────────────── */
@media (max-width: 600px) {
.navbar {
padding: 0 0.75rem;
padding: 0 0.5rem;
height: 52px;
}
.container {
padding: 1rem;
.navbar-brand {
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 {
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 {
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 {
grid-template-columns: 1fr;
gap: 0.6rem;
}
.package-card {
padding: 0.75rem;
}
.package-card .card-num {
font-size: 1.2rem;
}
.hunt-card {
flex-direction: column;
align-items: flex-start;
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;
}
}

View File

@@ -141,6 +141,15 @@ const Packages = {
`).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) {
return db.prepare(`
SELECT p.*,

View File

@@ -32,6 +32,33 @@ router.get('/leaderboard', (req, res) => {
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 ─────────────────────────────────────
router.get('/hunts', (req, res) => {
const hunts = Hunts.getAll();

View File

@@ -61,40 +61,29 @@ router.get('/:shortName/:code', (req, res) => {
// Reload package with full profile
const fullPkg = Packages.getProfile(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 isLastScanner = fullPkg.last_scanned_by === req.session.userId;
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,
scanResult: result,
scanHistory,
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) => {
const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code);
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.' });
}
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
});
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
});
// ─── Upload first-scan image ──────────────────────────────
@@ -111,12 +100,12 @@ router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req
}
if (!req.file) {
return res.redirect(`/loot/${shortName}/${code}/profile`);
return res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
}
const imagePath = `/uploads/${req.file.filename}`;
Packages.updateFirstScanImage(pkg.id, imagePath);
res.redirect(`/loot/${shortName}/${code}/profile`);
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
});
// ─── Update hint/message ──────────────────────────────────
@@ -134,7 +123,7 @@ router.post('/:shortName/:code/hint', requireAuth, (req, res) => {
const hint = (req.body.hint || '').trim().substring(0, 500);
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
res.redirect(`/loot/${shortName}/${code}/profile`);
res.redirect(`/hunt/${shortName}/${pkg.card_number}`);
});
module.exports = router;

View File

@@ -3,75 +3,116 @@ const QRCode = require('qrcode');
/**
* 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) {
// 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({
size: 'LETTER',
margins: { top: 36, bottom: 36, left: 36, right: 36 }
margins: { top: 0, bottom: 0, left: 0, right: 0 }
});
doc.pipe(outputStream);
const pageWidth = 612 - 72; // letter width minus margins
const cardHeight = 200;
const qrSize = 160;
const cardsPerPage = 3;
// Process cards in chunks of cardsPerPage
const pageCount = Math.ceil(packages.length / cardsPerPage);
for (let i = 0; i < packages.length; i++) {
const pkg = packages[i];
const cardIndex = i % cardsPerPage;
for (let p = 0; p < pageCount; p++) {
const startIdx = p * cardsPerPage;
const pagePackages = packages.slice(startIdx, startIdx + cardsPerPage);
if (i > 0 && cardIndex === 0) {
doc.addPage();
// ──── FRONT PAGE ────
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
doc.save();
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc');
doc.restore();
const backLines = [
'THIS CARD IS UNIQUE',
'',
'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
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
const qrDataUrl = await QRCode.toDataURL(url, {
width: qrSize,
margin: 1,
errorCorrectionLevel: 'M'
});
for (let c = 0; c < pagePackages.length; c++) {
// Mirror columns: col 0 ↔ col 1 so backs align when flipped
const col = c % cols;
const mirroredCol = cols - 1 - col;
const row = Math.floor(c / cols);
const x = offsetX + mirroredCol * cardW;
const y = offsetY + row * cardH;
// Convert data URL to buffer for PDFKit
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
const startY = y + (cardH - blockH) / 2;
// QR code on the left
const qrX = 48;
const qrY = yOffset + (cardHeight - qrSize) / 2;
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
// 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.font('Courier-Bold').fontSize(9).fillColor('#333333');
backLines.forEach((line, li) => {
doc.text(line, x, startY + li * lineHeight, {
width: cardW,
align: 'center'
});
});
}
}
doc.end();

View File

@@ -61,7 +61,7 @@
<td><%= pkg.first_scanner_name || '&mdash;' %></td>
<td><%= pkg.last_scanner_name || '&mdash;' %></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>
</tr>
<% }) %>

View File

@@ -40,10 +40,9 @@
<div class="package-grid">
<% 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' %>">
<div class="card-num">#<%= pkg.card_number %></div>
<div class="code"><%= pkg.unique_code %></div>
<div class="card-num"><%= pkg.card_number %> of <%= hunt.package_count %></div>
<div class="scan-info">
<% if (pkg.scan_count > 0) { %>
&#x2705; Found &middot; <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>

View File

@@ -7,9 +7,8 @@
<div class="card">
<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 style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div>
<div class="stats-row">
@@ -18,11 +17,11 @@
<div class="label">Total Scans</div>
</div>
<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>
<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>
</div>
@@ -98,7 +97,7 @@
<% } %>
<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>
</div>
</div>

View File

@@ -21,9 +21,8 @@
<div class="card">
<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 style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div>
<div class="stats-row">
@@ -32,11 +31,11 @@
<div class="label">Total Scans</div>
</div>
<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>
<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>
</div>
@@ -114,7 +113,7 @@
<% } %>
<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>
</div>
</div>