| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <meta name="theme-color" content="#121113" /> |
| 7 | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
| 8 | <link rel="stylesheet" href="/static/styles.css" /> |
| 9 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" /> |
| 10 | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" /> |
| 11 | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" /> |
| 12 | <link rel="manifest" href="/static/site.webmanifest" /> |
| 13 | <title>Library | Admin</title> |
| 14 | </head> |
| 15 | <body> |
| 16 | <div class="header"> |
| 17 | <a href="/" class="logo"><h1>LIBRARY</h1></a> |
| 18 | <nav class="links"> |
| 19 | <a href="/admin/logout">logout</a> |
| 20 | </nav> |
| 21 | </div> |
| 22 | |
| 23 | {{if .Success}}<p class="success">{{.Success}}</p>{{end}} |
| 24 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 25 | |
| 26 | <section class="admin-form"> |
| 27 | <h3>Category Labels</h3> |
| 28 | <form method="POST" action="/admin/categories/labels" class="labels-form"> |
| 29 | <label> |
| 30 | <span>Reading</span> |
| 31 | <input type="text" name="reading" value="{{.Labels.Reading}}" required /> |
| 32 | </label> |
| 33 | <label> |
| 34 | <span>Read</span> |
| 35 | <input type="text" name="read" value="{{.Labels.Read}}" required /> |
| 36 | </label> |
| 37 | <label> |
| 38 | <span>Want to Read</span> |
| 39 | <input type="text" name="want" value="{{.Labels.Want}}" required /> |
| 40 | </label> |
| 41 | <button type="submit">Save labels</button> |
| 42 | </form> |
| 43 | </section> |
| 44 | |
| 45 | <section class="admin-form"> |
| 46 | <h3>Search Books (Google)</h3> |
| 47 | <div class="search-row"> |
| 48 | <input type="text" id="book-query" placeholder="title, author, isbn" /> |
| 49 | <button type="button" id="search-btn" onclick="searchBooks()">Search</button> |
| 50 | <button type="button" id="scan-btn" onclick="openScanner()" hidden>Scan</button> |
| 51 | </div> |
| 52 | <div id="search-status" class="search-status" style="display:none;"></div> |
| 53 | <div id="search-results" class="search-results"></div> |
| 54 | </section> |
| 55 | |
| 56 | <section class="admin-form"> |
| 57 | <h3>Search Library</h3> |
| 58 | <form method="GET" action="/admin" class="search-row"> |
| 59 | <input type="text" name="q" placeholder="title, author, isbn" value="{{.LibraryQuery}}" /> |
| 60 | <button type="submit">Search</button> |
| 61 | {{if .LibrarySearched}} |
| 62 | <a href="/admin" class="hint">clear</a> |
| 63 | {{end}} |
| 64 | </form> |
| 65 | {{if .LibrarySearched}} |
| 66 | {{if not .LibraryResults}} |
| 67 | <p class="hint">No matches.</p> |
| 68 | {{else}} |
| 69 | <div class="books-list"> |
| 70 | {{$labels := .Labels}} |
| 71 | {{range .LibraryResults}} |
| 72 | <div class="book-card admin"> |
| 73 | {{if .CoverURL}} |
| 74 | <img class="book-cover" src="{{.CoverURL}}" alt="" loading="lazy" /> |
| 75 | {{else}} |
| 76 | <div class="book-cover placeholder"></div> |
| 77 | {{end}} |
| 78 | <div class="book-info"> |
| 79 | <h3 class="book-title">{{.Title}}</h3> |
| 80 | <p class="book-authors">{{.Authors}}</p> |
| 81 | {{if .ISBN}}<p class="book-meta">ISBN: {{.ISBN}}</p>{{end}} |
| 82 | <form method="POST" action="/admin/books/{{.ID}}/status" class="inline"> |
| 83 | <select name="status" onchange="this.form.submit()"> |
| 84 | <option value="read"{{if eq .Status "read"}} selected{{end}}>{{$labels.Read}}</option> |
| 85 | <option value="reading"{{if eq .Status "reading"}} selected{{end}}>{{$labels.Reading}}</option> |
| 86 | <option value="want"{{if eq .Status "want"}} selected{{end}}>{{$labels.Want}}</option> |
| 87 | </select> |
| 88 | <noscript><button type="submit">Save</button></noscript> |
| 89 | </form> |
| 90 | <form method="POST" action="/admin/books/{{.ID}}/notes" class="inline notes-form"> |
| 91 | <textarea name="notes" rows="5" placeholder="notes">{{.Notes}}</textarea> |
| 92 | <button type="submit">Save notes</button> |
| 93 | </form> |
| 94 | <form method="POST" action="/admin/books/{{.ID}}/delete" class="inline"> |
| 95 | <button type="submit" class="danger">Delete</button> |
| 96 | </form> |
| 97 | </div> |
| 98 | </div> |
| 99 | {{end}} |
| 100 | </div> |
| 101 | {{end}} |
| 102 | {{end}} |
| 103 | </section> |
| 104 | |
| 105 | <div id="scan-modal" class="scan-modal" hidden> |
| 106 | <div class="scan-inner"> |
| 107 | <video id="scan-video" playsinline muted></video> |
| 108 | <p id="scan-status" class="scan-status">Point camera at barcode</p> |
| 109 | <button type="button" onclick="closeScanner()">Cancel</button> |
| 110 | </div> |
| 111 | </div> |
| 112 | |
| 113 | <section class="admin-subs"> |
| 114 | <h3>Library ({{len .Books}})</h3> |
| 115 | {{if not .Books}} |
| 116 | <p class="hint">No books yet. Search above to add one.</p> |
| 117 | {{else}} |
| 118 | <div class="books-list"> |
| 119 | {{$labels := .Labels}} |
| 120 | {{range .Books}} |
| 121 | <div class="book-card admin"> |
| 122 | {{if .CoverURL}} |
| 123 | <img class="book-cover" src="{{.CoverURL}}" alt="" loading="lazy" /> |
| 124 | {{else}} |
| 125 | <div class="book-cover placeholder"></div> |
| 126 | {{end}} |
| 127 | <div class="book-info"> |
| 128 | <h3 class="book-title">{{.Title}}</h3> |
| 129 | <p class="book-authors">{{.Authors}}</p> |
| 130 | {{if .ISBN}}<p class="book-meta">ISBN: {{.ISBN}}</p>{{end}} |
| 131 | <form method="POST" action="/admin/books/{{.ID}}/status" class="inline"> |
| 132 | <select name="status" onchange="this.form.submit()"> |
| 133 | <option value="read"{{if eq .Status "read"}} selected{{end}}>{{$labels.Read}}</option> |
| 134 | <option value="reading"{{if eq .Status "reading"}} selected{{end}}>{{$labels.Reading}}</option> |
| 135 | <option value="want"{{if eq .Status "want"}} selected{{end}}>{{$labels.Want}}</option> |
| 136 | </select> |
| 137 | <noscript><button type="submit">Save</button></noscript> |
| 138 | </form> |
| 139 | <form method="POST" action="/admin/books/{{.ID}}/notes" class="inline notes-form"> |
| 140 | <textarea name="notes" rows="5" placeholder="notes">{{.Notes}}</textarea> |
| 141 | <button type="submit">Save notes</button> |
| 142 | </form> |
| 143 | <form method="POST" action="/admin/books/{{.ID}}/delete" class="inline"> |
| 144 | <button type="submit" class="danger">Delete</button> |
| 145 | </form> |
| 146 | </div> |
| 147 | </div> |
| 148 | {{end}} |
| 149 | </div> |
| 150 | {{end}} |
| 151 | </section> |
| 152 | |
| 153 | <div id="category-labels-data" |
| 154 | data-want="{{.Labels.Want}}" |
| 155 | data-reading="{{.Labels.Reading}}" |
| 156 | data-read="{{.Labels.Read}}" |
| 157 | hidden></div> |
| 158 | |
| 159 | <script src="https://unpkg.com/@zxing/browser@0.1.5/umd/zxing-browser.min.js"></script> |
| 160 | <script> |
| 161 | (function() { |
| 162 | const el = document.getElementById('category-labels-data'); |
| 163 | window.__categoryLabels = { |
| 164 | want: el.dataset.want, |
| 165 | reading: el.dataset.reading, |
| 166 | read: el.dataset.read, |
| 167 | }; |
| 168 | })(); |
| 169 | |
| 170 | async function searchBooks() { |
| 171 | const q = document.getElementById('book-query').value.trim(); |
| 172 | if (!q) return; |
| 173 | const btn = document.getElementById('search-btn'); |
| 174 | const status = document.getElementById('search-status'); |
| 175 | const results = document.getElementById('search-results'); |
| 176 | btn.disabled = true; |
| 177 | btn.textContent = 'Searching...'; |
| 178 | status.style.display = 'none'; |
| 179 | results.innerHTML = ''; |
| 180 | try { |
| 181 | const resp = await fetch('/admin/search?q=' + encodeURIComponent(q)); |
| 182 | const data = await resp.json(); |
| 183 | if (!resp.ok) { |
| 184 | status.textContent = data.error || 'Search failed'; |
| 185 | status.className = 'search-status error'; |
| 186 | status.style.display = 'block'; |
| 187 | return; |
| 188 | } |
| 189 | if (!data.length) { |
| 190 | status.textContent = 'No results'; |
| 191 | status.className = 'search-status'; |
| 192 | status.style.display = 'block'; |
| 193 | return; |
| 194 | } |
| 195 | data.forEach(function(hit) { |
| 196 | results.appendChild(renderHit(hit)); |
| 197 | }); |
| 198 | } catch (e) { |
| 199 | status.textContent = 'Request failed'; |
| 200 | status.className = 'search-status error'; |
| 201 | status.style.display = 'block'; |
| 202 | } finally { |
| 203 | btn.disabled = false; |
| 204 | btn.textContent = 'Search'; |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | function renderHit(hit) { |
| 209 | const card = document.createElement('form'); |
| 210 | card.method = 'POST'; |
| 211 | card.action = '/admin/add'; |
| 212 | card.className = 'book-card hit'; |
| 213 | |
| 214 | const cover = document.createElement('div'); |
| 215 | if (hit.cover_url) { |
| 216 | const img = document.createElement('img'); |
| 217 | img.src = hit.cover_url; |
| 218 | img.className = 'book-cover'; |
| 219 | img.loading = 'lazy'; |
| 220 | card.appendChild(img); |
| 221 | } else { |
| 222 | cover.className = 'book-cover placeholder'; |
| 223 | card.appendChild(cover); |
| 224 | } |
| 225 | |
| 226 | const info = document.createElement('div'); |
| 227 | info.className = 'book-info'; |
| 228 | info.innerHTML = |
| 229 | '<h3 class="book-title"></h3>' + |
| 230 | '<p class="book-authors"></p>' + |
| 231 | (hit.isbn ? '<p class="book-meta">ISBN: ' + escapeHtml(hit.isbn) + '</p>' : ''); |
| 232 | info.querySelector('.book-title').textContent = hit.title; |
| 233 | info.querySelector('.book-authors').textContent = hit.authors; |
| 234 | |
| 235 | const hidden = function(name, value) { |
| 236 | const el = document.createElement('input'); |
| 237 | el.type = 'hidden'; |
| 238 | el.name = name; |
| 239 | el.value = value || ''; |
| 240 | return el; |
| 241 | }; |
| 242 | info.appendChild(hidden('google_id', hit.google_id)); |
| 243 | info.appendChild(hidden('title', hit.title)); |
| 244 | info.appendChild(hidden('authors', hit.authors)); |
| 245 | info.appendChild(hidden('isbn', hit.isbn)); |
| 246 | info.appendChild(hidden('cover_url', hit.cover_url)); |
| 247 | |
| 248 | const select = document.createElement('select'); |
| 249 | select.name = 'status'; |
| 250 | const labels = window.__categoryLabels || { want: 'Want to Read', reading: 'Reading', read: 'Read' }; |
| 251 | ['want', 'reading', 'read'].forEach(function(s) { |
| 252 | const o = document.createElement('option'); |
| 253 | o.value = s; |
| 254 | o.textContent = labels[s]; |
| 255 | select.appendChild(o); |
| 256 | }); |
| 257 | info.appendChild(select); |
| 258 | |
| 259 | const btn = document.createElement('button'); |
| 260 | btn.type = 'submit'; |
| 261 | btn.textContent = 'Add'; |
| 262 | info.appendChild(btn); |
| 263 | |
| 264 | card.appendChild(info); |
| 265 | return card; |
| 266 | } |
| 267 | |
| 268 | function escapeHtml(s) { |
| 269 | return String(s || '').replace(/[&<>"']/g, function(c) { |
| 270 | return ({'&':'&','<':'<','>':'>','"':'"',"'":"'"})[c]; |
| 271 | }); |
| 272 | } |
| 273 | |
| 274 | let scanStream = null; |
| 275 | let scanRaf = null; |
| 276 | let zxingControls = null; |
| 277 | |
| 278 | const hasNativeBarcode = 'BarcodeDetector' in window; |
| 279 | const hasZxing = typeof ZXingBrowser !== 'undefined'; |
| 280 | |
| 281 | (function initScan() { |
| 282 | if ((hasNativeBarcode || hasZxing) && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { |
| 283 | document.getElementById('scan-btn').hidden = false; |
| 284 | } |
| 285 | })(); |
| 286 | |
| 287 | async function openScanner() { |
| 288 | const modal = document.getElementById('scan-modal'); |
| 289 | const video = document.getElementById('scan-video'); |
| 290 | const status = document.getElementById('scan-status'); |
| 291 | status.textContent = 'Point camera at barcode'; |
| 292 | modal.hidden = false; |
| 293 | |
| 294 | const onHit = (isbn) => { |
| 295 | closeScanner(); |
| 296 | document.getElementById('book-query').value = isbn; |
| 297 | searchBooks(); |
| 298 | }; |
| 299 | |
| 300 | try { |
| 301 | let detector = null; |
| 302 | if (hasNativeBarcode) { |
| 303 | try { |
| 304 | detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] }); |
| 305 | } catch (_) { |
| 306 | detector = null; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | if (detector) { |
| 311 | scanStream = await navigator.mediaDevices.getUserMedia({ |
| 312 | video: { facingMode: 'environment' } |
| 313 | }); |
| 314 | video.srcObject = scanStream; |
| 315 | await video.play(); |
| 316 | const tick = async () => { |
| 317 | if (!scanStream) return; |
| 318 | try { |
| 319 | const codes = await detector.detect(video); |
| 320 | if (codes.length) return onHit(codes[0].rawValue); |
| 321 | } catch (_) {} |
| 322 | scanRaf = requestAnimationFrame(tick); |
| 323 | }; |
| 324 | tick(); |
| 325 | return; |
| 326 | } |
| 327 | |
| 328 | if (hasZxing) { |
| 329 | const reader = new ZXingBrowser.BrowserMultiFormatReader(); |
| 330 | zxingControls = await reader.decodeFromVideoDevice(undefined, video, (result, err, controls) => { |
| 331 | if (result) { |
| 332 | controls.stop(); |
| 333 | zxingControls = null; |
| 334 | onHit(result.getText()); |
| 335 | } |
| 336 | }); |
| 337 | return; |
| 338 | } |
| 339 | |
| 340 | status.textContent = 'Scanner not supported'; |
| 341 | } catch (e) { |
| 342 | status.textContent = 'Camera unavailable'; |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | function closeScanner() { |
| 347 | if (scanRaf) cancelAnimationFrame(scanRaf); |
| 348 | scanRaf = null; |
| 349 | if (zxingControls) { |
| 350 | try { zxingControls.stop(); } catch (_) {} |
| 351 | zxingControls = null; |
| 352 | } |
| 353 | if (scanStream) { |
| 354 | scanStream.getTracks().forEach(function(t) { t.stop(); }); |
| 355 | scanStream = null; |
| 356 | } |
| 357 | document.getElementById('scan-modal').hidden = true; |
| 358 | } |
| 359 | </script> |
| 360 | </body> |
| 361 | </html> |