chore: Library upgrades 71177ea2
- Toggle display mode
- Rename categories
- Search library
Steve · 2026-04-26 16:38 6 file(s) · +359 −38
apps/library/.env.example +1 −0
5 5
PORT=3000
6 6
LIBRARY_DB_PATH=library.sqlite
7 7
GOOGLE_BOOKS_API_KEY=
8 +
LIBRARY_DISPLAY_MODE=inline
apps/library/README.md +4 −1
23 23
| `HOST` | Server bind address | `127.0.0.1` |
24 24
| `PORT` | Server port | `3000` |
25 25
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
26 +
| `LIBRARY_DISPLAY_MODE` | Public index layout: `inline` (stacked sections) or `nav` (filter buttons in header) | `inline` |
26 27
27 28
## Overview
28 29
29 30
A simple, self-hosted book tracker built with Rust. Highlights:
30 31
- Single Rust binary with embedded assets
31 32
- Password authentication with session cookies
32 -
- Track books across Read, Reading, and Want to Read
33 +
- Track books across Read, Reading, and Want to Read (labels customizable from admin)
33 34
- Google Books search to add titles with cover art and ISBN
35 +
- Library search from the admin page (title / author / ISBN)
36 +
- Toggle between inline category sections and a filter-nav layout via `LIBRARY_DISPLAY_MODE`
34 37
- Per-book notes
35 38
- JSON API for listing and fetching books
36 39
- SQLite for persistent storage
apps/library/src/db.rs +85 −0
16 16
    updated_at    INTEGER NOT NULL
17 17
);
18 18
CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC);
19 +
20 +
CREATE TABLE IF NOT EXISTS settings (
21 +
    key   TEXT PRIMARY KEY,
22 +
    value TEXT NOT NULL
23 +
);
19 24
"#;
20 25
21 26
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165 170
    Ok(n > 0)
166 171
}
167 172
173 +
pub fn search_books(db: &Db, q: &str) -> Result<Vec<Book>, DbError> {
174 +
    let term = q.trim();
175 +
    if term.is_empty() {
176 +
        return Ok(Vec::new());
177 +
    }
178 +
    let pattern = format!("%{}%", term.to_lowercase());
179 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
180 +
    let sql = format!(
181 +
        "SELECT {SELECT_COLS} FROM books \
182 +
         WHERE LOWER(title) LIKE ?1 OR LOWER(authors) LIKE ?1 OR LOWER(IFNULL(isbn, '')) LIKE ?1 \
183 +
         ORDER BY added_at DESC LIMIT 50"
184 +
    );
185 +
    let mut stmt = conn.prepare(&sql)?;
186 +
    let rows = stmt.query_map([&pattern], map_book)?;
187 +
    Ok(rows.collect::<Result<Vec<_>, _>>()?)
188 +
}
189 +
190 +
#[derive(Debug, Clone, Serialize)]
191 +
pub struct CategoryLabels {
192 +
    pub reading: String,
193 +
    pub read: String,
194 +
    pub want: String,
195 +
}
196 +
197 +
impl Default for CategoryLabels {
198 +
    fn default() -> Self {
199 +
        Self {
200 +
            reading: "Reading".to_string(),
201 +
            read: "Read".to_string(),
202 +
            want: "Want to Read".to_string(),
203 +
        }
204 +
    }
205 +
}
206 +
207 +
impl CategoryLabels {
208 +
    pub fn label_for(&self, status: BookStatus) -> &str {
209 +
        match status {
210 +
            BookStatus::Reading => &self.reading,
211 +
            BookStatus::Read => &self.read,
212 +
            BookStatus::Want => &self.want,
213 +
        }
214 +
    }
215 +
}
216 +
217 +
pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> {
218 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
219 +
    let val = conn
220 +
        .query_row(
221 +
            "SELECT value FROM settings WHERE key = ?1",
222 +
            [key],
223 +
            |r| r.get::<_, String>(0),
224 +
        )
225 +
        .optional()?;
226 +
    Ok(val)
227 +
}
228 +
229 +
pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> {
230 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
231 +
    conn.execute(
232 +
        "INSERT INTO settings (key, value) VALUES (?1, ?2) \
233 +
         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
234 +
        params![key, value],
235 +
    )?;
236 +
    Ok(())
237 +
}
238 +
239 +
pub fn get_category_labels(db: &Db) -> Result<CategoryLabels, DbError> {
240 +
    let mut labels = CategoryLabels::default();
241 +
    if let Some(v) = get_setting(db, "category_label.reading")? {
242 +
        labels.reading = v;
243 +
    }
244 +
    if let Some(v) = get_setting(db, "category_label.read")? {
245 +
        labels.read = v;
246 +
    }
247 +
    if let Some(v) = get_setting(db, "category_label.want")? {
248 +
        labels.want = v;
249 +
    }
250 +
    Ok(labels)
251 +
}
252 +
apps/library/src/main.rs +168 −32
20 20
use rust_embed::Embed;
21 21
use serde::Deserialize;
22 22
23 -
use crate::db::{Book, BookStatus, NewBook};
23 +
use crate::db::{Book, BookStatus, CategoryLabels, NewBook};
24 +
25 +
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 +
pub enum DisplayMode {
27 +
    Inline,
28 +
    Nav,
29 +
}
30 +
31 +
impl DisplayMode {
32 +
    fn parse(s: &str) -> Self {
33 +
        match s.to_ascii_lowercase().as_str() {
34 +
            "nav" => DisplayMode::Nav,
35 +
            _ => DisplayMode::Inline,
36 +
        }
37 +
    }
38 +
}
24 39
25 40
#[derive(Embed)]
26 41
#[folder = "static/"]
32 47
    pub google_books_api_key: Option<String>,
33 48
    pub cookie_secure: bool,
34 49
    pub base_url: String,
50 +
    pub display_mode: DisplayMode,
35 51
}
36 52
37 53
// ── Templates ────────────────────────────────────────────────────────────
44 60
}
45 61
46 62
struct SectionView {
47 -
    label: &'static str,
63 +
    label: String,
48 64
    books: Vec<BookView>,
49 65
}
50 66
67 +
struct NavCategory {
68 +
    slug: &'static str,
69 +
    label: String,
70 +
    active: bool,
71 +
}
72 +
51 73
#[derive(Template)]
52 74
#[template(path = "index.html")]
53 75
struct IndexTemplate {
54 76
    base_url: String,
55 77
    sections: Vec<SectionView>,
78 +
    nav_mode: bool,
79 +
    nav_categories: Vec<NavCategory>,
56 80
}
57 81
58 82
#[derive(Template)]
77 101
    success: Option<String>,
78 102
    error: Option<String>,
79 103
    books: Vec<AdminBookRow>,
104 +
    labels: CategoryLabels,
105 +
    library_query: String,
106 +
    library_results: Vec<AdminBookRow>,
107 +
    library_searched: bool,
80 108
}
81 109
82 110
fn make_book_view(b: Book) -> BookView {
88 116
    }
89 117
}
90 118
91 -
async fn index_handler(State(state): State<Arc<AppState>>) -> Response {
119 +
#[derive(Deserialize, Default)]
120 +
struct IndexQuery {
121 +
    category: Option<String>,
122 +
}
123 +
124 +
async fn index_handler(
125 +
    State(state): State<Arc<AppState>>,
126 +
    Query(q): Query<IndexQuery>,
127 +
) -> Response {
92 128
    let all_books = db::list_books(&state.db, None).unwrap_or_default();
129 +
    let labels = db::get_category_labels(&state.db).unwrap_or_default();
93 130
94 131
    let section_defs: &[(&'static str, BookStatus)] = &[
95 -
        ("Reading", BookStatus::Reading),
96 -
        ("Read", BookStatus::Read),
97 -
        ("Want to Read", BookStatus::Want),
132 +
        ("reading", BookStatus::Reading),
133 +
        ("read", BookStatus::Read),
134 +
        ("want", BookStatus::Want),
98 135
    ];
99 136
100 -
    let sections = section_defs
101 -
        .iter()
102 -
        .filter_map(|(label, status)| {
103 -
            let books: Vec<BookView> = all_books
104 -
                .iter()
105 -
                .filter(|b| b.status == status.as_str())
106 -
                .map(|b| make_book_view(b.clone()))
107 -
                .collect();
108 -
            if books.is_empty() {
109 -
                None
110 -
            } else {
111 -
                Some(SectionView { label, books })
112 -
            }
113 -
        })
114 -
        .collect();
137 +
    let make_section = |status: BookStatus| -> SectionView {
138 +
        let books = all_books
139 +
            .iter()
140 +
            .filter(|b| b.status == status.as_str())
141 +
            .map(|b| make_book_view(b.clone()))
142 +
            .collect();
143 +
        SectionView {
144 +
            label: labels.label_for(status).to_string(),
145 +
            books,
146 +
        }
147 +
    };
148 +
149 +
    let nav_mode = matches!(state.display_mode, DisplayMode::Nav);
150 +
151 +
    let (sections, nav_categories) = if nav_mode {
152 +
        let selected_slug = q
153 +
            .category
154 +
            .as_deref()
155 +
            .and_then(|s| {
156 +
                section_defs
157 +
                    .iter()
158 +
                    .find(|(slug, _)| *slug == s)
159 +
                    .map(|(slug, _)| *slug)
160 +
            })
161 +
            .unwrap_or(section_defs[0].0);
162 +
163 +
        let nav = section_defs
164 +
            .iter()
165 +
            .map(|(slug, status)| NavCategory {
166 +
                slug,
167 +
                label: labels.label_for(*status).to_string(),
168 +
                active: *slug == selected_slug,
169 +
            })
170 +
            .collect();
171 +
172 +
        let (_, status) = section_defs
173 +
            .iter()
174 +
            .find(|(slug, _)| *slug == selected_slug)
175 +
            .copied()
176 +
            .unwrap();
177 +
        (vec![make_section(status)], nav)
178 +
    } else {
179 +
        let secs = section_defs
180 +
            .iter()
181 +
            .filter_map(|(_, status)| {
182 +
                let s = make_section(*status);
183 +
                if s.books.is_empty() { None } else { Some(s) }
184 +
            })
185 +
            .collect();
186 +
        (secs, Vec::new())
187 +
    };
115 188
116 189
    Html(
117 190
        IndexTemplate {
118 191
            base_url: state.base_url.clone(),
119 192
            sections,
193 +
            nav_mode,
194 +
            nav_categories,
120 195
        }
121 196
        .render()
122 197
        .unwrap(),
139 214
#[derive(Deserialize, Default)]
140 215
struct FlashQuery {
141 216
    error: Option<String>,
142 -
    success: Option<String>,
143 217
}
144 218
145 219
#[derive(Deserialize)]
191 265
    resp
192 266
}
193 267
268 +
fn book_to_row(b: Book) -> AdminBookRow {
269 +
    AdminBookRow {
270 +
        id: b.id,
271 +
        title: b.title,
272 +
        authors: b.authors,
273 +
        isbn: b.isbn,
274 +
        cover_url: b.cover_url,
275 +
        notes: b.notes,
276 +
        status: b.status,
277 +
    }
278 +
}
279 +
280 +
#[derive(Deserialize, Default)]
281 +
struct AdminQuery {
282 +
    error: Option<String>,
283 +
    success: Option<String>,
284 +
    q: Option<String>,
285 +
}
286 +
194 287
async fn admin_handler(
195 288
    _session: auth::AuthSession,
196 289
    State(state): State<Arc<AppState>>,
197 -
    Query(q): Query<FlashQuery>,
290 +
    Query(q): Query<AdminQuery>,
198 291
) -> Response {
199 292
    let books = db::list_books(&state.db, None)
200 293
        .unwrap_or_default()
201 294
        .into_iter()
202 -
        .map(|b| AdminBookRow {
203 -
            id: b.id,
204 -
            title: b.title,
205 -
            authors: b.authors,
206 -
            isbn: b.isbn,
207 -
            cover_url: b.cover_url,
208 -
            notes: b.notes,
209 -
            status: b.status,
210 -
        })
295 +
        .map(book_to_row)
211 296
        .collect();
297 +
    let labels = db::get_category_labels(&state.db).unwrap_or_default();
298 +
299 +
    let library_query = q.q.unwrap_or_default();
300 +
    let library_searched = !library_query.trim().is_empty();
301 +
    let library_results = if library_searched {
302 +
        db::search_books(&state.db, &library_query)
303 +
            .unwrap_or_default()
304 +
            .into_iter()
305 +
            .map(book_to_row)
306 +
            .collect()
307 +
    } else {
308 +
        Vec::new()
309 +
    };
212 310
213 311
    Html(
214 312
        AdminTemplate {
215 313
            success: q.success,
216 314
            error: q.error,
217 315
            books,
316 +
            labels,
317 +
            library_query,
318 +
            library_results,
319 +
            library_searched,
218 320
        }
219 321
        .render()
220 322
        .unwrap(),
221 323
    )
222 324
    .into_response()
325 +
}
326 +
327 +
#[derive(Deserialize)]
328 +
struct CategoryLabelsForm {
329 +
    reading: String,
330 +
    read: String,
331 +
    want: String,
332 +
}
333 +
334 +
async fn admin_update_labels(
335 +
    _session: auth::AuthSession,
336 +
    State(state): State<Arc<AppState>>,
337 +
    Form(form): Form<CategoryLabelsForm>,
338 +
) -> Response {
339 +
    let reading = form.reading.trim();
340 +
    let read = form.read.trim();
341 +
    let want = form.want.trim();
342 +
    if reading.is_empty() || read.is_empty() || want.is_empty() {
343 +
        return Redirect::to("/admin?error=Labels+cannot+be+empty").into_response();
344 +
    }
345 +
    if let Err(e) = db::set_setting(&state.db, "category_label.reading", reading)
346 +
        .and_then(|_| db::set_setting(&state.db, "category_label.read", read))
347 +
        .and_then(|_| db::set_setting(&state.db, "category_label.want", want))
348 +
    {
349 +
        tracing::error!("save labels: {e}");
350 +
        return Redirect::to("/admin?error=Failed+to+save+labels").into_response();
351 +
    }
352 +
    Redirect::to("/admin?success=Labels+updated").into_response()
223 353
}
224 354
225 355
#[derive(Deserialize)]
399 529
        .ok()
400 530
        .filter(|s| !s.is_empty());
401 531
532 +
    let display_mode = std::env::var("LIBRARY_DISPLAY_MODE")
533 +
        .map(|v| DisplayMode::parse(&v))
534 +
        .unwrap_or(DisplayMode::Inline);
535 +
402 536
    let state = Arc::new(AppState {
403 537
        db,
404 538
        admin_password: std::env::var("ADMIN_PASSWORD").ok(),
405 539
        google_books_api_key,
406 540
        cookie_secure,
407 541
        base_url,
542 +
        display_mode,
408 543
    });
409 544
410 545
    let admin_router = Router::new()
415 550
        )
416 551
        .route("/admin/logout", get(logout_handler))
417 552
        .route("/admin/search", get(admin_search_handler))
553 +
        .route("/admin/categories/labels", post(admin_update_labels))
418 554
        .route("/admin/add", post(admin_add_book))
419 555
        .route("/admin/books/{id}/status", post(admin_update_status))
420 556
        .route("/admin/books/{id}/notes", post(admin_update_notes))
apps/library/src/templates/admin.html +90 −5
28 28
    {% endif %}
29 29
30 30
    <section class="admin-form">
31 -
      <h3>Search Books</h3>
31 +
      <h3>Category Labels</h3>
32 +
      <form method="POST" action="/admin/categories/labels" class="labels-form">
33 +
        <label>
34 +
          <span>Reading</span>
35 +
          <input type="text" name="reading" value="{{ labels.reading }}" required />
36 +
        </label>
37 +
        <label>
38 +
          <span>Read</span>
39 +
          <input type="text" name="read" value="{{ labels.read }}" required />
40 +
        </label>
41 +
        <label>
42 +
          <span>Want to Read</span>
43 +
          <input type="text" name="want" value="{{ labels.want }}" required />
44 +
        </label>
45 +
        <button type="submit">Save labels</button>
46 +
      </form>
47 +
    </section>
48 +
49 +
    <section class="admin-form">
50 +
      <h3>Search Books (Google)</h3>
32 51
      <div class="search-row">
33 52
        <input type="text" id="book-query" placeholder="title, author, isbn" />
34 53
        <button type="button" id="search-btn" onclick="searchBooks()">Search</button>
38 57
      <div id="search-results" class="search-results"></div>
39 58
    </section>
40 59
60 +
    <section class="admin-form">
61 +
      <h3>Search Library</h3>
62 +
      <form method="GET" action="/admin" class="search-row">
63 +
        <input type="text" name="q" placeholder="title, author, isbn" value="{{ library_query }}" />
64 +
        <button type="submit">Search</button>
65 +
        {% if library_searched %}
66 +
        <a href="/admin" class="hint">clear</a>
67 +
        {% endif %}
68 +
      </form>
69 +
      {% if library_searched %}
70 +
        {% if library_results.is_empty() %}
71 +
        <p class="hint">No matches.</p>
72 +
        {% else %}
73 +
        <div class="books-list">
74 +
          {% for b in library_results %}
75 +
          <div class="book-card admin">
76 +
            {% if let Some(url) = b.cover_url %}
77 +
            <img class="book-cover" src="{{ url }}" alt="" loading="lazy" />
78 +
            {% else %}
79 +
            <div class="book-cover placeholder"></div>
80 +
            {% endif %}
81 +
            <div class="book-info">
82 +
              <h3 class="book-title">{{ b.title }}</h3>
83 +
              <p class="book-authors">{{ b.authors }}</p>
84 +
              {% if let Some(isbn) = b.isbn %}
85 +
              <p class="book-meta">ISBN: {{ isbn }}</p>
86 +
              {% endif %}
87 +
              <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline">
88 +
                <select name="status" onchange="this.form.submit()">
89 +
                  <option value="read"{% if b.status == "read" %} selected{% endif %}>{{ labels.read }}</option>
90 +
                  <option value="reading"{% if b.status == "reading" %} selected{% endif %}>{{ labels.reading }}</option>
91 +
                  <option value="want"{% if b.status == "want" %} selected{% endif %}>{{ labels.want }}</option>
92 +
                </select>
93 +
                <noscript><button type="submit">Save</button></noscript>
94 +
              </form>
95 +
              <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form">
96 +
                <textarea name="notes" rows="5" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea>
97 +
                <button type="submit">Save notes</button>
98 +
              </form>
99 +
              <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline">
100 +
                <button type="submit" class="danger">Delete</button>
101 +
              </form>
102 +
            </div>
103 +
          </div>
104 +
          {% endfor %}
105 +
        </div>
106 +
        {% endif %}
107 +
      {% endif %}
108 +
    </section>
109 +
41 110
    <div id="scan-modal" class="scan-modal" hidden>
42 111
      <div class="scan-inner">
43 112
        <video id="scan-video" playsinline muted></video>
67 136
            {% endif %}
68 137
            <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline">
69 138
              <select name="status" onchange="this.form.submit()">
70 -
                <option value="read"{% if b.status == "read" %} selected{% endif %}>Read</option>
71 -
                <option value="reading"{% if b.status == "reading" %} selected{% endif %}>Reading</option>
72 -
                <option value="want"{% if b.status == "want" %} selected{% endif %}>Want to Read</option>
139 +
                <option value="read"{% if b.status == "read" %} selected{% endif %}>{{ labels.read }}</option>
140 +
                <option value="reading"{% if b.status == "reading" %} selected{% endif %}>{{ labels.reading }}</option>
141 +
                <option value="want"{% if b.status == "want" %} selected{% endif %}>{{ labels.want }}</option>
73 142
              </select>
74 143
              <noscript><button type="submit">Save</button></noscript>
75 144
            </form>
87 156
      {% endif %}
88 157
    </section>
89 158
159 +
    <div id="category-labels-data"
160 +
         data-want="{{ labels.want }}"
161 +
         data-reading="{{ labels.reading }}"
162 +
         data-read="{{ labels.read }}"
163 +
         hidden></div>
164 +
90 165
    <script src="https://unpkg.com/@zxing/browser@0.1.5/umd/zxing-browser.min.js"></script>
91 166
    <script>
167 +
      (function() {
168 +
        const el = document.getElementById('category-labels-data');
169 +
        window.__categoryLabels = {
170 +
          want: el.dataset.want,
171 +
          reading: el.dataset.reading,
172 +
          read: el.dataset.read,
173 +
        };
174 +
      })();
175 +
92 176
      async function searchBooks() {
93 177
        const q = document.getElementById('book-query').value.trim();
94 178
        if (!q) return;
169 253
170 254
        const select = document.createElement('select');
171 255
        select.name = 'status';
256 +
        const labels = window.__categoryLabels || { want: 'Want to Read', reading: 'Reading', read: 'Read' };
172 257
        ['want', 'reading', 'read'].forEach(function(s) {
173 258
          const o = document.createElement('option');
174 259
          o.value = s;
175 -
          o.textContent = s === 'want' ? 'Want to Read' : s.charAt(0).toUpperCase() + s.slice(1);
260 +
          o.textContent = labels[s];
176 261
          select.appendChild(o);
177 262
        });
178 263
        info.appendChild(select);
apps/library/src/templates/index.html +11 −0
29 29
    <div class="header">
30 30
      <a href="/" class="logo"><h1>LIBRARY</h1></a>
31 31
      <nav class="links">
32 +
        {% if nav_mode %}
33 +
        {% for cat in nav_categories %}
34 +
        <a href="/?category={{ cat.slug }}"{% if cat.active %} class="active"{% endif %}>{{ cat.label }}</a>
35 +
        {% endfor %}
36 +
        {% endif %}
32 37
        <a href="/admin">add</a>
33 38
      </nav>
34 39
    </div>
38 43
    {% else %}
39 44
    {% for section in sections %}
40 45
    <div class="section">
46 +
      {% if !nav_mode %}
41 47
      <h2 class="section-label">{{ section.label }}</h2>
48 +
      {% endif %}
49 +
      {% if section.books.is_empty() %}
50 +
      <p class="no-books">No books in {{ section.label }}.</p>
51 +
      {% else %}
42 52
      <div class="books-list">
43 53
        {% for book in section.books %}
44 54
        <article class="book-card">
57 67
        </article>
58 68
        {% endfor %}
59 69
      </div>
70 +
      {% endif %}
60 71
    </div>
61 72
    {% endfor %}
62 73
    {% endif %}