updates to most features
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
149
src/utils/pdf.js
149
src/utils/pdf.js
@@ -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();
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<td><%= pkg.first_scanner_name || '—' %></td>
|
||||
<td><%= pkg.last_scanner_name || '—' %></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>
|
||||
<% }) %>
|
||||
|
||||
@@ -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) { %>
|
||||
✅ Found · <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
|
||||
|
||||
@@ -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 || '—' %></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 || '—' %></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>
|
||||
|
||||
@@ -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 || '—' %></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 || '—' %></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>
|
||||
|
||||
Reference in New Issue
Block a user