apps/library/templates/admin.html 13.3 K raw
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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[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>