add built in scanner

This commit is contained in:
2026-02-28 02:26:04 -05:00
parent b6cd483401
commit 10cb8048a0
5 changed files with 323 additions and 1 deletions

View File

@@ -590,7 +590,7 @@ tr:hover {
background: linear-gradient(135deg, var(--card-bg) 0%, #0d2618 100%);
}
[data-theme="dark"] a:not(.btn) {
[data-theme="dark"] a:not(.btn):not(.scanner-fab) {
color: var(--primary);
}
@@ -800,3 +800,45 @@ tr:hover {
min-height: 36px;
}
}
/* ── Floating QR Scanner FAB ──────────────────────────────── */
.scanner-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--primary);
color: #000;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
text-decoration: none;
z-index: 100;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.scanner-fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
color: #fff;
}
.scanner-fab:active {
transform: scale(0.95);
}
/* ── Scanner disclaimer on home page ─────────────────────── */
.scanner-disclaimer {
background: var(--card-bg);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
border-radius: 8px;
padding: 1rem;
}
[data-theme="dark"] .scanner-disclaimer {
border-left-color: var(--primary);
}

View File

@@ -121,6 +121,11 @@ async function start() {
app.use('/loot', require('./routes/loot'));
app.use('/', require('./routes/hunts'));
// QR Scanner
app.get('/scanner', (req, res) => {
res.render('scanner', { title: 'QR Scanner' });
});
// Home page
app.get('/', (req, res) => {
const { Hunts, Scans } = require('./models');

View File

@@ -34,6 +34,18 @@
</div>
</div>
<div style="margin-top: 3rem;">
<div class="scanner-disclaimer">
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<span style="font-size: 1.5rem; flex-shrink: 0;">&#x1F6E1;&#xFE0F;</span>
<div>
<strong>Scan QR Codes Safely</strong>
<p style="margin: 0.25rem 0 0.5rem; color: var(--muted); font-size: 0.9rem;">Not all QR codes can be trusted. Use our <a href="/scanner" style="color: var(--primary); font-weight: 600;">built-in QR scanner</a> to safely scan codes and warn you about invalid ones - at least as far as collecting points is concerned. &#x1F3C6;</p>
</div>
</div>
</div>
</div>
<% if (hunts && hunts.length > 0) { %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Active Hunts</h2>
<% hunts.forEach(hunt => { %>

View File

@@ -1,6 +1,20 @@
<footer class="footer">
&copy; <%= new Date().getFullYear() %> Loot Hunt &mdash; Find. Scan. Conquer.
</footer>
<!-- Floating QR Scanner Button -->
<a href="/scanner" class="scanner-fab" aria-label="Open QR Scanner" title="Scan QR Code">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
<rect x="14" y="14" width="3" height="3"></rect>
<line x1="21" y1="14" x2="21" y2="14.01"></line>
<line x1="21" y1="21" x2="21" y2="21.01"></line>
<line x1="17" y1="21" x2="17" y2="21.01"></line>
<line x1="17" y1="17" x2="21" y2="17"></line>
</svg>
</a>
<script src="/js/timeago.js"></script>
<script>
function setThemeIcons(isDark) {

249
src/views/scanner.ejs Normal file
View File

@@ -0,0 +1,249 @@
<%- include('partials/header') %>
<div id="scanner-page">
<div id="scanner-ui">
<div id="scanner-header">
<a href="/" class="btn btn-sm btn-outline" style="color: #fff; border-color: rgba(255,255,255,0.3);">&larr; Back</a>
<span style="font-weight: 700; font-size: 1.1rem;">QR Scanner</span>
<button id="camera-flip" class="btn btn-sm btn-outline" style="color: #fff; border-color: rgba(255,255,255,0.3);" title="Switch camera">&#x1F504;</button>
</div>
<div id="scanner-viewport">
<video id="scanner-video" autoplay playsinline muted></video>
<canvas id="scanner-canvas" style="display:none;"></canvas>
<div id="scanner-overlay">
<div id="scan-frame"></div>
</div>
<div id="scanner-status">Point your camera at a QR code</div>
</div>
</div>
<div id="scanner-result" style="display:none;">
<div id="result-safe" style="display:none;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">&#x2705;</div>
<h2>Verified Loot Hunt QR</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">This code points to a verified Loot Hunt page.</p>
<a id="result-safe-link" href="#" class="btn btn-primary" style="width: 100%; justify-content: center; font-size: 1rem;">Open Package</a>
<button onclick="resumeScanner()" class="btn btn-outline" style="width: 100%; margin-top: 0.5rem;">Scan Another</button>
</div>
<div id="result-warning" style="display:none;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">&#x26A0;&#xFE0F;</div>
<h2 style="color: var(--danger);">Unknown QR Code</h2>
<p style="color: var(--muted); margin-bottom: 0.5rem;">This QR code points to an external or unrecognized URL. It is <strong>not</strong> a verified Loot Hunt code.</p>
<div id="result-warning-url" style="word-break: break-all; background: var(--body-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem;"></div>
<p style="font-size: 0.85rem; color: var(--danger); margin-bottom: 1rem;"><strong>Do not visit links you don't trust.</strong> Malicious QR codes can lead to phishing or harmful websites.</p>
<button onclick="resumeScanner()" class="btn btn-primary" style="width: 100%; justify-content: center;">Scan Another</button>
</div>
<div id="result-nourl" style="display:none;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">&#x1F4CB;</div>
<h2>QR Code Content</h2>
<p style="color: var(--muted); margin-bottom: 0.5rem;">This QR code contains text, not a URL:</p>
<div id="result-nourl-text" style="word-break: break-all; background: var(--body-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem;"></div>
<button onclick="resumeScanner()" class="btn btn-primary" style="width: 100%; justify-content: center;">Scan Another</button>
</div>
</div>
<div id="scanner-error" style="display:none;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">&#x1F4F7;</div>
<h2>Camera Access Required</h2>
<p style="color: var(--muted); margin-bottom: 1rem;">Please allow camera access to use the QR scanner. Make sure you're using HTTPS.</p>
<a href="/" class="btn btn-outline">Back to Home</a>
</div>
</div>
<style>
/* Scanner-specific styles */
#scanner-page {
position: fixed;
inset: 0;
z-index: 200;
background: #000;
display: flex;
flex-direction: column;
}
#scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgba(0,0,0,0.8);
color: #fff;
z-index: 10;
}
#scanner-viewport {
flex: 1;
position: relative;
overflow: hidden;
}
#scanner-video {
width: 100%;
height: 100%;
object-fit: cover;
}
#scanner-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
#scan-frame {
width: 250px;
height: 250px;
border: 3px solid rgba(253, 203, 110, 0.8);
border-radius: 20px;
box-shadow: 0 0 0 9999px rgba(0,0,0,0.4);
}
#scanner-status {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 0.5rem 1.25rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
#scanner-result,
#scanner-error {
position: absolute;
inset: 0;
background: var(--body-bg);
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
z-index: 20;
}
/* Hide normal page chrome on the scanner page */
.navbar { display: none !important; }
.footer { display: none !important; }
.scanner-fab { display: none !important; }
</style>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script>
<script>
(function() {
var TRUSTED_ORIGIN = '<%= baseUrl %>'.replace(/\/+$/, '').toLowerCase();
var video = document.getElementById('scanner-video');
var canvas = document.getElementById('scanner-canvas');
var ctx = canvas.getContext('2d', { willReadFrequently: true });
var statusEl = document.getElementById('scanner-status');
var resultEl = document.getElementById('scanner-result');
var errorEl = document.getElementById('scanner-error');
var uiEl = document.getElementById('scanner-ui');
var scanning = true;
var stream = null;
var facingMode = 'environment';
var animFrame = null;
function startCamera() {
if (stream) {
stream.getTracks().forEach(function(t) { t.stop(); });
}
navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode, width: { ideal: 1280 }, height: { ideal: 720 } }
}).then(function(s) {
stream = s;
video.srcObject = s;
video.play();
requestAnimationFrame(tick);
}).catch(function(err) {
console.error('Camera error:', err);
uiEl.style.display = 'none';
errorEl.style.display = 'flex';
});
}
function tick() {
if (!scanning) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
var code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert'
});
if (code && code.data) {
scanning = false;
handleResult(code.data);
return;
}
}
animFrame = requestAnimationFrame(tick);
}
function handleResult(data) {
// Haptic feedback
if (navigator.vibrate) navigator.vibrate(100);
// Hide scanner UI, show result
uiEl.style.display = 'none';
resultEl.style.display = 'flex';
var isUrl = /^https?:\/\//i.test(data);
if (!isUrl) {
// Plain text
document.getElementById('result-nourl').style.display = 'block';
document.getElementById('result-nourl-text').textContent = data;
return;
}
var normalizedData = data.toLowerCase().replace(/\/+$/, '');
var isTrusted = normalizedData === TRUSTED_ORIGIN || normalizedData.indexOf(TRUSTED_ORIGIN + '/') === 0;
if (isTrusted) {
document.getElementById('result-safe').style.display = 'block';
document.getElementById('result-safe-link').href = data;
} else {
document.getElementById('result-warning').style.display = 'block';
document.getElementById('result-warning-url').textContent = data;
}
}
// Expose globally for the "Scan Another" buttons
window.resumeScanner = function() {
resultEl.style.display = 'none';
document.getElementById('result-safe').style.display = 'none';
document.getElementById('result-warning').style.display = 'none';
document.getElementById('result-nourl').style.display = 'none';
uiEl.style.display = 'flex';
uiEl.style.flexDirection = 'column';
scanning = true;
requestAnimationFrame(tick);
};
// Camera flip
document.getElementById('camera-flip').addEventListener('click', function() {
facingMode = facingMode === 'environment' ? 'user' : 'environment';
startCamera();
});
// Clean up camera when leaving page
window.addEventListener('beforeunload', function() {
if (stream) {
stream.getTracks().forEach(function(t) { t.stop(); });
}
});
startCamera();
})();
</script>
<%- include('partials/footer') %>