add built in scanner
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;">🛡️</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. 🏆</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (hunts && hunts.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Active Hunts</h2>
|
||||
<% hunts.forEach(hunt => { %>
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
<footer class="footer">
|
||||
© <%= new Date().getFullYear() %> Loot Hunt — 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
249
src/views/scanner.ejs
Normal 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);">← 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">🔄</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;">✅</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;">⚠️</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;">📋</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;">📷</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') %>
|
||||
Reference in New Issue
Block a user