feat: add barcode scanning 38761054
Steve · 2026-04-25 13:17 2 file(s) · +96 −0
apps/library/src/templates/admin.html +61 −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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[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>
apps/library/static/styles.css +35 −0
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 +
}