diff --git a/prod-compose.yml b/prod-compose.yml index e2b3888..91fe38f 100644 --- a/prod-compose.yml +++ b/prod-compose.yml @@ -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 diff --git a/public/css/style.css b/public/css/style.css index 41fb0c5..f0b67f8 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } } diff --git a/src/models/index.js b/src/models/index.js index 7391e73..840bc8a 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -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.*, diff --git a/src/routes/hunts.js b/src/routes/hunts.js index 5613ab0..64d2ae0 100644 --- a/src/routes/hunts.js +++ b/src/routes/hunts.js @@ -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(); diff --git a/src/routes/loot.js b/src/routes/loot.js index a0fecd8..b4a6975 100644 --- a/src/routes/loot.js +++ b/src/routes/loot.js @@ -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; diff --git a/src/utils/pdf.js b/src/utils/pdf.js index e8ad7b1..3b1c1f2 100644 --- a/src/utils/pdf.js +++ b/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(); diff --git a/src/views/admin/manage-hunt.ejs b/src/views/admin/manage-hunt.ejs index 6cecf7d..881f112 100644 --- a/src/views/admin/manage-hunt.ejs +++ b/src/views/admin/manage-hunt.ejs @@ -61,7 +61,7 @@