feat: add barcode scanning
38761054
2 file(s) · +96 −0
| 32 | 32 | <div class="search-row"> |
|
| 33 | 33 | <input type="text" id="book-query" placeholder="title, author, isbn" /> |
|
| 34 | 34 | <button type="button" id="search-btn" onclick="searchBooks()">Search</button> |
|
| 35 | + | <button type="button" id="scan-btn" onclick="openScanner()" hidden>Scan</button> |
|
| 35 | 36 | </div> |
|
| 36 | 37 | <div id="search-status" class="search-status" style="display:none;"></div> |
|
| 37 | 38 | <div id="search-results" class="search-results"></div> |
|
| 38 | 39 | </section> |
|
| 40 | + | ||
| 41 | + | <div id="scan-modal" class="scan-modal" hidden> |
|
| 42 | + | <div class="scan-inner"> |
|
| 43 | + | <video id="scan-video" playsinline muted></video> |
|
| 44 | + | <p id="scan-status" class="scan-status">Point camera at barcode</p> |
|
| 45 | + | <button type="button" onclick="closeScanner()">Cancel</button> |
|
| 46 | + | </div> |
|
| 47 | + | </div> |
|
| 39 | 48 | ||
| 40 | 49 | <section class="admin-subs"> |
|
| 41 | 50 | <h3>Library ({{ books.len() }})</h3> |
|
| 180 | 189 | return String(s || '').replace(/[&<>"']/g, function(c) { |
|
| 181 | 190 | return ({'&':'&','<':'<','>':'>','"':'"',"'":"'"})[c]; |
|
| 182 | 191 | }); |
|
| 192 | + | } |
|
| 193 | + | ||
| 194 | + | let scanStream = null; |
|
| 195 | + | let scanRaf = null; |
|
| 196 | + | ||
| 197 | + | (function initScan() { |
|
| 198 | + | if ('BarcodeDetector' in window && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { |
|
| 199 | + | document.getElementById('scan-btn').hidden = false; |
|
| 200 | + | } |
|
| 201 | + | })(); |
|
| 202 | + | ||
| 203 | + | async function openScanner() { |
|
| 204 | + | const modal = document.getElementById('scan-modal'); |
|
| 205 | + | const video = document.getElementById('scan-video'); |
|
| 206 | + | const status = document.getElementById('scan-status'); |
|
| 207 | + | status.textContent = 'Point camera at barcode'; |
|
| 208 | + | modal.hidden = false; |
|
| 209 | + | try { |
|
| 210 | + | scanStream = await navigator.mediaDevices.getUserMedia({ |
|
| 211 | + | video: { facingMode: 'environment' } |
|
| 212 | + | }); |
|
| 213 | + | video.srcObject = scanStream; |
|
| 214 | + | await video.play(); |
|
| 215 | + | const detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] }); |
|
| 216 | + | const tick = async () => { |
|
| 217 | + | if (!scanStream) return; |
|
| 218 | + | try { |
|
| 219 | + | const codes = await detector.detect(video); |
|
| 220 | + | if (codes.length) { |
|
| 221 | + | const isbn = codes[0].rawValue; |
|
| 222 | + | closeScanner(); |
|
| 223 | + | document.getElementById('book-query').value = isbn; |
|
| 224 | + | searchBooks(); |
|
| 225 | + | return; |
|
| 226 | + | } |
|
| 227 | + | } catch (_) {} |
|
| 228 | + | scanRaf = requestAnimationFrame(tick); |
|
| 229 | + | }; |
|
| 230 | + | tick(); |
|
| 231 | + | } catch (e) { |
|
| 232 | + | status.textContent = 'Camera unavailable'; |
|
| 233 | + | } |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | function closeScanner() { |
|
| 237 | + | if (scanRaf) cancelAnimationFrame(scanRaf); |
|
| 238 | + | scanRaf = null; |
|
| 239 | + | if (scanStream) { |
|
| 240 | + | scanStream.getTracks().forEach(function(t) { t.stop(); }); |
|
| 241 | + | scanStream = null; |
|
| 242 | + | } |
|
| 243 | + | document.getElementById('scan-modal').hidden = true; |
|
| 183 | 244 | } |
|
| 184 | 245 | </script> |
|
| 185 | 246 | </body> |
|
| 193 | 193 | font-size: 14px; |
|
| 194 | 194 | } |
|
| 195 | 195 | } |
|
| 196 | + | ||
| 197 | + | .scan-modal { |
|
| 198 | + | position: fixed; |
|
| 199 | + | inset: 0; |
|
| 200 | + | background: rgba(0, 0, 0, 0.85); |
|
| 201 | + | display: flex; |
|
| 202 | + | align-items: center; |
|
| 203 | + | justify-content: center; |
|
| 204 | + | z-index: 1000; |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | .scan-modal[hidden] { |
|
| 208 | + | display: none; |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | .scan-inner { |
|
| 212 | + | display: flex; |
|
| 213 | + | flex-direction: column; |
|
| 214 | + | align-items: center; |
|
| 215 | + | gap: 12px; |
|
| 216 | + | width: min(90vw, 480px); |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | .scan-inner video { |
|
| 220 | + | width: 100%; |
|
| 221 | + | max-height: 70vh; |
|
| 222 | + | background: #000; |
|
| 223 | + | border-radius: 8px; |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | .scan-status { |
|
| 227 | + | color: #eee; |
|
| 228 | + | font-size: 14px; |
|
| 229 | + | margin: 0; |
|
| 230 | + | } |