first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
This commit is contained in:
150
src/app.js
Normal file
150
src/app.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Load .env if present
|
||||
try {
|
||||
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (match) {
|
||||
process.env[match[1].trim()] = match[2].trim();
|
||||
}
|
||||
});
|
||||
} catch (e) { /* .env file is optional */ }
|
||||
|
||||
// Ensure data dirs exist
|
||||
const dataDir = path.dirname(process.env.DB_PATH || './data/loot-hunt.db');
|
||||
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
|
||||
[dataDir, uploadsDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
// Initialize database (async — schema creation happens in background)
|
||||
const db = require('./config/database');
|
||||
|
||||
const { loadUser } = require('./middleware/auth');
|
||||
|
||||
// ─── SQLite Session Store ─────────────────────────────────
|
||||
class SQLiteSessionStore extends session.Store {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
get(sid, callback) {
|
||||
try {
|
||||
const row = db.prepare('SELECT sess FROM sessions WHERE sid = ? AND expired > datetime(\'now\')').get(sid);
|
||||
if (row) {
|
||||
callback(null, JSON.parse(row.sess));
|
||||
} else {
|
||||
callback(null, null);
|
||||
}
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
set(sid, sess, callback) {
|
||||
try {
|
||||
const maxAge = sess.cookie && sess.cookie.maxAge ? sess.cookie.maxAge : 86400000;
|
||||
const expiredDate = new Date(Date.now() + maxAge).toISOString();
|
||||
const sessStr = JSON.stringify(sess);
|
||||
// upsert
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
db.prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)').run(sid, sessStr, expiredDate);
|
||||
callback(null);
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
destroy(sid, callback) {
|
||||
try {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
callback(null);
|
||||
} catch (err) { callback(err); }
|
||||
}
|
||||
|
||||
// Clean up expired sessions periodically
|
||||
touch(sid, sess, callback) {
|
||||
this.set(sid, sess, callback);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Start app once DB is ready ───────────────────────────
|
||||
async function start() {
|
||||
await db.ready;
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// View engine
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
// Static files
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
app.use('/uploads', express.static(path.resolve(uploadsDir)));
|
||||
|
||||
// Sessions
|
||||
app.use(session({
|
||||
store: new SQLiteSessionStore(),
|
||||
secret: process.env.SESSION_SECRET || 'loot-hunt-dev-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.TRUST_PROXY === 'true'
|
||||
}
|
||||
}));
|
||||
|
||||
// Trust proxy if behind nginx
|
||||
if (process.env.TRUST_PROXY === 'true') {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
|
||||
// Load current user into all views
|
||||
app.use(loadUser);
|
||||
|
||||
// Flash-like messages via session
|
||||
app.use((req, res, next) => {
|
||||
res.locals.flash = req.session.flash || null;
|
||||
delete req.session.flash;
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/auth', require('./routes/auth'));
|
||||
app.use('/admin', require('./routes/admin'));
|
||||
app.use('/loot', require('./routes/loot'));
|
||||
app.use('/', require('./routes/hunts'));
|
||||
|
||||
// Home page
|
||||
app.get('/', (req, res) => {
|
||||
const { Hunts } = require('./models');
|
||||
const hunts = Hunts.getAll();
|
||||
res.render('home', { title: 'Loot Hunt', hunts });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).render('error', { title: 'Not Found', message: 'The page you are looking for does not exist.' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🎯 Loot Hunt running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
console.error('Failed to start:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
219
src/config/database.js
Normal file
219
src/config/database.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const dbPath = process.env.DB_PATH || './data/loot-hunt.db';
|
||||
const dbDir = path.dirname(dbPath);
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// ─── Compatibility wrapper ────────────────────────────────
|
||||
// Wraps sql.js to provide a better-sqlite3–compatible API so models
|
||||
// can call db.prepare(sql).get(...), .all(...), .run(...) seamlessly.
|
||||
|
||||
let _db = null; // raw sql.js Database instance
|
||||
let _saveTimer = null; // debounced persistence timer
|
||||
|
||||
function save() {
|
||||
if (!_db) return;
|
||||
const data = _db.export();
|
||||
fs.writeFileSync(dbPath, Buffer.from(data));
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (_saveTimer) return; // already scheduled
|
||||
_saveTimer = setTimeout(() => {
|
||||
_saveTimer = null;
|
||||
save();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/** Convert sql.js result set to array of plain objects */
|
||||
function rowsToObjects(stmt) {
|
||||
const cols = stmt.getColumnNames();
|
||||
const rows = [];
|
||||
while (stmt.step()) {
|
||||
const values = stmt.get();
|
||||
const obj = {};
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
obj[cols[i]] = values[i];
|
||||
}
|
||||
rows.push(obj);
|
||||
}
|
||||
stmt.free();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The public API that models import */
|
||||
const db = {
|
||||
/** Execute raw SQL (DDL, multi-statement) */
|
||||
exec(sql) {
|
||||
_db.run(sql);
|
||||
scheduleSave();
|
||||
},
|
||||
|
||||
/** Set a pragma */
|
||||
pragma(str) {
|
||||
_db.run(`PRAGMA ${str}`);
|
||||
},
|
||||
|
||||
/** Prepare a statement — returns object with get/all/run */
|
||||
prepare(sql) {
|
||||
return {
|
||||
/** Return first matching row as object, or undefined */
|
||||
get(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
const rows = rowsToObjects(stmt);
|
||||
return rows[0] || undefined;
|
||||
},
|
||||
|
||||
/** Return all matching rows as array of objects */
|
||||
all(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
return rowsToObjects(stmt);
|
||||
},
|
||||
|
||||
/** Execute a write statement; returns { lastInsertRowid, changes } */
|
||||
run(...params) {
|
||||
const stmt = _db.prepare(sql);
|
||||
if (params.length) stmt.bind(params);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
const changes = _db.getRowsModified();
|
||||
// sql.js uses last_insert_rowid() for the last rowid
|
||||
const idResult = _db.exec('SELECT last_insert_rowid() as id');
|
||||
const lastInsertRowid = idResult.length > 0 ? idResult[0].values[0][0] : 0;
|
||||
scheduleSave();
|
||||
return { lastInsertRowid, changes };
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/** Wrap a function in a transaction */
|
||||
transaction(fn) {
|
||||
return (...args) => {
|
||||
_db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
const result = fn(...args);
|
||||
_db.run('COMMIT');
|
||||
scheduleSave();
|
||||
return result;
|
||||
} catch (err) {
|
||||
_db.run('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/** Force an immediate save to disk */
|
||||
forceSave() {
|
||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Initialisation (synchronous-style via top-level await alternative) ───
|
||||
// sql.js init is async, so we expose a ready promise that app.js awaits.
|
||||
|
||||
let _readyResolve;
|
||||
const ready = new Promise(resolve => { _readyResolve = resolve; });
|
||||
|
||||
(async () => {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// Load existing DB from disk or create new
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
_db = new SQL.Database(fileBuffer);
|
||||
} else {
|
||||
_db = new SQL.Database();
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
_db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Initialize schema
|
||||
_db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hunts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
short_name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
package_count INTEGER NOT NULL,
|
||||
expiry_date DATETIME,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hunt_id INTEGER NOT NULL,
|
||||
card_number INTEGER NOT NULL,
|
||||
unique_code TEXT NOT NULL,
|
||||
first_scanned_by INTEGER,
|
||||
first_scan_image TEXT,
|
||||
last_scanned_by INTEGER,
|
||||
last_scan_hint TEXT,
|
||||
scan_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (first_scanned_by) REFERENCES users(id),
|
||||
FOREIGN KEY (last_scanned_by) REFERENCES users(id),
|
||||
UNIQUE(hunt_id, card_number),
|
||||
UNIQUE(hunt_id, unique_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS packages_idx1 (id INTEGER);
|
||||
DROP TABLE IF EXISTS packages_idx1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
package_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
points_awarded INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expired DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all
|
||||
// versions, so wrap in try/catch)
|
||||
const indexes = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_packages_hunt ON packages(hunt_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)'
|
||||
];
|
||||
for (const idx of indexes) {
|
||||
try { _db.run(idx); } catch (e) { /* index may already exist */ }
|
||||
}
|
||||
|
||||
save(); // persist initial schema
|
||||
_readyResolve();
|
||||
})();
|
||||
|
||||
db.ready = ready;
|
||||
module.exports = db;
|
||||
37
src/middleware/auth.js
Normal file
37
src/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Authentication middleware
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
// Save the original URL so we can redirect back after login
|
||||
req.session.returnTo = req.originalUrl;
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.userId && req.session.isAdmin) {
|
||||
return next();
|
||||
}
|
||||
if (req.session && req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'You do not have admin access.' });
|
||||
}
|
||||
req.session.returnTo = req.originalUrl;
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
function loadUser(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
res.locals.currentUser = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
isAdmin: req.session.isAdmin
|
||||
};
|
||||
} else {
|
||||
res.locals.currentUser = null;
|
||||
}
|
||||
res.locals.baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireAdmin, loadUser };
|
||||
246
src/models/index.js
Normal file
246
src/models/index.js
Normal file
@@ -0,0 +1,246 @@
|
||||
const db = require('../config/database');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function generateCode(length = 5) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I/O/0/1 to avoid confusion
|
||||
let code = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function getPointsForScanNumber(scanNumber) {
|
||||
if (scanNumber === 1) return 500;
|
||||
if (scanNumber === 2) return 250;
|
||||
if (scanNumber === 3) return 100;
|
||||
return 50;
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────
|
||||
const Users = {
|
||||
create(username, password) {
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||
const result = stmt.run(username, hash);
|
||||
return result.lastInsertRowid;
|
||||
},
|
||||
|
||||
findByUsername(username) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return db.prepare('SELECT id, username, is_admin, created_at FROM users WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
verifyPassword(user, password) {
|
||||
return bcrypt.compareSync(password, user.password_hash);
|
||||
},
|
||||
|
||||
makeAdmin(userId) {
|
||||
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
|
||||
},
|
||||
|
||||
getTotalPoints(userId) {
|
||||
const row = db.prepare('SELECT COALESCE(SUM(points_awarded), 0) as total FROM scans WHERE user_id = ?').get(userId);
|
||||
return row.total;
|
||||
},
|
||||
|
||||
getProfile(userId) {
|
||||
const user = this.findById(userId);
|
||||
if (!user) return null;
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Hunts ────────────────────────────────────────────────
|
||||
const Hunts = {
|
||||
create(name, shortName, description, packageCount, expiryDate, createdBy) {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy);
|
||||
const huntId = result.lastInsertRowid;
|
||||
|
||||
// Generate packages
|
||||
const insertPkg = db.prepare('INSERT INTO packages (hunt_id, card_number, unique_code) VALUES (?, ?, ?)');
|
||||
const usedCodes = new Set();
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (let i = 1; i <= packageCount; i++) {
|
||||
let code;
|
||||
do {
|
||||
code = generateCode(5);
|
||||
} while (usedCodes.has(code));
|
||||
usedCodes.add(code);
|
||||
insertPkg.run(huntId, i, code);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return huntId;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByShortName(shortName) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||
},
|
||||
|
||||
getAll() {
|
||||
return db.prepare('SELECT h.*, u.username as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all();
|
||||
},
|
||||
|
||||
getByCreator(userId) {
|
||||
return db.prepare('SELECT * FROM hunts WHERE created_by = ? ORDER BY created_at DESC').all(userId);
|
||||
},
|
||||
|
||||
isExpired(hunt) {
|
||||
if (!hunt.expiry_date) return false;
|
||||
return new Date(hunt.expiry_date) < new Date();
|
||||
},
|
||||
|
||||
getLeaderboard(huntId) {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
JOIN packages p ON s.package_id = p.id
|
||||
WHERE p.hunt_id = ? AND s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all(huntId);
|
||||
},
|
||||
|
||||
shortNameExists(shortName) {
|
||||
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
|
||||
return !!row;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Packages ─────────────────────────────────────────────
|
||||
const Packages = {
|
||||
findById(id) {
|
||||
return db.prepare('SELECT * FROM packages WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByHuntAndCode(shortName, uniqueCode) {
|
||||
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
|
||||
FROM packages p
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
WHERE h.short_name = ? COLLATE NOCASE AND p.unique_code = ? COLLATE NOCASE
|
||||
`).get(shortName, uniqueCode);
|
||||
},
|
||||
|
||||
getByHunt(huntId) {
|
||||
return db.prepare(`
|
||||
SELECT p.*,
|
||||
u1.username as first_scanner_name,
|
||||
u2.username as last_scanner_name
|
||||
FROM packages p
|
||||
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
|
||||
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
|
||||
WHERE p.hunt_id = ?
|
||||
ORDER BY p.card_number ASC
|
||||
`).all(huntId);
|
||||
},
|
||||
|
||||
getProfile(packageId) {
|
||||
return db.prepare(`
|
||||
SELECT p.*,
|
||||
h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id,
|
||||
u1.username as first_scanner_name,
|
||||
u2.username as last_scanner_name
|
||||
FROM packages p
|
||||
JOIN hunts h ON p.hunt_id = h.id
|
||||
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
|
||||
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
|
||||
WHERE p.id = ?
|
||||
`).get(packageId);
|
||||
},
|
||||
|
||||
getScanHistory(packageId) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, u.username
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.package_id = ?
|
||||
ORDER BY s.scanned_at ASC
|
||||
`).all(packageId);
|
||||
},
|
||||
|
||||
updateFirstScanImage(packageId, imagePath) {
|
||||
db.prepare('UPDATE packages SET first_scan_image = ? WHERE id = ?').run(imagePath, packageId);
|
||||
},
|
||||
|
||||
updateLastScanHint(packageId, userId, hint) {
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ?, last_scan_hint = ? WHERE id = ?').run(userId, hint, packageId);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Scans ────────────────────────────────────────────────
|
||||
const Scans = {
|
||||
hasUserScanned(packageId, userId) {
|
||||
const row = db.prepare('SELECT id FROM scans WHERE package_id = ? AND user_id = ? AND points_awarded > 0').get(packageId, userId);
|
||||
return !!row;
|
||||
},
|
||||
|
||||
recordScan(packageId, userId) {
|
||||
const pkg = Packages.findById(packageId);
|
||||
if (!pkg) return { error: 'Package not found' };
|
||||
|
||||
const alreadyScanned = this.hasUserScanned(packageId, userId);
|
||||
|
||||
if (alreadyScanned) {
|
||||
// No points, but update last_scanned_by so they can edit the hint
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ? WHERE id = ?').run(userId, packageId);
|
||||
// Record the scan with 0 points
|
||||
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, 0)').run(packageId, userId);
|
||||
return { points: 0, alreadyScanned: true, isFirst: false };
|
||||
}
|
||||
|
||||
const scanNumber = pkg.scan_count + 1;
|
||||
const points = getPointsForScanNumber(scanNumber);
|
||||
|
||||
const doScan = db.transaction(() => {
|
||||
// Record the scan
|
||||
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, ?)').run(packageId, userId, points);
|
||||
|
||||
// Update package
|
||||
const isFirst = scanNumber === 1;
|
||||
if (isFirst) {
|
||||
db.prepare('UPDATE packages SET first_scanned_by = ?, last_scanned_by = ?, scan_count = ? WHERE id = ?')
|
||||
.run(userId, userId, scanNumber, packageId);
|
||||
} else {
|
||||
db.prepare('UPDATE packages SET last_scanned_by = ?, scan_count = ? WHERE id = ?')
|
||||
.run(userId, scanNumber, packageId);
|
||||
}
|
||||
|
||||
return { points, alreadyScanned: false, isFirst, scanNumber };
|
||||
});
|
||||
|
||||
return doScan();
|
||||
},
|
||||
|
||||
getGlobalLeaderboard() {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
|
||||
FROM scans s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.points_awarded > 0
|
||||
GROUP BY u.id
|
||||
ORDER BY total_points DESC
|
||||
`).all();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { Users, Hunts, Packages, Scans, generateCode };
|
||||
96
src/routes/admin.js
Normal file
96
src/routes/admin.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
const { Hunts, Packages } = require('../models');
|
||||
const { generateHuntPDF } = require('../utils/pdf');
|
||||
|
||||
// All admin routes require admin access
|
||||
router.use(requireAdmin);
|
||||
|
||||
// Admin dashboard
|
||||
router.get('/', (req, res) => {
|
||||
const hunts = Hunts.getByCreator(req.session.userId);
|
||||
res.render('admin/dashboard', { title: 'Admin Dashboard', hunts });
|
||||
});
|
||||
|
||||
// Create hunt form
|
||||
router.get('/hunts/new', (req, res) => {
|
||||
res.render('admin/create-hunt', { title: 'Create New Hunt', error: null });
|
||||
});
|
||||
|
||||
// Create hunt
|
||||
router.post('/hunts', (req, res) => {
|
||||
const { name, short_name, description, package_count, expiry_date } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name || !short_name || !package_count) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Name, short name, and number of packages are required.'
|
||||
});
|
||||
}
|
||||
|
||||
const shortName = short_name.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
if (shortName.length < 2 || shortName.length > 12) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Short name must be 2-12 uppercase alphanumeric characters.'
|
||||
});
|
||||
}
|
||||
|
||||
if (Hunts.shortNameExists(shortName)) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'That short name is already taken.'
|
||||
});
|
||||
}
|
||||
|
||||
const count = parseInt(package_count, 10);
|
||||
if (isNaN(count) || count < 1 || count > 10000) {
|
||||
return res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Package count must be between 1 and 10,000.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
|
||||
res.redirect(`/admin/hunts/${huntId}`);
|
||||
} catch (err) {
|
||||
console.error('Hunt creation error:', err);
|
||||
res.render('admin/create-hunt', {
|
||||
title: 'Create New Hunt',
|
||||
error: 'Failed to create hunt. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Manage hunt
|
||||
router.get('/hunts/:id', (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages });
|
||||
});
|
||||
|
||||
// Download PDF of QR codes
|
||||
router.get('/hunts/:id/pdf', async (req, res) => {
|
||||
const hunt = Hunts.findById(req.params.id);
|
||||
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
|
||||
try {
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${hunt.short_name}-packages.pdf"`);
|
||||
|
||||
await generateHuntPDF(hunt, packages, baseUrl, res);
|
||||
} catch (err) {
|
||||
console.error('PDF generation error:', err);
|
||||
res.status(500).render('error', { title: 'Error', message: 'Failed to generate PDF.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
82
src/routes/auth.js
Normal file
82
src/routes/auth.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Users } = require('../models');
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
res.render('auth/login', { title: 'Login', error: null });
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.render('auth/login', { title: 'Login', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
const user = Users.findByUsername(username);
|
||||
if (!user || !Users.verifyPassword(user, password)) {
|
||||
return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' });
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.isAdmin = !!user.is_admin;
|
||||
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
res.redirect(returnTo);
|
||||
});
|
||||
|
||||
router.get('/register', (req, res) => {
|
||||
res.render('auth/register', { title: 'Register', error: null });
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { username, password, password_confirm } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username and password are required.' });
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 24) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username must be 3-24 characters.' });
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username can only contain letters, numbers, hyphens and underscores.' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Password must be at least 6 characters.' });
|
||||
}
|
||||
|
||||
if (password !== password_confirm) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Passwords do not match.' });
|
||||
}
|
||||
|
||||
if (Users.findByUsername(username)) {
|
||||
return res.render('auth/register', { title: 'Register', error: 'Username is already taken.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = Users.create(username, password);
|
||||
req.session.userId = userId;
|
||||
req.session.username = username;
|
||||
req.session.isAdmin = false;
|
||||
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
res.redirect(returnTo);
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err);
|
||||
res.render('auth/register', { title: 'Register', error: 'Registration failed. Try a different username.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/');
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
41
src/routes/hunts.js
Normal file
41
src/routes/hunts.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Hunts, Packages, Scans } = require('../models');
|
||||
|
||||
// ─── Hunt profile ─────────────────────────────────────────
|
||||
router.get('/hunt/:shortName', (req, res) => {
|
||||
const hunt = Hunts.findByShortName(req.params.shortName);
|
||||
if (!hunt) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
}
|
||||
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const isExpired = Hunts.isExpired(hunt);
|
||||
|
||||
res.render('hunt/profile', { title: hunt.name, hunt, packages, isExpired });
|
||||
});
|
||||
|
||||
// ─── Hunt leaderboard ─────────────────────────────────────
|
||||
router.get('/hunt/:shortName/leaderboard', (req, res) => {
|
||||
const hunt = Hunts.findByShortName(req.params.shortName);
|
||||
if (!hunt) {
|
||||
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
|
||||
}
|
||||
|
||||
const leaderboard = Hunts.getLeaderboard(hunt.id);
|
||||
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard });
|
||||
});
|
||||
|
||||
// ─── Global leaderboard ──────────────────────────────────
|
||||
router.get('/leaderboard', (req, res) => {
|
||||
const leaderboard = Scans.getGlobalLeaderboard();
|
||||
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
|
||||
});
|
||||
|
||||
// ─── Browse all hunts ─────────────────────────────────────
|
||||
router.get('/hunts', (req, res) => {
|
||||
const hunts = Hunts.getAll();
|
||||
res.render('hunt/list', { title: 'All Hunts', hunts });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
140
src/routes/loot.js
Normal file
140
src/routes/loot.js
Normal file
@@ -0,0 +1,140 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { Packages, Scans, Hunts } = require('../models');
|
||||
|
||||
// Configure multer for image uploads
|
||||
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const name = `pkg-${req.params.shortName}-${req.params.code}-${Date.now()}${ext}`;
|
||||
cb(null, name);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowed.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Scan a package (QR code landing page) ────────────────
|
||||
router.get('/:shortName/:code', (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.' });
|
||||
}
|
||||
|
||||
// Check if hunt is expired
|
||||
if (pkg.expiry_date && new Date(pkg.expiry_date) < new Date()) {
|
||||
return res.render('loot/expired', { title: 'Hunt Expired', pkg });
|
||||
}
|
||||
|
||||
// If not logged in, save this URL and redirect to auth
|
||||
if (!req.session.userId) {
|
||||
req.session.returnTo = req.originalUrl;
|
||||
return res.redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const result = Scans.recordScan(pkg.id, req.session.userId);
|
||||
|
||||
// Reload package with full profile
|
||||
const fullPkg = Packages.getProfile(pkg.id);
|
||||
const scanHistory = Packages.getScanHistory(pkg.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}`,
|
||||
pkg: fullPkg,
|
||||
scanResult: result,
|
||||
scanHistory,
|
||||
isFirstScanner,
|
||||
isLastScanner
|
||||
});
|
||||
});
|
||||
|
||||
// ─── View package profile (non-scan view) ─────────────────
|
||||
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.' });
|
||||
}
|
||||
|
||||
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 ──────────────────────────────
|
||||
router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (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: 'Package not found.' });
|
||||
}
|
||||
|
||||
if (pkg.first_scanned_by !== req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the first scanner can upload an image.' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
}
|
||||
|
||||
const imagePath = `/uploads/${req.file.filename}`;
|
||||
Packages.updateFirstScanImage(pkg.id, imagePath);
|
||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
});
|
||||
|
||||
// ─── Update hint/message ──────────────────────────────────
|
||||
router.post('/:shortName/:code/hint', requireAuth, (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: 'Package not found.' });
|
||||
}
|
||||
|
||||
if (pkg.last_scanned_by !== req.session.userId) {
|
||||
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the most recent scanner can update the hint.' });
|
||||
}
|
||||
|
||||
const hint = (req.body.hint || '').trim().substring(0, 500);
|
||||
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
|
||||
res.redirect(`/loot/${shortName}/${code}/profile`);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
55
src/setup-admin.js
Normal file
55
src/setup-admin.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Admin Setup Script
|
||||
*
|
||||
* Run this to create the first admin user (or promote an existing one).
|
||||
* Usage: node src/setup-admin.js <username> <password>
|
||||
* node src/setup-admin.js <username> (promote existing user)
|
||||
*/
|
||||
|
||||
// Load env
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
try {
|
||||
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const match = line.match(/^([^#=]+)=(.*)$/);
|
||||
if (match) process.env[match[1].trim()] = match[2].trim();
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
const db = require('./config/database');
|
||||
|
||||
async function main() {
|
||||
await db.ready;
|
||||
|
||||
const { Users } = require('./models');
|
||||
|
||||
const username = process.argv[2];
|
||||
const password = process.argv[3];
|
||||
|
||||
if (!username) {
|
||||
console.log('Usage:');
|
||||
console.log(' Create new admin: node src/setup-admin.js <username> <password>');
|
||||
console.log(' Promote existing: node src/setup-admin.js <username>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = Users.findByUsername(username);
|
||||
|
||||
if (existing) {
|
||||
Users.makeAdmin(existing.id);
|
||||
console.log(`✅ User "${username}" has been promoted to admin.`);
|
||||
} else if (password) {
|
||||
const userId = Users.create(username, password);
|
||||
Users.makeAdmin(userId);
|
||||
console.log(`✅ Admin user "${username}" created successfully (ID: ${userId}).`);
|
||||
} else {
|
||||
console.log(`❌ User "${username}" not found. Provide a password to create a new admin.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.forceSave();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
80
src/utils/pdf.js
Normal file
80
src/utils/pdf.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const PDFDocument = require('pdfkit');
|
||||
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.
|
||||
*/
|
||||
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
|
||||
const doc = new PDFDocument({
|
||||
size: 'LETTER',
|
||||
margins: { top: 36, bottom: 36, left: 36, right: 36 }
|
||||
});
|
||||
|
||||
doc.pipe(outputStream);
|
||||
|
||||
const pageWidth = 612 - 72; // letter width minus margins
|
||||
const cardHeight = 200;
|
||||
const qrSize = 160;
|
||||
const cardsPerPage = 3;
|
||||
|
||||
for (let i = 0; i < packages.length; i++) {
|
||||
const pkg = packages[i];
|
||||
const cardIndex = i % cardsPerPage;
|
||||
|
||||
if (i > 0 && cardIndex === 0) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
const yOffset = 36 + cardIndex * (cardHeight + 24);
|
||||
|
||||
// Draw card border
|
||||
doc.save();
|
||||
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc');
|
||||
doc.restore();
|
||||
|
||||
// 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'
|
||||
});
|
||||
|
||||
// Convert data URL to buffer for PDFKit
|
||||
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
|
||||
|
||||
// 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.end();
|
||||
}
|
||||
|
||||
module.exports = { generateHuntPDF };
|
||||
48
src/views/admin/create-hunt.ejs
Normal file
48
src/views/admin/create-hunt.ejs
Normal file
@@ -0,0 +1,48 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 2rem;">
|
||||
<h1 style="margin-bottom: 1.5rem;">Create New Hunt</h1>
|
||||
|
||||
<div class="card">
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/admin/hunts">
|
||||
<div class="form-group">
|
||||
<label for="name">Hunt Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required placeholder="e.g. Veld Music Festival 2026">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="short_name">Short Name (Code)</label>
|
||||
<input type="text" id="short_name" name="short_name" class="form-control" required
|
||||
maxlength="12" placeholder="e.g. VELD26" style="text-transform: uppercase; font-family: monospace; font-size: 1.1rem;">
|
||||
<div class="form-hint">2-12 uppercase alphanumeric characters. Used in QR code URLs.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3"
|
||||
placeholder="Describe this hunt for participants..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="package_count">Number of Hidden Packages</label>
|
||||
<input type="number" id="package_count" name="package_count" class="form-control"
|
||||
required min="1" max="10000" placeholder="e.g. 50">
|
||||
<div class="form-hint">Each package gets a unique QR code and printable card.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiry_date">Expiry Date (optional)</label>
|
||||
<input type="datetime-local" id="expiry_date" name="expiry_date" class="form-control">
|
||||
<div class="form-hint">Leave blank for no expiry.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Hunt</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
29
src/views/admin/dashboard.ejs
Normal file
29
src/views/admin/dashboard.ejs
Normal file
@@ -0,0 +1,29 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<a href="/admin/hunts/new" class="btn btn-primary">+ New Hunt</a>
|
||||
</div>
|
||||
|
||||
<% if (hunts.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted); font-size: 1.1rem;">You haven't created any hunts yet.</p>
|
||||
<a href="/admin/hunts/new" class="btn btn-primary" style="margin-top: 1rem;">Create Your First Hunt</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/admin/hunts/<%= hunt.id %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages
|
||||
<% if (hunt.expiry_date) { %> · Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %><% } %>
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge">Manage</span>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
73
src/views/admin/manage-hunt.ejs
Normal file
73
src/views/admin/manage-hunt.ejs
Normal file
@@ -0,0 +1,73 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
<h1 style="margin: 0;"><%= hunt.name %></h1>
|
||||
<span style="color: var(--muted); font-family: monospace; font-size: 1rem;"><%= hunt.short_name %></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="/admin/hunts/<%= hunt.id %>/pdf" class="btn btn-success">📥 Download PDF</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="btn btn-outline">View Public Page</a>
|
||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunt.description) { %>
|
||||
<div class="card">
|
||||
<p style="margin: 0; color: var(--muted);"><%= hunt.description %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.package_count %></div>
|
||||
<div class="label">Packages</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
|
||||
<div class="label">Found</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.reduce((sum, p) => sum + p.scan_count, 0) %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '—' %></div>
|
||||
<div class="label">Expires</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Code</th>
|
||||
<th>Scans</th>
|
||||
<th>First Scanner</th>
|
||||
<th>Last Scanner</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% packages.forEach(pkg => { %>
|
||||
<tr>
|
||||
<td><strong><%= pkg.card_number %></strong></td>
|
||||
<td style="font-family: monospace;"><%= pkg.unique_code %></td>
|
||||
<td><%= pkg.scan_count %></td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
31
src/views/auth/login.ejs
Normal file
31
src/views/auth/login.ejs
Normal file
@@ -0,0 +1,31 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 3rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">Login</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/auth/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Login</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
|
||||
Don't have an account? <a href="/auth/register">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
39
src/views/auth/register.ejs
Normal file
39
src/views/auth/register.ejs
Normal file
@@ -0,0 +1,39 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container-narrow" style="padding-top: 3rem;">
|
||||
<div class="card">
|
||||
<div class="card-header">Register</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="alert alert-danger error"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/auth/register">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus
|
||||
minlength="3" maxlength="24" pattern="[a-zA-Z0-9_-]+" autocomplete="username">
|
||||
<div class="form-hint">3-24 characters: letters, numbers, hyphens, underscores</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required minlength="6" autocomplete="new-password">
|
||||
<div class="form-hint">At least 6 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required minlength="6" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Account</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
|
||||
Already have an account? <a href="/auth/login">Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
11
src/views/error.ejs
Normal file
11
src/views/error.ejs
Normal file
@@ -0,0 +1,11 @@
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<div class="container" style="display: flex; align-items: center; justify-content: center; min-height: 60vh;">
|
||||
<div class="card" style="text-align: center; max-width: 400px;">
|
||||
<h1 style="font-size: 3rem; margin-bottom: 0.5rem;"><%= typeof title !== 'undefined' ? title : 'Error' %></h1>
|
||||
<p style="color: var(--muted); margin-bottom: 1.5rem;"><%= typeof message !== 'undefined' ? message : 'Something went wrong.' %></p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
52
src/views/home.ejs
Normal file
52
src/views/home.ejs
Normal file
@@ -0,0 +1,52 @@
|
||||
<%- include('partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Find. Scan. Conquer.</h1>
|
||||
<p>Hunt for hidden QR codes in the real world, earn points, climb the leaderboard. Be the first to find a package for maximum points!</p>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="/hunts" class="btn btn-primary">Browse Hunts</a>
|
||||
<a href="/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value">500</div>
|
||||
<div class="label">1st Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">250</div>
|
||||
<div class="label">2nd Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">100</div>
|
||||
<div class="label">3rd Find</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">50</div>
|
||||
<div class="label">4th+ Finds</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunts && hunts.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Active Hunts</h2>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages · by <%= hunt.creator_name %></span>
|
||||
</div>
|
||||
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
|
||||
<span class="badge expired">Expired</span>
|
||||
<% } else { %>
|
||||
<span class="badge"><%= hunt.package_count %> packages</span>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
42
src/views/hunt/leaderboard.ejs
Normal file
42
src/views/hunt/leaderboard.ejs
Normal file
@@ -0,0 +1,42 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="/hunt/<%= hunt.short_name %>" style="color: var(--muted); text-decoration: none;">← Back to <%= hunt.name %></a>
|
||||
</div>
|
||||
|
||||
<h1><%= hunt.name %> — Leaderboard</h1>
|
||||
|
||||
<% if (leaderboard.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No scans yet. Be the first to find a package!</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Scans</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>"><%= i + 1 %></td>
|
||||
<td><strong><%= entry.username %></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
<td><%= entry.scans %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
31
src/views/hunt/list.ejs
Normal file
31
src/views/hunt/list.ejs
Normal file
@@ -0,0 +1,31 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<h1 style="margin-bottom: 1.5rem;">All Hunts</h1>
|
||||
|
||||
<% if (hunts.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No hunts have been created yet.</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
|
||||
<div class="hunt-info">
|
||||
<h3><%= hunt.name %></h3>
|
||||
<span class="meta"><%= hunt.short_name %> · <%= hunt.package_count %> packages · by <%= hunt.creator_name %>
|
||||
<% if (hunt.expiry_date) { %>
|
||||
· Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %>
|
||||
<% } %>
|
||||
</span>
|
||||
</div>
|
||||
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
|
||||
<span class="badge expired">Expired</span>
|
||||
<% } else { %>
|
||||
<span class="badge"><%= hunt.package_count %> packages</span>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
65
src/views/hunt/profile.ejs
Normal file
65
src/views/hunt/profile.ejs
Normal file
@@ -0,0 +1,65 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<h1 style="margin: 0;"><%= hunt.name %></h1>
|
||||
<span style="color: var(--muted); font-family: monospace;"><%= hunt.short_name %></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-primary">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunt.description) { %>
|
||||
<div class="card">
|
||||
<p style="margin: 0;"><%= hunt.description %></p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (isExpired) { %>
|
||||
<div class="alert alert-danger">This hunt has expired. Scanning is no longer available.</div>
|
||||
<% } %>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= hunt.package_count %></div>
|
||||
<div class="label">Packages</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
|
||||
<div class="label">Found</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= packages.filter(p => p.scan_count === 0).length %></div>
|
||||
<div class="label">Unfound</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">Packages</h2>
|
||||
|
||||
<div class="package-grid">
|
||||
<% packages.forEach(pkg => { %>
|
||||
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile"
|
||||
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="scan-info">
|
||||
<% if (pkg.scan_count > 0) { %>
|
||||
✅ Found · <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
|
||||
<% if (pkg.first_scanner_name) { %> · First: <%= pkg.first_scanner_name %><% } %>
|
||||
<% } else { %>
|
||||
🔍 Not yet found
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<div style="margin-top: 0.5rem; font-size: 0.85rem; font-style: italic; color: #555;">
|
||||
"<%= pkg.last_scan_hint %>"
|
||||
</div>
|
||||
<% } %>
|
||||
</a>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
40
src/views/leaderboard/global.ejs
Normal file
40
src/views/leaderboard/global.ejs
Normal file
@@ -0,0 +1,40 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<h1 style="margin-bottom: 1.5rem;">🏆 Global Leaderboard</h1>
|
||||
|
||||
<% if (leaderboard.length === 0) { %>
|
||||
<div class="card" style="text-align: center; padding: 3rem;">
|
||||
<p style="color: var(--muted);">No scans recorded yet. Start hunting!</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Packages Found</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% leaderboard.forEach((entry, i) => { %>
|
||||
<tr>
|
||||
<td class="rank-cell rank-<%= i + 1 %>">
|
||||
<% if (i === 0) { %>🥇<% } else if (i === 1) { %>🥈<% } else if (i === 2) { %>🥉<% } else { %><%= i + 1 %><% } %>
|
||||
</td>
|
||||
<td><strong><%= entry.username %></strong></td>
|
||||
<td><span class="points-badge"><%= entry.total_points %></span></td>
|
||||
<td><%= entry.scans %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
13
src/views/loot/expired.ejs
Normal file
13
src/views/loot/expired.ejs
Normal file
@@ -0,0 +1,13 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container" style="text-align: center; padding-top: 3rem;">
|
||||
<div class="emoji" style="font-size: 4rem;">⏰</div>
|
||||
<h1>Hunt Expired</h1>
|
||||
<p style="color: var(--muted);">This hunt has ended. Scanning is no longer available.</p>
|
||||
<p style="color: var(--muted);">
|
||||
<strong><%= pkg.hunt_name %></strong> · Package #<%= pkg.card_number %>
|
||||
</p>
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline" style="margin-top: 1rem;">View Hunt Results</a>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
106
src/views/loot/profile.ejs
Normal file
106
src/views/loot/profile.ejs
Normal file
@@ -0,0 +1,106 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<a href="/hunt/<%= pkg.hunt_short_name %>" style="color: var(--muted); text-decoration: none;">← Back to <%= pkg.hunt_name %></a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="package-hero">
|
||||
<div class="card-number">#<%= pkg.card_number %></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">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.scan_count %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<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="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 First Finder's Photo</div>
|
||||
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* First scanner can upload image */%>
|
||||
<% if (isFirstScanner && !pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 Upload a Photo</div>
|
||||
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="file" name="image" accept="image/*" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload Photo</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
|
||||
<% if (isLastScanner) { %>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label>Update Hint/Message</label>
|
||||
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (scanHistory.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Scan History</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<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 %>/leaderboard" class="btn btn-outline">Leaderboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
122
src/views/loot/scanned.ejs
Normal file
122
src/views/loot/scanned.ejs
Normal file
@@ -0,0 +1,122 @@
|
||||
<%- include('../partials/header') %>
|
||||
|
||||
<div class="container">
|
||||
<div class="scan-result">
|
||||
<% if (scanResult.alreadyScanned) { %>
|
||||
<div class="emoji">🔄</div>
|
||||
<h1>Already Scanned!</h1>
|
||||
<p style="color: var(--muted);">You've already found this package. No additional points, but you're now the most recent scanner and can update the hint below.</p>
|
||||
<% } else if (scanResult.isFirst) { %>
|
||||
<div class="emoji">🌟</div>
|
||||
<h1>FIRST FIND!</h1>
|
||||
<div class="points-badge large">+<%= scanResult.points %> pts</div>
|
||||
<p style="color: var(--muted); margin-top: 0.5rem;">You're the first person to discover this package! You can upload an image below.</p>
|
||||
<% } else { %>
|
||||
<div class="emoji">✅</div>
|
||||
<h1>Package Found!</h1>
|
||||
<div class="points-badge large">+<%= scanResult.points %> pts</div>
|
||||
<p style="color: var(--muted); margin-top: 0.5rem;">Scan #<%= scanResult.scanNumber %> — nice find!</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="package-hero">
|
||||
<div class="card-number">#<%= pkg.card_number %></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">
|
||||
<div class="stat-box">
|
||||
<div class="value"><%= pkg.scan_count %></div>
|
||||
<div class="label">Total Scans</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<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="label">Most Recent</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">First Finder's Photo</div>
|
||||
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* First scanner can upload image */%>
|
||||
<% if (isFirstScanner && !pkg.first_scan_image) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">📸 Upload a Photo</div>
|
||||
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="file" name="image" accept="image/*" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload Photo</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<%/* Hint/message section */%>
|
||||
<div class="card">
|
||||
<div class="card-header">💬 Package Hint</div>
|
||||
<% if (pkg.last_scan_hint) { %>
|
||||
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
|
||||
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
|
||||
<% } else { %>
|
||||
<p style="color: var(--muted);">No hint has been left yet.</p>
|
||||
<% } %>
|
||||
|
||||
<% if (isLastScanner) { %>
|
||||
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label>Update Hint/Message</label>
|
||||
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%/* Scan history */%>
|
||||
<% if (scanHistory.length > 0) { %>
|
||||
<div class="card">
|
||||
<div class="card-header">Scan History</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Player</th>
|
||||
<th>Points</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% scanHistory.forEach((scan, i) => { %>
|
||||
<tr>
|
||||
<td><%= i + 1 %></td>
|
||||
<td><%= scan.username %></td>
|
||||
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
|
||||
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<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 %>" class="btn btn-outline">Back to Hunt</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('../partials/footer') %>
|
||||
5
src/views/partials/footer.ejs
Normal file
5
src/views/partials/footer.ejs
Normal file
@@ -0,0 +1,5 @@
|
||||
<footer class="footer">
|
||||
© <%= new Date().getFullYear() %> Loot Hunt — Find. Scan. Conquer.
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
30
src/views/partials/header.ejs
Normal file
30
src/views/partials/header.ejs
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">🎯 Loot Hunt</a>
|
||||
<ul class="navbar-nav">
|
||||
<li><a href="/hunts">Hunts</a></li>
|
||||
<li><a href="/leaderboard">Leaderboard</a></li>
|
||||
<% if (currentUser) { %>
|
||||
<% if (currentUser.isAdmin) { %>
|
||||
<li><a href="/admin">Admin</a></li>
|
||||
<% } %>
|
||||
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
|
||||
<% } else { %>
|
||||
<li><a href="/auth/login">Login</a></li>
|
||||
<li><a href="/auth/register">Register</a></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</nav>
|
||||
<% if (typeof flash !== 'undefined' && flash) { %>
|
||||
<div class="container">
|
||||
<div class="alert alert-<%= flash.type %>"><%= flash.message %></div>
|
||||
</div>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user