Merge pull request #37 from stevedylandev/feat/add-library-app 55ace1a9
feat/add library app
Steve Simkins · 2026-04-25 13:21 30 file(s) · +1616 −2
Cargo.lock +25 −0
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"
Cargo.toml +1 −0
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",
apps/backup/backup.sh +2 −2
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}')
apps/backup/docker-compose.yml +4 −0
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}
apps/library/.env.example (added) +7 −0
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=
apps/library/Cargo.toml (added) +29 −0
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"
apps/library/Dockerfile (added) +22 −0
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"]
apps/library/README.md (added) +73 −0
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.
apps/library/askama.toml (added) +2 −0
1 +
[general]
2 +
dirs = ["src/templates"]
apps/library/src/auth.rs (added) +60 −0
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 +
apps/library/src/db.rs (added) +167 −0
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 +
apps/library/src/google_books.rs (added) +111 −0
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 +
}
apps/library/src/main.rs (added) +446 −0
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 +
apps/library/src/templates/admin.html (added) +247 −0
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 ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[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>
apps/library/src/templates/index.html (added) +62 −0
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>
apps/library/src/templates/login.html (added) +29 −0
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>
apps/library/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/library/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/library/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/library/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/library/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/library/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/library/static/icon.png (added) +0 −0

Binary file — no preview.

apps/library/static/og.png (added) +0 −0

Binary file — no preview.

apps/library/static/site.webmanifest (added) +1 −0
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"}
apps/library/static/styles.css (added) +230 −0
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 +
}
docker-compose.yml +13 −0
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
docs/docs/pages/apps/library.mdx (added) +80 −0
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 |
docs/docs/pages/deploy-railway.mdx +1 −0
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.
docs/vocs.config.ts +4 −0
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
        },