Merge pull request #39 from stevedylandev/chore/library-upgrades
1e543e37
6 file(s) · +359 −38
| 5 | 5 | PORT=3000 |
|
| 6 | 6 | LIBRARY_DB_PATH=library.sqlite |
|
| 7 | 7 | GOOGLE_BOOKS_API_KEY= |
|
| 8 | + | LIBRARY_DISPLAY_MODE=inline |
| 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 |
| 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 | + | ||
| 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)) |
|
| 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); |
|
| 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 %} |
|