Merge pull request #37 from stevedylandev/feat/add-library-app
55ace1a9
feat/add library app
30 file(s) · +1616 −2
feat/add library app
| 2322 | 2322 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" |
|
| 2323 | 2323 | ||
| 2324 | 2324 | [[package]] |
|
| 2325 | + | name = "library" |
|
| 2326 | + | version = "0.1.0" |
|
| 2327 | + | dependencies = [ |
|
| 2328 | + | "andromeda-auth", |
|
| 2329 | + | "andromeda-darkmatter-css", |
|
| 2330 | + | "andromeda-db", |
|
| 2331 | + | "askama 0.13.1", |
|
| 2332 | + | "axum", |
|
| 2333 | + | "chrono", |
|
| 2334 | + | "dotenvy", |
|
| 2335 | + | "mime_guess", |
|
| 2336 | + | "rand 0.8.5", |
|
| 2337 | + | "reqwest 0.12.28", |
|
| 2338 | + | "rusqlite", |
|
| 2339 | + | "rust-embed", |
|
| 2340 | + | "serde", |
|
| 2341 | + | "serde_json", |
|
| 2342 | + | "subtle", |
|
| 2343 | + | "tokio", |
|
| 2344 | + | "tracing", |
|
| 2345 | + | "tracing-subscriber", |
|
| 2346 | + | "urlencoding", |
|
| 2347 | + | ] |
|
| 2348 | + | ||
| 2349 | + | [[package]] |
|
| 2325 | 2350 | name = "libsqlite3-sys" |
|
| 2326 | 2351 | version = "0.36.0" |
|
| 2327 | 2352 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 8 | 8 | "apps/shrink", |
|
| 9 | 9 | "apps/cellar", |
|
| 10 | 10 | "apps/posts", |
|
| 11 | + | "apps/library", |
|
| 11 | 12 | "crates/auth", |
|
| 12 | 13 | "crates/db", |
|
| 13 | 14 | "crates/darkmatter-css", |
| 5 | 5 | BUCKET="${R2_BUCKET:-andromeda-backups}" |
|
| 6 | 6 | RETENTION_DAYS="${RETENTION_DAYS:-30}" |
|
| 7 | 7 | ||
| 8 | - | DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite feeds:/data/feeds/feeds.sqlite" |
|
| 8 | + | DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite feeds:/data/feeds/feeds.sqlite library:/data/library/library.sqlite" |
|
| 9 | 9 | ||
| 10 | 10 | for entry in $DBS; do |
|
| 11 | 11 | name="${entry%%:*}" |
|
| 28 | 28 | ||
| 29 | 29 | # Prune old backups |
|
| 30 | 30 | cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || date -u -v-${RETENTION_DAYS}d +%Y-%m-%d) |
|
| 31 | - | for name in jotts sipp cellar posts feeds; do |
|
| 31 | + | for name in jotts sipp cellar posts feeds library; do |
|
| 32 | 32 | aws s3 ls "s3://${BUCKET}/${name}/" --endpoint-url "${R2_ENDPOINT}" 2>/dev/null | while read -r line; do |
|
| 33 | 33 | filedate=$(echo "$line" | awk '{print $1}') |
|
| 34 | 34 | filename=$(echo "$line" | awk '{print $4}') |
|
| 7 | 7 | - cellar-data:/data/cellar:ro |
|
| 8 | 8 | - posts-data:/data/posts:ro |
|
| 9 | 9 | - feeds-data:/data/feeds:ro |
|
| 10 | + | - library-data:/data/library:ro |
|
| 10 | 11 | env_file: .env |
|
| 11 | 12 | restart: unless-stopped |
|
| 12 | 13 | ||
| 26 | 27 | feeds-data: |
|
| 27 | 28 | external: true |
|
| 28 | 29 | name: ${FEEDS_VOLUME:-feeds_feeds-data} |
|
| 30 | + | library-data: |
|
| 31 | + | external: true |
|
| 32 | + | name: ${LIBRARY_VOLUME:-library_library-data} |
|
| 1 | + | ADMIN_PASSWORD=changeme |
|
| 2 | + | COOKIE_SECURE=false |
|
| 3 | + | BASE_URL=http://localhost:3000 |
|
| 4 | + | HOST=127.0.0.1 |
|
| 5 | + | PORT=3000 |
|
| 6 | + | LIBRARY_DB_PATH=library.sqlite |
|
| 7 | + | GOOGLE_BOOKS_API_KEY= |
| 1 | + | [package] |
|
| 2 | + | name = "library" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | description = "Personal book tracking" |
|
| 6 | + | license = "MIT" |
|
| 7 | + | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | + | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | + | ||
| 10 | + | [dependencies] |
|
| 11 | + | axum = { workspace = true } |
|
| 12 | + | tokio = { workspace = true } |
|
| 13 | + | serde = { workspace = true } |
|
| 14 | + | serde_json = { workspace = true } |
|
| 15 | + | dotenvy = { workspace = true } |
|
| 16 | + | rust-embed = { workspace = true } |
|
| 17 | + | subtle = { workspace = true } |
|
| 18 | + | rand = { workspace = true } |
|
| 19 | + | rusqlite = { workspace = true } |
|
| 20 | + | tracing = { workspace = true } |
|
| 21 | + | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 22 | + | andromeda-auth = { workspace = true } |
|
| 23 | + | andromeda-db = { workspace = true, features = ["axum", "session"] } |
|
| 24 | + | andromeda-darkmatter-css = { workspace = true } |
|
| 25 | + | askama = "0.13" |
|
| 26 | + | reqwest = { version = "0.12", features = ["json"] } |
|
| 27 | + | chrono = "0.4" |
|
| 28 | + | mime_guess = "2" |
|
| 29 | + | urlencoding = "2" |
| 1 | + | FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef |
|
| 2 | + | WORKDIR /app |
|
| 3 | + | ||
| 4 | + | FROM chef AS planner |
|
| 5 | + | COPY . . |
|
| 6 | + | RUN cargo chef prepare --recipe-path recipe.json |
|
| 7 | + | ||
| 8 | + | FROM chef AS builder |
|
| 9 | + | RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* |
|
| 10 | + | COPY --from=planner /app/recipe.json recipe.json |
|
| 11 | + | RUN cargo chef cook --release --recipe-path recipe.json -p library |
|
| 12 | + | COPY . . |
|
| 13 | + | RUN cargo build --release -p library |
|
| 14 | + | ||
| 15 | + | FROM debian:bookworm-slim |
|
| 16 | + | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 17 | + | COPY --from=builder /app/target/release/library /usr/local/bin/library |
|
| 18 | + | WORKDIR /data |
|
| 19 | + | EXPOSE 3000 |
|
| 20 | + | ENV HOST=0.0.0.0 |
|
| 21 | + | ENV PORT=3000 |
|
| 22 | + | CMD ["library"] |
| 1 | + | # Library |
|
| 2 | + | ||
| 3 | + | A minimal personal book tracker |
|
| 4 | + | ||
| 5 | + | ## Quickstart |
|
| 6 | + | ||
| 7 | + | ```bash |
|
| 8 | + | git clone https://github.com/stevedylandev/andromeda.git |
|
| 9 | + | cd andromeda |
|
| 10 | + | cp apps/library/.env.example apps/library/.env |
|
| 11 | + | # Edit .env with your admin password |
|
| 12 | + | cargo run -p library |
|
| 13 | + | ``` |
|
| 14 | + | ||
| 15 | + | ### Environment Variables |
|
| 16 | + | ||
| 17 | + | | Variable | Description | Default | |
|
| 18 | + | |---|---|---| |
|
| 19 | + | | `ADMIN_PASSWORD` | Password for admin login | `changeme` | |
|
| 20 | + | | `LIBRARY_DB_PATH` | SQLite database file path | `library.sqlite` | |
|
| 21 | + | | `GOOGLE_BOOKS_API_KEY` | Google Books API key for search | | |
|
| 22 | + | | `BASE_URL` | Public base URL | `http://localhost:3000` | |
|
| 23 | + | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 24 | + | | `PORT` | Server port | `3000` | |
|
| 25 | + | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 26 | + | ||
| 27 | + | ## Overview |
|
| 28 | + | ||
| 29 | + | A simple, self-hosted book tracker built with Rust. Highlights: |
|
| 30 | + | - Single Rust binary with embedded assets |
|
| 31 | + | - Password authentication with session cookies |
|
| 32 | + | - Track books across Read, Reading, and Want to Read |
|
| 33 | + | - Google Books search to add titles with cover art and ISBN |
|
| 34 | + | - Per-book notes |
|
| 35 | + | - JSON API for listing and fetching books |
|
| 36 | + | - SQLite for persistent storage |
|
| 37 | + | ||
| 38 | + | ## Structure |
|
| 39 | + | ||
| 40 | + | ``` |
|
| 41 | + | library/ |
|
| 42 | + | ├── src/ |
|
| 43 | + | │ ├── main.rs # App entrypoint, env vars, router |
|
| 44 | + | │ ├── auth.rs # Password verification and sessions |
|
| 45 | + | │ ├── db.rs # SQLite layer (books) |
|
| 46 | + | │ └── google_books.rs # Google Books API client |
|
| 47 | + | ├── templates/ # Askama HTML templates |
|
| 48 | + | ├── static/ # Favicons, og:image, styles |
|
| 49 | + | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 50 | + | └── Cargo.toml |
|
| 51 | + | ``` |
|
| 52 | + | ||
| 53 | + | ## Deployment |
|
| 54 | + | ||
| 55 | + | ### Docker (recommended) |
|
| 56 | + | ||
| 57 | + | From the repo root: |
|
| 58 | + | ||
| 59 | + | ```bash |
|
| 60 | + | cp apps/library/.env.example apps/library/.env |
|
| 61 | + | # Edit .env |
|
| 62 | + | docker compose up -d library |
|
| 63 | + | ``` |
|
| 64 | + | ||
| 65 | + | This will start Library on port `4646` with a persistent volume for the SQLite database. |
|
| 66 | + | ||
| 67 | + | ### Binary |
|
| 68 | + | ||
| 69 | + | ```bash |
|
| 70 | + | cargo build --release -p library |
|
| 71 | + | ``` |
|
| 72 | + | ||
| 73 | + | The resulting binary at `./target/release/library` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
| 1 | + | [general] |
|
| 2 | + | dirs = ["src/templates"] |
| 1 | + | use axum::{ |
|
| 2 | + | extract::{FromRef, FromRequestParts}, |
|
| 3 | + | http::request::Parts, |
|
| 4 | + | response::{IntoResponse, Redirect, Response}, |
|
| 5 | + | }; |
|
| 6 | + | use chrono::{Duration, Utc}; |
|
| 7 | + | use std::sync::Arc; |
|
| 8 | + | ||
| 9 | + | use crate::AppState; |
|
| 10 | + | use andromeda_db::session; |
|
| 11 | + | ||
| 12 | + | pub use andromeda_auth::{ |
|
| 13 | + | build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token, |
|
| 14 | + | verify_password, |
|
| 15 | + | }; |
|
| 16 | + | ||
| 17 | + | const SESSION_DAYS: i64 = 7; |
|
| 18 | + | ||
| 19 | + | pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> { |
|
| 20 | + | let expires = (Utc::now() + Duration::days(SESSION_DAYS)) |
|
| 21 | + | .format("%Y-%m-%d %H:%M:%S") |
|
| 22 | + | .to_string(); |
|
| 23 | + | session::insert_session(db, token, &expires) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool { |
|
| 27 | + | match session::get_session_expiry(db, token) { |
|
| 28 | + | Ok(Some(expires_at)) => { |
|
| 29 | + | chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S") |
|
| 30 | + | .map(|exp| exp > Utc::now().naive_utc()) |
|
| 31 | + | .unwrap_or(false) |
|
| 32 | + | } |
|
| 33 | + | _ => false, |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | pub fn delete_session(db: &andromeda_db::Db, token: &str) { |
|
| 38 | + | let _ = session::delete_session(db, token); |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | pub struct AuthSession; |
|
| 42 | + | ||
| 43 | + | impl<S> FromRequestParts<S> for AuthSession |
|
| 44 | + | where |
|
| 45 | + | S: Send + Sync, |
|
| 46 | + | Arc<AppState>: FromRef<S>, |
|
| 47 | + | { |
|
| 48 | + | type Rejection = Response; |
|
| 49 | + | ||
| 50 | + | async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { |
|
| 51 | + | let state = Arc::<AppState>::from_ref(state); |
|
| 52 | + | if let Some(token) = extract_session_cookie(&parts.headers) { |
|
| 53 | + | if is_valid_session(&state.db, &token) { |
|
| 54 | + | return Ok(AuthSession); |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | Err(Redirect::to("/admin/login").into_response()) |
|
| 58 | + | } |
|
| 59 | + | } |
|
| 60 | + |
| 1 | + | use andromeda_db::{Db, DbError}; |
|
| 2 | + | use rusqlite::{params, OptionalExtension}; |
|
| 3 | + | use serde::{Deserialize, Serialize}; |
|
| 4 | + | ||
| 5 | + | pub const BOOKS_SCHEMA: &str = r#" |
|
| 6 | + | CREATE TABLE IF NOT EXISTS books ( |
|
| 7 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 8 | + | google_id TEXT UNIQUE, |
|
| 9 | + | title TEXT NOT NULL, |
|
| 10 | + | authors TEXT NOT NULL, |
|
| 11 | + | isbn TEXT, |
|
| 12 | + | cover_url TEXT, |
|
| 13 | + | notes TEXT, |
|
| 14 | + | status TEXT NOT NULL CHECK (status IN ('read','reading','want')), |
|
| 15 | + | added_at INTEGER NOT NULL, |
|
| 16 | + | updated_at INTEGER NOT NULL |
|
| 17 | + | ); |
|
| 18 | + | CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC); |
|
| 19 | + | "#; |
|
| 20 | + | ||
| 21 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
|
| 22 | + | #[serde(rename_all = "lowercase")] |
|
| 23 | + | pub enum BookStatus { |
|
| 24 | + | Read, |
|
| 25 | + | Reading, |
|
| 26 | + | Want, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | impl BookStatus { |
|
| 30 | + | pub fn as_str(&self) -> &'static str { |
|
| 31 | + | match self { |
|
| 32 | + | BookStatus::Read => "read", |
|
| 33 | + | BookStatus::Reading => "reading", |
|
| 34 | + | BookStatus::Want => "want", |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | pub fn parse(s: &str) -> Option<Self> { |
|
| 39 | + | match s { |
|
| 40 | + | "read" => Some(BookStatus::Read), |
|
| 41 | + | "reading" => Some(BookStatus::Reading), |
|
| 42 | + | "want" => Some(BookStatus::Want), |
|
| 43 | + | _ => None, |
|
| 44 | + | } |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[derive(Debug, Clone, Serialize)] |
|
| 49 | + | pub struct Book { |
|
| 50 | + | pub id: i64, |
|
| 51 | + | pub google_id: Option<String>, |
|
| 52 | + | pub title: String, |
|
| 53 | + | pub authors: String, |
|
| 54 | + | pub isbn: Option<String>, |
|
| 55 | + | pub cover_url: Option<String>, |
|
| 56 | + | pub notes: Option<String>, |
|
| 57 | + | pub status: String, |
|
| 58 | + | pub added_at: i64, |
|
| 59 | + | pub updated_at: i64, |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | #[derive(Debug, Clone)] |
|
| 63 | + | pub struct NewBook { |
|
| 64 | + | pub google_id: Option<String>, |
|
| 65 | + | pub title: String, |
|
| 66 | + | pub authors: String, |
|
| 67 | + | pub isbn: Option<String>, |
|
| 68 | + | pub cover_url: Option<String>, |
|
| 69 | + | pub notes: Option<String>, |
|
| 70 | + | pub status: BookStatus, |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | fn map_book(row: &rusqlite::Row) -> rusqlite::Result<Book> { |
|
| 74 | + | Ok(Book { |
|
| 75 | + | id: row.get(0)?, |
|
| 76 | + | google_id: row.get(1)?, |
|
| 77 | + | title: row.get(2)?, |
|
| 78 | + | authors: row.get(3)?, |
|
| 79 | + | isbn: row.get(4)?, |
|
| 80 | + | cover_url: row.get(5)?, |
|
| 81 | + | notes: row.get(6)?, |
|
| 82 | + | status: row.get(7)?, |
|
| 83 | + | added_at: row.get(8)?, |
|
| 84 | + | updated_at: row.get(9)?, |
|
| 85 | + | }) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | const SELECT_COLS: &str = |
|
| 89 | + | "id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at"; |
|
| 90 | + | ||
| 91 | + | pub fn list_books(db: &Db, status: Option<BookStatus>) -> Result<Vec<Book>, DbError> { |
|
| 92 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 93 | + | let books = match status { |
|
| 94 | + | Some(s) => { |
|
| 95 | + | let sql = format!( |
|
| 96 | + | "SELECT {SELECT_COLS} FROM books WHERE status = ?1 ORDER BY added_at DESC" |
|
| 97 | + | ); |
|
| 98 | + | let mut stmt = conn.prepare(&sql)?; |
|
| 99 | + | let rows = stmt.query_map([s.as_str()], map_book)?; |
|
| 100 | + | rows.collect::<Result<Vec<_>, _>>()? |
|
| 101 | + | } |
|
| 102 | + | None => { |
|
| 103 | + | let sql = format!("SELECT {SELECT_COLS} FROM books ORDER BY added_at DESC"); |
|
| 104 | + | let mut stmt = conn.prepare(&sql)?; |
|
| 105 | + | let rows = stmt.query_map([], map_book)?; |
|
| 106 | + | rows.collect::<Result<Vec<_>, _>>()? |
|
| 107 | + | } |
|
| 108 | + | }; |
|
| 109 | + | Ok(books) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | pub fn get_book(db: &Db, id: i64) -> Result<Option<Book>, DbError> { |
|
| 113 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 114 | + | let sql = format!("SELECT {SELECT_COLS} FROM books WHERE id = ?1"); |
|
| 115 | + | let book = conn |
|
| 116 | + | .query_row(&sql, [id], map_book) |
|
| 117 | + | .optional()?; |
|
| 118 | + | Ok(book) |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | pub fn insert_book(db: &Db, b: &NewBook) -> Result<i64, DbError> { |
|
| 122 | + | let now = chrono::Utc::now().timestamp(); |
|
| 123 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 124 | + | conn.execute( |
|
| 125 | + | "INSERT INTO books (google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at) |
|
| 126 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) |
|
| 127 | + | ON CONFLICT(google_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at", |
|
| 128 | + | params![ |
|
| 129 | + | b.google_id, |
|
| 130 | + | b.title, |
|
| 131 | + | b.authors, |
|
| 132 | + | b.isbn, |
|
| 133 | + | b.cover_url, |
|
| 134 | + | b.notes, |
|
| 135 | + | b.status.as_str(), |
|
| 136 | + | now, |
|
| 137 | + | ], |
|
| 138 | + | )?; |
|
| 139 | + | Ok(conn.last_insert_rowid()) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | pub fn update_book_status(db: &Db, id: i64, status: BookStatus) -> Result<bool, DbError> { |
|
| 143 | + | let now = chrono::Utc::now().timestamp(); |
|
| 144 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 145 | + | let n = conn.execute( |
|
| 146 | + | "UPDATE books SET status = ?1, updated_at = ?2 WHERE id = ?3", |
|
| 147 | + | params![status.as_str(), now, id], |
|
| 148 | + | )?; |
|
| 149 | + | Ok(n > 0) |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | pub fn update_book_notes(db: &Db, id: i64, notes: Option<&str>) -> Result<bool, DbError> { |
|
| 153 | + | let now = chrono::Utc::now().timestamp(); |
|
| 154 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 155 | + | let n = conn.execute( |
|
| 156 | + | "UPDATE books SET notes = ?1, updated_at = ?2 WHERE id = ?3", |
|
| 157 | + | params![notes, now, id], |
|
| 158 | + | )?; |
|
| 159 | + | Ok(n > 0) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | pub fn delete_book(db: &Db, id: i64) -> Result<bool, DbError> { |
|
| 163 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 164 | + | let n = conn.execute("DELETE FROM books WHERE id = ?1", [id])?; |
|
| 165 | + | Ok(n > 0) |
|
| 166 | + | } |
|
| 167 | + |
| 1 | + | use serde::{Deserialize, Serialize}; |
|
| 2 | + | use std::time::Duration; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug, Clone, Serialize)] |
|
| 5 | + | pub struct SearchHit { |
|
| 6 | + | pub google_id: String, |
|
| 7 | + | pub title: String, |
|
| 8 | + | pub authors: String, |
|
| 9 | + | pub isbn: Option<String>, |
|
| 10 | + | pub cover_url: Option<String>, |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | #[derive(Deserialize)] |
|
| 14 | + | struct VolumesResponse { |
|
| 15 | + | #[serde(default)] |
|
| 16 | + | items: Vec<Volume>, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[derive(Deserialize)] |
|
| 20 | + | struct Volume { |
|
| 21 | + | id: String, |
|
| 22 | + | #[serde(rename = "volumeInfo")] |
|
| 23 | + | volume_info: VolumeInfo, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | #[derive(Deserialize)] |
|
| 27 | + | struct VolumeInfo { |
|
| 28 | + | title: Option<String>, |
|
| 29 | + | #[serde(default)] |
|
| 30 | + | authors: Vec<String>, |
|
| 31 | + | #[serde(default, rename = "industryIdentifiers")] |
|
| 32 | + | identifiers: Vec<Identifier>, |
|
| 33 | + | #[serde(rename = "imageLinks")] |
|
| 34 | + | image_links: Option<ImageLinks>, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | #[derive(Deserialize)] |
|
| 38 | + | struct Identifier { |
|
| 39 | + | #[serde(rename = "type")] |
|
| 40 | + | kind: String, |
|
| 41 | + | identifier: String, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | #[derive(Deserialize)] |
|
| 45 | + | struct ImageLinks { |
|
| 46 | + | thumbnail: Option<String>, |
|
| 47 | + | #[serde(rename = "smallThumbnail")] |
|
| 48 | + | small_thumbnail: Option<String>, |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | pub async fn search(query: &str, api_key: Option<&str>) -> Result<Vec<SearchHit>, String> { |
|
| 52 | + | let q = urlencoding::encode(query.trim()); |
|
| 53 | + | if q.is_empty() { |
|
| 54 | + | return Ok(Vec::new()); |
|
| 55 | + | } |
|
| 56 | + | let mut url = format!( |
|
| 57 | + | "https://www.googleapis.com/books/v1/volumes?q={q}&maxResults=10&printType=books" |
|
| 58 | + | ); |
|
| 59 | + | if let Some(key) = api_key { |
|
| 60 | + | url.push_str(&format!("&key={}", urlencoding::encode(key))); |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | let client = reqwest::Client::builder() |
|
| 64 | + | .timeout(Duration::from_secs(8)) |
|
| 65 | + | .user_agent("andromeda-library/0.1") |
|
| 66 | + | .build() |
|
| 67 | + | .map_err(|e| format!("client build: {e}"))?; |
|
| 68 | + | ||
| 69 | + | let resp = client |
|
| 70 | + | .get(&url) |
|
| 71 | + | .send() |
|
| 72 | + | .await |
|
| 73 | + | .map_err(|e| format!("request: {e}"))?; |
|
| 74 | + | ||
| 75 | + | if !resp.status().is_success() { |
|
| 76 | + | return Err(format!("google books status {}", resp.status())); |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | let data: VolumesResponse = resp |
|
| 80 | + | .json() |
|
| 81 | + | .await |
|
| 82 | + | .map_err(|e| format!("parse: {e}"))?; |
|
| 83 | + | ||
| 84 | + | Ok(data |
|
| 85 | + | .items |
|
| 86 | + | .into_iter() |
|
| 87 | + | .map(|v| { |
|
| 88 | + | let info = v.volume_info; |
|
| 89 | + | let isbn = pick_isbn(&info.identifiers); |
|
| 90 | + | let cover_url = info |
|
| 91 | + | .image_links |
|
| 92 | + | .as_ref() |
|
| 93 | + | .and_then(|l| l.thumbnail.clone().or_else(|| l.small_thumbnail.clone())) |
|
| 94 | + | .map(|u| u.replacen("http://", "https://", 1)); |
|
| 95 | + | SearchHit { |
|
| 96 | + | google_id: v.id, |
|
| 97 | + | title: info.title.unwrap_or_else(|| "Untitled".to_string()), |
|
| 98 | + | authors: info.authors.join(", "), |
|
| 99 | + | isbn, |
|
| 100 | + | cover_url, |
|
| 101 | + | } |
|
| 102 | + | }) |
|
| 103 | + | .collect()) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | fn pick_isbn(ids: &[Identifier]) -> Option<String> { |
|
| 107 | + | ids.iter() |
|
| 108 | + | .find(|i| i.kind == "ISBN_13") |
|
| 109 | + | .or_else(|| ids.iter().find(|i| i.kind == "ISBN_10")) |
|
| 110 | + | .map(|i| i.identifier.clone()) |
|
| 111 | + | } |
| 1 | + | mod auth; |
|
| 2 | + | mod db; |
|
| 3 | + | mod google_books; |
|
| 4 | + | ||
| 5 | + | use std::sync::{Arc, Mutex}; |
|
| 6 | + | ||
| 7 | + | use andromeda_db::{ |
|
| 8 | + | session::{prune_expired_sessions, SESSION_SCHEMA}, |
|
| 9 | + | Db, |
|
| 10 | + | }; |
|
| 11 | + | use askama::Template; |
|
| 12 | + | use axum::{ |
|
| 13 | + | extract::{Path, Query, State}, |
|
| 14 | + | http::{header, HeaderMap, StatusCode}, |
|
| 15 | + | response::{Html, IntoResponse, Json, Redirect, Response}, |
|
| 16 | + | routing::{get, post}, |
|
| 17 | + | Form, Router, |
|
| 18 | + | }; |
|
| 19 | + | use rusqlite::Connection; |
|
| 20 | + | use rust_embed::Embed; |
|
| 21 | + | use serde::Deserialize; |
|
| 22 | + | ||
| 23 | + | use crate::db::{Book, BookStatus, NewBook}; |
|
| 24 | + | ||
| 25 | + | #[derive(Embed)] |
|
| 26 | + | #[folder = "static/"] |
|
| 27 | + | struct Static; |
|
| 28 | + | ||
| 29 | + | pub struct AppState { |
|
| 30 | + | pub db: Db, |
|
| 31 | + | pub admin_password: Option<String>, |
|
| 32 | + | pub google_books_api_key: Option<String>, |
|
| 33 | + | pub cookie_secure: bool, |
|
| 34 | + | pub base_url: String, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | // ── Templates ──────────────────────────────────────────────────────────── |
|
| 38 | + | ||
| 39 | + | struct BookView { |
|
| 40 | + | title: String, |
|
| 41 | + | authors: String, |
|
| 42 | + | cover_url: Option<String>, |
|
| 43 | + | notes: Option<String>, |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | #[derive(Template)] |
|
| 47 | + | #[template(path = "index.html")] |
|
| 48 | + | struct IndexTemplate { |
|
| 49 | + | base_url: String, |
|
| 50 | + | tab: &'static str, |
|
| 51 | + | tab_label: &'static str, |
|
| 52 | + | books: Vec<BookView>, |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | #[derive(Template)] |
|
| 56 | + | #[template(path = "login.html")] |
|
| 57 | + | struct LoginTemplate { |
|
| 58 | + | error: Option<String>, |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | struct AdminBookRow { |
|
| 62 | + | id: i64, |
|
| 63 | + | title: String, |
|
| 64 | + | authors: String, |
|
| 65 | + | isbn: Option<String>, |
|
| 66 | + | cover_url: Option<String>, |
|
| 67 | + | notes: Option<String>, |
|
| 68 | + | status: String, |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | #[derive(Template)] |
|
| 72 | + | #[template(path = "admin.html")] |
|
| 73 | + | struct AdminTemplate { |
|
| 74 | + | success: Option<String>, |
|
| 75 | + | error: Option<String>, |
|
| 76 | + | books: Vec<AdminBookRow>, |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | fn render_index( |
|
| 80 | + | state: &AppState, |
|
| 81 | + | status: BookStatus, |
|
| 82 | + | tab: &'static str, |
|
| 83 | + | label: &'static str, |
|
| 84 | + | ) -> Response { |
|
| 85 | + | let books = db::list_books(&state.db, Some(status)) |
|
| 86 | + | .unwrap_or_default() |
|
| 87 | + | .into_iter() |
|
| 88 | + | .map(|b: Book| BookView { |
|
| 89 | + | title: b.title, |
|
| 90 | + | authors: b.authors, |
|
| 91 | + | cover_url: b.cover_url, |
|
| 92 | + | notes: b.notes, |
|
| 93 | + | }) |
|
| 94 | + | .collect(); |
|
| 95 | + | Html( |
|
| 96 | + | IndexTemplate { |
|
| 97 | + | base_url: state.base_url.clone(), |
|
| 98 | + | tab, |
|
| 99 | + | tab_label: label, |
|
| 100 | + | books, |
|
| 101 | + | } |
|
| 102 | + | .render() |
|
| 103 | + | .unwrap(), |
|
| 104 | + | ) |
|
| 105 | + | .into_response() |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | async fn root_redirect() -> Response { |
|
| 109 | + | Redirect::to("/read").into_response() |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | async fn read_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 113 | + | render_index(&state, BookStatus::Read, "read", "Read") |
|
| 114 | + | } |
|
| 115 | + | async fn reading_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 116 | + | render_index(&state, BookStatus::Reading, "reading", "Reading") |
|
| 117 | + | } |
|
| 118 | + | async fn want_handler(State(state): State<Arc<AppState>>) -> Response { |
|
| 119 | + | render_index(&state, BookStatus::Want, "want", "Want to Read") |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | async fn static_handler(Path(path): Path<String>) -> Response { |
|
| 123 | + | match Static::get(&path) { |
|
| 124 | + | Some(file) => { |
|
| 125 | + | let mime = mime_guess::from_path(&path).first_or_octet_stream(); |
|
| 126 | + | ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response() |
|
| 127 | + | } |
|
| 128 | + | None => StatusCode::NOT_FOUND.into_response(), |
|
| 129 | + | } |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | // ── Admin ──────────────────────────────────────────────────────────────── |
|
| 133 | + | ||
| 134 | + | #[derive(Deserialize, Default)] |
|
| 135 | + | struct FlashQuery { |
|
| 136 | + | error: Option<String>, |
|
| 137 | + | success: Option<String>, |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | #[derive(Deserialize)] |
|
| 141 | + | struct LoginForm { |
|
| 142 | + | password: String, |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | async fn login_get_handler(Query(q): Query<FlashQuery>) -> Response { |
|
| 146 | + | Html(LoginTemplate { error: q.error }.render().unwrap()).into_response() |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | async fn login_post_handler( |
|
| 150 | + | State(state): State<Arc<AppState>>, |
|
| 151 | + | Form(form): Form<LoginForm>, |
|
| 152 | + | ) -> Response { |
|
| 153 | + | let admin_password = match &state.admin_password { |
|
| 154 | + | Some(p) => p, |
|
| 155 | + | None => { |
|
| 156 | + | return Redirect::to("/admin/login?error=No+admin+password+configured").into_response(); |
|
| 157 | + | } |
|
| 158 | + | }; |
|
| 159 | + | if !auth::verify_password(&form.password, admin_password) { |
|
| 160 | + | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | let token = auth::generate_session_token(); |
|
| 164 | + | if let Err(e) = auth::create_session(&state.db, &token) { |
|
| 165 | + | tracing::error!("failed to create session: {e}"); |
|
| 166 | + | return Redirect::to("/admin/login?error=Session+error").into_response(); |
|
| 167 | + | } |
|
| 168 | + | let _ = prune_expired_sessions(&state.db); |
|
| 169 | + | ||
| 170 | + | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 171 | + | let mut resp = Redirect::to("/admin").into_response(); |
|
| 172 | + | resp.headers_mut() |
|
| 173 | + | .insert(header::SET_COOKIE, cookie.parse().unwrap()); |
|
| 174 | + | resp |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response { |
|
| 178 | + | if let Some(token) = auth::extract_session_cookie(&headers) { |
|
| 179 | + | auth::delete_session(&state.db, &token); |
|
| 180 | + | } |
|
| 181 | + | let mut resp = Redirect::to("/admin/login").into_response(); |
|
| 182 | + | resp.headers_mut().insert( |
|
| 183 | + | header::SET_COOKIE, |
|
| 184 | + | auth::clear_session_cookie().parse().unwrap(), |
|
| 185 | + | ); |
|
| 186 | + | resp |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | async fn admin_handler( |
|
| 190 | + | _session: auth::AuthSession, |
|
| 191 | + | State(state): State<Arc<AppState>>, |
|
| 192 | + | Query(q): Query<FlashQuery>, |
|
| 193 | + | ) -> Response { |
|
| 194 | + | let books = db::list_books(&state.db, None) |
|
| 195 | + | .unwrap_or_default() |
|
| 196 | + | .into_iter() |
|
| 197 | + | .map(|b| AdminBookRow { |
|
| 198 | + | id: b.id, |
|
| 199 | + | title: b.title, |
|
| 200 | + | authors: b.authors, |
|
| 201 | + | isbn: b.isbn, |
|
| 202 | + | cover_url: b.cover_url, |
|
| 203 | + | notes: b.notes, |
|
| 204 | + | status: b.status, |
|
| 205 | + | }) |
|
| 206 | + | .collect(); |
|
| 207 | + | ||
| 208 | + | Html( |
|
| 209 | + | AdminTemplate { |
|
| 210 | + | success: q.success, |
|
| 211 | + | error: q.error, |
|
| 212 | + | books, |
|
| 213 | + | } |
|
| 214 | + | .render() |
|
| 215 | + | .unwrap(), |
|
| 216 | + | ) |
|
| 217 | + | .into_response() |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | #[derive(Deserialize)] |
|
| 221 | + | struct SearchQuery { |
|
| 222 | + | q: String, |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | async fn admin_search_handler( |
|
| 226 | + | _session: auth::AuthSession, |
|
| 227 | + | State(state): State<Arc<AppState>>, |
|
| 228 | + | Query(q): Query<SearchQuery>, |
|
| 229 | + | ) -> Response { |
|
| 230 | + | match google_books::search(&q.q, state.google_books_api_key.as_deref()).await { |
|
| 231 | + | Ok(hits) => Json(hits).into_response(), |
|
| 232 | + | Err(e) => { |
|
| 233 | + | tracing::warn!("google books search failed: {e}"); |
|
| 234 | + | (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e }))) |
|
| 235 | + | .into_response() |
|
| 236 | + | } |
|
| 237 | + | } |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | #[derive(Deserialize)] |
|
| 241 | + | struct AddBookForm { |
|
| 242 | + | google_id: Option<String>, |
|
| 243 | + | title: String, |
|
| 244 | + | authors: String, |
|
| 245 | + | isbn: Option<String>, |
|
| 246 | + | cover_url: Option<String>, |
|
| 247 | + | status: String, |
|
| 248 | + | } |
|
| 249 | + | ||
| 250 | + | async fn admin_add_book( |
|
| 251 | + | _session: auth::AuthSession, |
|
| 252 | + | State(state): State<Arc<AppState>>, |
|
| 253 | + | Form(form): Form<AddBookForm>, |
|
| 254 | + | ) -> Response { |
|
| 255 | + | let Some(status) = BookStatus::parse(&form.status) else { |
|
| 256 | + | return Redirect::to("/admin?error=Invalid+status").into_response(); |
|
| 257 | + | }; |
|
| 258 | + | let new_book = NewBook { |
|
| 259 | + | google_id: form.google_id.filter(|s| !s.is_empty()), |
|
| 260 | + | title: form.title, |
|
| 261 | + | authors: form.authors, |
|
| 262 | + | isbn: form.isbn.filter(|s| !s.is_empty()), |
|
| 263 | + | cover_url: form.cover_url.filter(|s| !s.is_empty()), |
|
| 264 | + | notes: None, |
|
| 265 | + | status, |
|
| 266 | + | }; |
|
| 267 | + | match db::insert_book(&state.db, &new_book) { |
|
| 268 | + | Ok(_) => Redirect::to("/admin?success=Book+added").into_response(), |
|
| 269 | + | Err(e) => { |
|
| 270 | + | tracing::error!("insert book: {e}"); |
|
| 271 | + | Redirect::to("/admin?error=Failed+to+add+book").into_response() |
|
| 272 | + | } |
|
| 273 | + | } |
|
| 274 | + | } |
|
| 275 | + | ||
| 276 | + | #[derive(Deserialize)] |
|
| 277 | + | struct UpdateStatusForm { |
|
| 278 | + | status: String, |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | async fn admin_update_status( |
|
| 282 | + | _session: auth::AuthSession, |
|
| 283 | + | State(state): State<Arc<AppState>>, |
|
| 284 | + | Path(id): Path<i64>, |
|
| 285 | + | Form(form): Form<UpdateStatusForm>, |
|
| 286 | + | ) -> Response { |
|
| 287 | + | let Some(status) = BookStatus::parse(&form.status) else { |
|
| 288 | + | return Redirect::to("/admin?error=Invalid+status").into_response(); |
|
| 289 | + | }; |
|
| 290 | + | let _ = db::update_book_status(&state.db, id, status); |
|
| 291 | + | Redirect::to("/admin?success=Status+updated").into_response() |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | #[derive(Deserialize)] |
|
| 295 | + | struct UpdateNotesForm { |
|
| 296 | + | notes: String, |
|
| 297 | + | } |
|
| 298 | + | ||
| 299 | + | async fn admin_update_notes( |
|
| 300 | + | _session: auth::AuthSession, |
|
| 301 | + | State(state): State<Arc<AppState>>, |
|
| 302 | + | Path(id): Path<i64>, |
|
| 303 | + | Form(form): Form<UpdateNotesForm>, |
|
| 304 | + | ) -> Response { |
|
| 305 | + | let trimmed = form.notes.trim(); |
|
| 306 | + | let notes = if trimmed.is_empty() { None } else { Some(trimmed) }; |
|
| 307 | + | let _ = db::update_book_notes(&state.db, id, notes); |
|
| 308 | + | Redirect::to("/admin?success=Notes+saved").into_response() |
|
| 309 | + | } |
|
| 310 | + | ||
| 311 | + | async fn admin_delete_book( |
|
| 312 | + | _session: auth::AuthSession, |
|
| 313 | + | State(state): State<Arc<AppState>>, |
|
| 314 | + | Path(id): Path<i64>, |
|
| 315 | + | ) -> Response { |
|
| 316 | + | let _ = db::delete_book(&state.db, id); |
|
| 317 | + | Redirect::to("/admin?success=Book+removed").into_response() |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | // ── JSON API ───────────────────────────────────────────────────────────── |
|
| 321 | + | ||
| 322 | + | #[derive(Deserialize)] |
|
| 323 | + | struct ListBooksQuery { |
|
| 324 | + | status: Option<String>, |
|
| 325 | + | } |
|
| 326 | + | ||
| 327 | + | async fn api_list_books( |
|
| 328 | + | State(state): State<Arc<AppState>>, |
|
| 329 | + | Query(q): Query<ListBooksQuery>, |
|
| 330 | + | ) -> Response { |
|
| 331 | + | let status = match q.status.as_deref() { |
|
| 332 | + | None | Some("") | Some("all") => None, |
|
| 333 | + | Some(s) => match BookStatus::parse(s) { |
|
| 334 | + | Some(st) => Some(st), |
|
| 335 | + | None => { |
|
| 336 | + | return ( |
|
| 337 | + | StatusCode::BAD_REQUEST, |
|
| 338 | + | Json(serde_json::json!({ "error": "invalid status" })), |
|
| 339 | + | ) |
|
| 340 | + | .into_response(); |
|
| 341 | + | } |
|
| 342 | + | }, |
|
| 343 | + | }; |
|
| 344 | + | match db::list_books(&state.db, status) { |
|
| 345 | + | Ok(books) => Json(books).into_response(), |
|
| 346 | + | Err(e) => { |
|
| 347 | + | tracing::error!("list books: {e}"); |
|
| 348 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 349 | + | } |
|
| 350 | + | } |
|
| 351 | + | } |
|
| 352 | + | ||
| 353 | + | async fn api_get_book( |
|
| 354 | + | State(state): State<Arc<AppState>>, |
|
| 355 | + | Path(id): Path<i64>, |
|
| 356 | + | ) -> Response { |
|
| 357 | + | match db::get_book(&state.db, id) { |
|
| 358 | + | Ok(Some(book)) => Json(book).into_response(), |
|
| 359 | + | Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" }))) |
|
| 360 | + | .into_response(), |
|
| 361 | + | Err(e) => { |
|
| 362 | + | tracing::error!("get book: {e}"); |
|
| 363 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() |
|
| 364 | + | } |
|
| 365 | + | } |
|
| 366 | + | } |
|
| 367 | + | ||
| 368 | + | // ── main ───────────────────────────────────────────────────────────────── |
|
| 369 | + | ||
| 370 | + | #[tokio::main] |
|
| 371 | + | async fn main() { |
|
| 372 | + | dotenvy::dotenv().ok(); |
|
| 373 | + | tracing_subscriber::fmt() |
|
| 374 | + | .with_env_filter( |
|
| 375 | + | tracing_subscriber::EnvFilter::try_from_default_env() |
|
| 376 | + | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,library=info")), |
|
| 377 | + | ) |
|
| 378 | + | .init(); |
|
| 379 | + | ||
| 380 | + | let db_path = |
|
| 381 | + | std::env::var("LIBRARY_DB_PATH").unwrap_or_else(|_| "library.sqlite".to_string()); |
|
| 382 | + | let conn = Connection::open(&db_path).expect("open sqlite"); |
|
| 383 | + | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 384 | + | conn.execute_batch(db::BOOKS_SCHEMA).expect("books schema"); |
|
| 385 | + | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 386 | + | ||
| 387 | + | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 388 | + | .map(|v| v.eq_ignore_ascii_case("true")) |
|
| 389 | + | .unwrap_or(false); |
|
| 390 | + | let base_url = |
|
| 391 | + | std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); |
|
| 392 | + | ||
| 393 | + | let google_books_api_key = std::env::var("GOOGLE_BOOKS_API_KEY") |
|
| 394 | + | .ok() |
|
| 395 | + | .filter(|s| !s.is_empty()); |
|
| 396 | + | ||
| 397 | + | let state = Arc::new(AppState { |
|
| 398 | + | db, |
|
| 399 | + | admin_password: std::env::var("ADMIN_PASSWORD").ok(), |
|
| 400 | + | google_books_api_key, |
|
| 401 | + | cookie_secure, |
|
| 402 | + | base_url, |
|
| 403 | + | }); |
|
| 404 | + | ||
| 405 | + | let admin_router = Router::new() |
|
| 406 | + | .route("/admin", get(admin_handler)) |
|
| 407 | + | .route( |
|
| 408 | + | "/admin/login", |
|
| 409 | + | get(login_get_handler).post(login_post_handler), |
|
| 410 | + | ) |
|
| 411 | + | .route("/admin/logout", get(logout_handler)) |
|
| 412 | + | .route("/admin/search", get(admin_search_handler)) |
|
| 413 | + | .route("/admin/add", post(admin_add_book)) |
|
| 414 | + | .route("/admin/books/{id}/status", post(admin_update_status)) |
|
| 415 | + | .route("/admin/books/{id}/notes", post(admin_update_notes)) |
|
| 416 | + | .route("/admin/books/{id}/delete", post(admin_delete_book)); |
|
| 417 | + | ||
| 418 | + | let api_router = Router::new() |
|
| 419 | + | .route("/api/books", get(api_list_books)) |
|
| 420 | + | .route("/api/books/{id}", get(api_get_book)); |
|
| 421 | + | ||
| 422 | + | let app = Router::new() |
|
| 423 | + | .route("/", get(root_redirect)) |
|
| 424 | + | .route("/read", get(read_handler)) |
|
| 425 | + | .route("/reading", get(reading_handler)) |
|
| 426 | + | .route("/want", get(want_handler)) |
|
| 427 | + | .route("/static/{*path}", get(static_handler)) |
|
| 428 | + | .merge(admin_router) |
|
| 429 | + | .merge(api_router) |
|
| 430 | + | .merge(andromeda_darkmatter_css::router::<Arc<AppState>>()) |
|
| 431 | + | .with_state(state); |
|
| 432 | + | ||
| 433 | + | let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
|
| 434 | + | let port: u16 = std::env::var("PORT") |
|
| 435 | + | .ok() |
|
| 436 | + | .and_then(|v| v.parse().ok()) |
|
| 437 | + | .unwrap_or(3000); |
|
| 438 | + | let addr = format!("{host}:{port}"); |
|
| 439 | + | let listener = tokio::net::TcpListener::bind(&addr) |
|
| 440 | + | .await |
|
| 441 | + | .unwrap_or_else(|_| panic!("Failed to bind to {addr}")); |
|
| 442 | + | ||
| 443 | + | tracing::info!("Library server running on http://{host}:{port}"); |
|
| 444 | + | axum::serve(listener, app).await.unwrap(); |
|
| 445 | + | } |
|
| 446 | + |
| 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 let Some(msg) = success %} |
|
| 24 | + | <p class="success">{{ msg }}</p> |
|
| 25 | + | {% endif %} |
|
| 26 | + | {% if let Some(err) = error %} |
|
| 27 | + | <p class="error">{{ err }}</p> |
|
| 28 | + | {% endif %} |
|
| 29 | + | ||
| 30 | + | <section class="admin-form"> |
|
| 31 | + | <h3>Search Books</h3> |
|
| 32 | + | <div class="search-row"> |
|
| 33 | + | <input type="text" id="book-query" placeholder="title, author, isbn" /> |
|
| 34 | + | <button type="button" id="search-btn" onclick="searchBooks()">Search</button> |
|
| 35 | + | <button type="button" id="scan-btn" onclick="openScanner()" hidden>Scan</button> |
|
| 36 | + | </div> |
|
| 37 | + | <div id="search-status" class="search-status" style="display:none;"></div> |
|
| 38 | + | <div id="search-results" class="search-results"></div> |
|
| 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> |
|
| 48 | + | ||
| 49 | + | <section class="admin-subs"> |
|
| 50 | + | <h3>Library ({{ books.len() }})</h3> |
|
| 51 | + | {% if books.is_empty() %} |
|
| 52 | + | <p class="hint">No books yet. Search above to add one.</p> |
|
| 53 | + | {% else %} |
|
| 54 | + | <div class="books-list"> |
|
| 55 | + | {% for b in books %} |
|
| 56 | + | <div class="book-card admin"> |
|
| 57 | + | {% if let Some(url) = b.cover_url %} |
|
| 58 | + | <img class="book-cover" src="{{ url }}" alt="" loading="lazy" /> |
|
| 59 | + | {% else %} |
|
| 60 | + | <div class="book-cover placeholder"></div> |
|
| 61 | + | {% endif %} |
|
| 62 | + | <div class="book-info"> |
|
| 63 | + | <h3 class="book-title">{{ b.title }}</h3> |
|
| 64 | + | <p class="book-authors">{{ b.authors }}</p> |
|
| 65 | + | {% if let Some(isbn) = b.isbn %} |
|
| 66 | + | <p class="book-meta">ISBN: {{ isbn }}</p> |
|
| 67 | + | {% endif %} |
|
| 68 | + | <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline"> |
|
| 69 | + | <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> |
|
| 73 | + | </select> |
|
| 74 | + | <noscript><button type="submit">Save</button></noscript> |
|
| 75 | + | </form> |
|
| 76 | + | <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form"> |
|
| 77 | + | <textarea name="notes" rows="5" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea> |
|
| 78 | + | <button type="submit">Save notes</button> |
|
| 79 | + | </form> |
|
| 80 | + | <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline"> |
|
| 81 | + | <button type="submit" class="danger">Delete</button> |
|
| 82 | + | </form> |
|
| 83 | + | </div> |
|
| 84 | + | </div> |
|
| 85 | + | {% endfor %} |
|
| 86 | + | </div> |
|
| 87 | + | {% endif %} |
|
| 88 | + | </section> |
|
| 89 | + | ||
| 90 | + | <script> |
|
| 91 | + | async function searchBooks() { |
|
| 92 | + | const q = document.getElementById('book-query').value.trim(); |
|
| 93 | + | if (!q) return; |
|
| 94 | + | const btn = document.getElementById('search-btn'); |
|
| 95 | + | const status = document.getElementById('search-status'); |
|
| 96 | + | const results = document.getElementById('search-results'); |
|
| 97 | + | btn.disabled = true; |
|
| 98 | + | btn.textContent = 'Searching...'; |
|
| 99 | + | status.style.display = 'none'; |
|
| 100 | + | results.innerHTML = ''; |
|
| 101 | + | try { |
|
| 102 | + | const resp = await fetch('/admin/search?q=' + encodeURIComponent(q)); |
|
| 103 | + | const data = await resp.json(); |
|
| 104 | + | if (!resp.ok) { |
|
| 105 | + | status.textContent = data.error || 'Search failed'; |
|
| 106 | + | status.className = 'search-status error'; |
|
| 107 | + | status.style.display = 'block'; |
|
| 108 | + | return; |
|
| 109 | + | } |
|
| 110 | + | if (!data.length) { |
|
| 111 | + | status.textContent = 'No results'; |
|
| 112 | + | status.className = 'search-status'; |
|
| 113 | + | status.style.display = 'block'; |
|
| 114 | + | return; |
|
| 115 | + | } |
|
| 116 | + | data.forEach(function(hit) { |
|
| 117 | + | results.appendChild(renderHit(hit)); |
|
| 118 | + | }); |
|
| 119 | + | } catch (e) { |
|
| 120 | + | status.textContent = 'Request failed'; |
|
| 121 | + | status.className = 'search-status error'; |
|
| 122 | + | status.style.display = 'block'; |
|
| 123 | + | } finally { |
|
| 124 | + | btn.disabled = false; |
|
| 125 | + | btn.textContent = 'Search'; |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | function renderHit(hit) { |
|
| 130 | + | const card = document.createElement('form'); |
|
| 131 | + | card.method = 'POST'; |
|
| 132 | + | card.action = '/admin/add'; |
|
| 133 | + | card.className = 'book-card hit'; |
|
| 134 | + | ||
| 135 | + | const cover = document.createElement('div'); |
|
| 136 | + | if (hit.cover_url) { |
|
| 137 | + | const img = document.createElement('img'); |
|
| 138 | + | img.src = hit.cover_url; |
|
| 139 | + | img.className = 'book-cover'; |
|
| 140 | + | img.loading = 'lazy'; |
|
| 141 | + | card.appendChild(img); |
|
| 142 | + | } else { |
|
| 143 | + | cover.className = 'book-cover placeholder'; |
|
| 144 | + | card.appendChild(cover); |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | const info = document.createElement('div'); |
|
| 148 | + | info.className = 'book-info'; |
|
| 149 | + | info.innerHTML = |
|
| 150 | + | '<h3 class="book-title"></h3>' + |
|
| 151 | + | '<p class="book-authors"></p>' + |
|
| 152 | + | (hit.isbn ? '<p class="book-meta">ISBN: ' + escapeHtml(hit.isbn) + '</p>' : ''); |
|
| 153 | + | info.querySelector('.book-title').textContent = hit.title; |
|
| 154 | + | info.querySelector('.book-authors').textContent = hit.authors; |
|
| 155 | + | ||
| 156 | + | const hidden = function(name, value) { |
|
| 157 | + | const el = document.createElement('input'); |
|
| 158 | + | el.type = 'hidden'; |
|
| 159 | + | el.name = name; |
|
| 160 | + | el.value = value || ''; |
|
| 161 | + | return el; |
|
| 162 | + | }; |
|
| 163 | + | info.appendChild(hidden('google_id', hit.google_id)); |
|
| 164 | + | info.appendChild(hidden('title', hit.title)); |
|
| 165 | + | info.appendChild(hidden('authors', hit.authors)); |
|
| 166 | + | info.appendChild(hidden('isbn', hit.isbn)); |
|
| 167 | + | info.appendChild(hidden('cover_url', hit.cover_url)); |
|
| 168 | + | ||
| 169 | + | const select = document.createElement('select'); |
|
| 170 | + | select.name = 'status'; |
|
| 171 | + | ['want', 'reading', 'read'].forEach(function(s) { |
|
| 172 | + | const o = document.createElement('option'); |
|
| 173 | + | o.value = s; |
|
| 174 | + | o.textContent = s === 'want' ? 'Want to Read' : s.charAt(0).toUpperCase() + s.slice(1); |
|
| 175 | + | select.appendChild(o); |
|
| 176 | + | }); |
|
| 177 | + | info.appendChild(select); |
|
| 178 | + | ||
| 179 | + | const btn = document.createElement('button'); |
|
| 180 | + | btn.type = 'submit'; |
|
| 181 | + | btn.textContent = 'Add'; |
|
| 182 | + | info.appendChild(btn); |
|
| 183 | + | ||
| 184 | + | card.appendChild(info); |
|
| 185 | + | return card; |
|
| 186 | + | } |
|
| 187 | + | ||
| 188 | + | function escapeHtml(s) { |
|
| 189 | + | return String(s || '').replace(/[&<>"']/g, function(c) { |
|
| 190 | + | return ({'&':'&','<':'<','>':'>','"':'"',"'":"'"})[c]; |
|
| 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; |
|
| 244 | + | } |
|
| 245 | + | </script> |
|
| 246 | + | </body> |
|
| 247 | + | </html> |
| 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 | {{ tab_label }}</title> |
|
| 14 | + | <meta name="description" content="Personal book tracker" /> |
|
| 15 | + | ||
| 16 | + | <meta property="og:url" content="{{ base_url }}" /> |
|
| 17 | + | <meta property="og:type" content="website" /> |
|
| 18 | + | <meta property="og:title" content="Library" /> |
|
| 19 | + | <meta property="og:description" content="Personal book tracker" /> |
|
| 20 | + | <meta property="og:image" content="{{ base_url }}/static/og.png" /> |
|
| 21 | + | ||
| 22 | + | <meta name="twitter:card" content="summary_large_image" /> |
|
| 23 | + | <meta property="twitter:url" content="{{ base_url }}" /> |
|
| 24 | + | <meta name="twitter:title" content="Library" /> |
|
| 25 | + | <meta name="twitter:description" content="Personal book tracker" /> |
|
| 26 | + | <meta name="twitter:image" content="{{ base_url }}/static/og.png" /> |
|
| 27 | + | </head> |
|
| 28 | + | <body> |
|
| 29 | + | <div class="header"> |
|
| 30 | + | <a href="/" class="logo"><h1>LIBRARY</h1></a> |
|
| 31 | + | <nav class="links"> |
|
| 32 | + | <a href="/read"{% if tab == "read" %} class="active"{% endif %}>read</a> |
|
| 33 | + | <a href="/reading"{% if tab == "reading" %} class="active"{% endif %}>reading</a> |
|
| 34 | + | <a href="/want"{% if tab == "want" %} class="active"{% endif %}>want to read</a> |
|
| 35 | + | <a href="/admin">add</a> |
|
| 36 | + | </nav> |
|
| 37 | + | </div> |
|
| 38 | + | ||
| 39 | + | {% if books.is_empty() %} |
|
| 40 | + | <p class="no-books">No books in {{ tab_label }}.</p> |
|
| 41 | + | {% else %} |
|
| 42 | + | <div class="books-list"> |
|
| 43 | + | {% for book in books %} |
|
| 44 | + | <article class="book-card"> |
|
| 45 | + | {% if let Some(url) = book.cover_url %} |
|
| 46 | + | <img class="book-cover" src="{{ url }}" alt="" loading="lazy" /> |
|
| 47 | + | {% else %} |
|
| 48 | + | <div class="book-cover placeholder"></div> |
|
| 49 | + | {% endif %} |
|
| 50 | + | <div class="book-info"> |
|
| 51 | + | <h3 class="book-title">{{ book.title }}</h3> |
|
| 52 | + | <p class="book-authors">{{ book.authors }}</p> |
|
| 53 | + | {% if let Some(n) = book.notes %} |
|
| 54 | + | <p class="book-notes">{{ n }}</p> |
|
| 55 | + | {% endif %} |
|
| 56 | + | </div> |
|
| 57 | + | </article> |
|
| 58 | + | {% endfor %} |
|
| 59 | + | </div> |
|
| 60 | + | {% endif %} |
|
| 61 | + | </body> |
|
| 62 | + | </html> |
| 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 | Login</title> |
|
| 14 | + | </head> |
|
| 15 | + | <body> |
|
| 16 | + | <a href="/" class="header"> |
|
| 17 | + | <h1>LIBRARY</h1> |
|
| 18 | + | </a> |
|
| 19 | + | {% if let Some(err) = error %} |
|
| 20 | + | <p class="error">{{ err }}</p> |
|
| 21 | + | {% endif %} |
|
| 22 | + | ||
| 23 | + | <form class="admin-form" method="POST" action="/admin/login"> |
|
| 24 | + | <label for="password">Password</label> |
|
| 25 | + | <input type="password" id="password" name="password" required autofocus /> |
|
| 26 | + | <button type="submit">Login</button> |
|
| 27 | + | </form> |
|
| 28 | + | </body> |
|
| 29 | + | </html> |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 1 | + | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} |
| 1 | + | /* library — app-specific styles. |
|
| 2 | + | * Shared reset / tokens / components come from /assets/darkmatter.css. |
|
| 3 | + | */ |
|
| 4 | + | ||
| 5 | + | .logo h1 { |
|
| 6 | + | font-size: 28px; |
|
| 7 | + | font-weight: 700; |
|
| 8 | + | text-transform: uppercase; |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | /* Active nav link (current tab) */ |
|
| 12 | + | ||
| 13 | + | .links a.active { |
|
| 14 | + | opacity: 1; |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | /* Books list */ |
|
| 18 | + | ||
| 19 | + | .books-list { |
|
| 20 | + | width: 100%; |
|
| 21 | + | display: flex; |
|
| 22 | + | flex-direction: column; |
|
| 23 | + | gap: 1.25rem; |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | .book-card { |
|
| 27 | + | display: flex; |
|
| 28 | + | gap: 1rem; |
|
| 29 | + | padding: 1rem 0; |
|
| 30 | + | border-bottom: 1px solid #333; |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | .book-card:last-child { |
|
| 34 | + | border-bottom: none; |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | .book-cover { |
|
| 38 | + | width: 72px; |
|
| 39 | + | height: 108px; |
|
| 40 | + | object-fit: cover; |
|
| 41 | + | background: #1e1c1f; |
|
| 42 | + | flex-shrink: 0; |
|
| 43 | + | display: block; |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | .book-cover.placeholder { |
|
| 47 | + | background: #1e1c1f; |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | .book-info { |
|
| 51 | + | display: flex; |
|
| 52 | + | flex-direction: column; |
|
| 53 | + | gap: 0.4rem; |
|
| 54 | + | flex: 1; |
|
| 55 | + | min-width: 0; |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | .book-title { |
|
| 59 | + | font-size: 16px; |
|
| 60 | + | font-weight: 400; |
|
| 61 | + | line-height: 1.4; |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | .book-authors { |
|
| 65 | + | font-size: 14px; |
|
| 66 | + | opacity: 0.7; |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | .book-meta { |
|
| 70 | + | font-size: 12px; |
|
| 71 | + | opacity: 0.5; |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | .book-notes { |
|
| 75 | + | font-size: 13px; |
|
| 76 | + | opacity: 0.7; |
|
| 77 | + | line-height: 1.5; |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | .no-books { |
|
| 81 | + | text-align: center; |
|
| 82 | + | opacity: 0.5; |
|
| 83 | + | padding: 2rem; |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | /* Admin */ |
|
| 87 | + | ||
| 88 | + | .admin-form { |
|
| 89 | + | display: flex; |
|
| 90 | + | flex-direction: column; |
|
| 91 | + | gap: 0.75rem; |
|
| 92 | + | width: 100%; |
|
| 93 | + | margin-bottom: 1.5rem; |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | .admin-form h3 { |
|
| 97 | + | font-size: 14px; |
|
| 98 | + | font-weight: 400; |
|
| 99 | + | opacity: 0.5; |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | .hint { |
|
| 103 | + | font-size: 12px; |
|
| 104 | + | opacity: 0.5; |
|
| 105 | + | line-height: 1.4; |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | .search-row { |
|
| 109 | + | display: flex; |
|
| 110 | + | gap: 0.5rem; |
|
| 111 | + | width: 100%; |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | .search-row input { |
|
| 115 | + | flex: 1; |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | .search-status { |
|
| 119 | + | font-size: 12px; |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | .search-results { |
|
| 123 | + | display: flex; |
|
| 124 | + | flex-direction: column; |
|
| 125 | + | gap: 0.5rem; |
|
| 126 | + | width: 100%; |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | .book-card.hit, |
|
| 130 | + | .book-card.admin { |
|
| 131 | + | border: 1px solid #333; |
|
| 132 | + | padding: 0.75rem; |
|
| 133 | + | border-bottom: 1px solid #333; |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | .book-card.admin .inline { |
|
| 137 | + | display: flex; |
|
| 138 | + | gap: 0.5rem; |
|
| 139 | + | align-items: center; |
|
| 140 | + | margin-top: 0.4rem; |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | .book-card.admin .notes-form { |
|
| 144 | + | flex-direction: column; |
|
| 145 | + | align-items: stretch; |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | .book-card.admin textarea { |
|
| 149 | + | width: 100%; |
|
| 150 | + | min-height: 1.6rem; |
|
| 151 | + | font-family: inherit; |
|
| 152 | + | font-size: 13px; |
|
| 153 | + | line-height: 1.4; |
|
| 154 | + | background: #121113; |
|
| 155 | + | color: #ffffff; |
|
| 156 | + | border: 1px solid #333; |
|
| 157 | + | padding: 0.3rem 0.4rem; |
|
| 158 | + | resize: vertical; |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | .admin-subs { |
|
| 162 | + | width: 100%; |
|
| 163 | + | display: flex; |
|
| 164 | + | flex-direction: column; |
|
| 165 | + | gap: 0.75rem; |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | .admin-subs h3 { |
|
| 169 | + | font-size: 14px; |
|
| 170 | + | opacity: 0.5; |
|
| 171 | + | font-weight: 400; |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | button.danger, |
|
| 175 | + | .btn.danger { |
|
| 176 | + | opacity: 0.5; |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | button.danger:hover, |
|
| 180 | + | .btn.danger:hover { |
|
| 181 | + | opacity: 0.3; |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | @media (max-width: 480px) { |
|
| 185 | + | .book-card { |
|
| 186 | + | gap: 0.75rem; |
|
| 187 | + | } |
|
| 188 | + | .book-cover { |
|
| 189 | + | width: 56px; |
|
| 190 | + | height: 84px; |
|
| 191 | + | } |
|
| 192 | + | .book-title { |
|
| 193 | + | font-size: 14px; |
|
| 194 | + | } |
|
| 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 | + | } |
| 68 | 68 | - posts_data:/data |
|
| 69 | 69 | env_file: apps/posts/.env |
|
| 70 | 70 | ||
| 71 | + | library: |
|
| 72 | + | image: ghcr.io/stevedylandev/andromeda/library:latest |
|
| 73 | + | restart: unless-stopped |
|
| 74 | + | ports: |
|
| 75 | + | - "4646:3000" |
|
| 76 | + | volumes: |
|
| 77 | + | - library_data:/data |
|
| 78 | + | env_file: apps/library/.env |
|
| 79 | + | ||
| 71 | 80 | backup: |
|
| 72 | 81 | image: ghcr.io/stevedylandev/andromeda/backup:latest |
|
| 73 | 82 | volumes: |
|
| 74 | 83 | - jotts_data:/data/jotts:ro |
|
| 75 | 84 | - sipp_data:/data/sipp:ro |
|
| 76 | 85 | - cellar_data:/data/cellar:ro |
|
| 86 | + | - library_data:/data/library:ro |
|
| 77 | 87 | env_file: apps/backup/.env |
|
| 78 | 88 | restart: unless-stopped |
|
| 79 | 89 | ||
| 96 | 106 | posts_data: |
|
| 97 | 107 | external: true |
|
| 98 | 108 | name: posts_posts-data |
|
| 109 | + | library_data: |
|
| 110 | + | external: true |
|
| 111 | + | name: library_library-data |
|
| 1 | + | # Library |
|
| 2 | + | ||
| 3 | + | A simple, self-hosted book tracker built with Rust. |
|
| 4 | + | ||
| 5 | + | - Single binary with embedded assets |
|
| 6 | + | - Password authentication with session cookies |
|
| 7 | + | - Track books across Read, Reading, and Want to Read |
|
| 8 | + | - Google Books search to add titles with cover art and ISBN |
|
| 9 | + | - Per-book notes |
|
| 10 | + | - JSON API for listing and fetching books |
|
| 11 | + | - Dark themed UI with Commit Mono font |
|
| 12 | + | - SQLite for persistent storage |
|
| 13 | + | ||
| 14 | + | ## Configure |
|
| 15 | + | ||
| 16 | + | ### Environment Variables |
|
| 17 | + | ||
| 18 | + | | Variable | Description | Default | |
|
| 19 | + | |---|---|---| |
|
| 20 | + | | `ADMIN_PASSWORD` | Password for admin login | `changeme` | |
|
| 21 | + | | `LIBRARY_DB_PATH` | SQLite database file path | `library.sqlite` | |
|
| 22 | + | | `GOOGLE_BOOKS_API_KEY` | Google Books API key for search | | |
|
| 23 | + | | `BASE_URL` | Public base URL | `http://localhost:3000` | |
|
| 24 | + | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 25 | + | | `PORT` | Server port | `3000` | |
|
| 26 | + | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 27 | + | ||
| 28 | + | The `GOOGLE_BOOKS_API_KEY` is optional — searches work without it but are rate-limited. |
|
| 29 | + | ||
| 30 | + | ## Deploy |
|
| 31 | + | ||
| 32 | + | ### Docker |
|
| 33 | + | ||
| 34 | + | From the repo root: |
|
| 35 | + | ||
| 36 | + | ```bash |
|
| 37 | + | cp apps/library/.env.example apps/library/.env |
|
| 38 | + | # Edit .env with your admin password |
|
| 39 | + | docker compose up -d library |
|
| 40 | + | ``` |
|
| 41 | + | ||
| 42 | + | This will start Library on port `4646` with a persistent volume for the SQLite database. |
|
| 43 | + | ||
| 44 | + | ### Binary |
|
| 45 | + | ||
| 46 | + | Build from source: |
|
| 47 | + | ||
| 48 | + | ```bash |
|
| 49 | + | cargo build --release -p library |
|
| 50 | + | ``` |
|
| 51 | + | ||
| 52 | + | The resulting binary at `./target/release/library` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 53 | + | ||
| 54 | + | ## Use |
|
| 55 | + | ||
| 56 | + | Your library is publicly viewable at `/`, which redirects to `/read`. Three tabs split the collection: |
|
| 57 | + | ||
| 58 | + | | Page | Description | |
|
| 59 | + | |---|---| |
|
| 60 | + | | `/read` | Books you've finished | |
|
| 61 | + | | `/reading` | Books you're currently reading | |
|
| 62 | + | | `/want` | Books you want to read | |
|
| 63 | + | ||
| 64 | + | ### Admin |
|
| 65 | + | ||
| 66 | + | Log in at `/admin/login` with your configured password to access the admin pages. |
|
| 67 | + | ||
| 68 | + | | Page | Description | |
|
| 69 | + | |---|---| |
|
| 70 | + | | `/admin` | Admin dashboard listing every book in your library | |
|
| 71 | + | | `/admin/search?q=...` | Search Google Books to add a title | |
|
| 72 | + | ||
| 73 | + | From the admin dashboard you can change a book's status, save notes, or remove a book. |
|
| 74 | + | ||
| 75 | + | ### API |
|
| 76 | + | ||
| 77 | + | | Endpoint | Description | |
|
| 78 | + | |---|---| |
|
| 79 | + | | `GET /api/books` | List all books. Filter with `?status=read\|reading\|want` | |
|
| 80 | + | | `GET /api/books/{id}` | Fetch a single book by ID | |
| 39 | 39 | ||
| 40 | 40 | - [Posts](/apps/posts) - A minimal CMS blog with an admin interface. |
|
| 41 | 41 | - [Jotts](/apps/jotts) - A minimal self-hosted markdown notes app. |
|
| 42 | + | - [Library](/apps/library) - A minimal personal book tracker. |
|
| 42 | 43 | - [Cellar](/apps/cellar) - A minimal wine collection tracker. |
|
| 43 | 44 | - [Feeds](/apps/feeds) - A minimal RSS reader that sends you back to the author's site to read posts in their original context. |
|
| 44 | 45 | - [Shrink](/apps/shrink) - A simple self-hosted tool for compressing and resizing images. |
| 53 | 53 | link: '/apps/jotts', |
|
| 54 | 54 | }, |
|
| 55 | 55 | { |
|
| 56 | + | text: 'Library', |
|
| 57 | + | link: '/apps/library', |
|
| 58 | + | }, |
|
| 59 | + | { |
|
| 56 | 60 | text: 'OG', |
|
| 57 | 61 | link: '/apps/og', |
|
| 58 | 62 | }, |