feat: init Library 457aee42
Steve · 2026-04-25 11:46 23 file(s) · +1564 −0
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/library/.env.example (added) +8 −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=/data/library.sqlite
7 +
API_KEY=
8 +
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) +23 −0
1 +
# Build from repo root: docker build -t library -f apps/library/Dockerfile .
2 +
FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef
3 +
WORKDIR /app
4 +
5 +
FROM chef AS planner
6 +
COPY . .
7 +
RUN cargo chef prepare --recipe-path recipe.json
8 +
9 +
FROM chef AS builder
10 +
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
11 +
COPY --from=planner /app/recipe.json recipe.json
12 +
RUN cargo chef cook --release --recipe-path recipe.json -p library
13 +
COPY . .
14 +
RUN cargo build --release -p library
15 +
16 +
FROM debian:bookworm-slim
17 +
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
18 +
COPY --from=builder /app/target/release/library /usr/local/bin/library
19 +
WORKDIR /data
20 +
EXPOSE 3000
21 +
ENV HOST=0.0.0.0
22 +
ENV PORT=3000
23 +
CMD ["library"]
apps/library/askama.toml (added) +2 −0
1 +
[general]
2 +
dirs = ["src/templates"]
apps/library/src/auth.rs (added) +97 −0
1 +
use axum::{
2 +
    extract::{FromRef, FromRequestParts},
3 +
    http::{request::Parts, StatusCode},
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_api_key, 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 +
61 +
pub struct ApiAuth;
62 +
63 +
impl<S> FromRequestParts<S> for ApiAuth
64 +
where
65 +
    S: Send + Sync,
66 +
    Arc<AppState>: FromRef<S>,
67 +
{
68 +
    type Rejection = Response;
69 +
70 +
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
71 +
        let state = Arc::<AppState>::from_ref(state);
72 +
73 +
        if let Some(expected_key) = state.api_key.as_deref() {
74 +
            if let Some(header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
75 +
                if let Ok(s) = header.to_str() {
76 +
                    if let Some(token) = s.strip_prefix("Bearer ").or_else(|| s.strip_prefix("bearer ")) {
77 +
                        if verify_api_key(token.trim(), expected_key) {
78 +
                            return Ok(ApiAuth);
79 +
                        }
80 +
                    }
81 +
                }
82 +
            }
83 +
        }
84 +
85 +
        if let Some(token) = extract_session_cookie(&parts.headers) {
86 +
            if is_valid_session(&state.db, &token) {
87 +
                return Ok(ApiAuth);
88 +
            }
89 +
        }
90 +
91 +
        Err((
92 +
            StatusCode::UNAUTHORIZED,
93 +
            axum::Json(serde_json::json!({ "error": "unauthorized" })),
94 +
        )
95 +
            .into_response())
96 +
    }
97 +
}
apps/library/src/db.rs (added) +194 −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 +
168 +
#[derive(Debug, Default, Clone, Copy, Serialize)]
169 +
pub struct StatusCounts {
170 +
    pub read: i64,
171 +
    pub reading: i64,
172 +
    pub want: i64,
173 +
}
174 +
175 +
pub fn count_by_status(db: &Db) -> Result<StatusCounts, DbError> {
176 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
177 +
    let mut stmt = conn.prepare("SELECT status, COUNT(*) FROM books GROUP BY status")?;
178 +
    let mut counts = StatusCounts::default();
179 +
    let rows = stmt.query_map([], |row| {
180 +
        let s: String = row.get(0)?;
181 +
        let n: i64 = row.get(1)?;
182 +
        Ok((s, n))
183 +
    })?;
184 +
    for r in rows {
185 +
        let (s, n) = r?;
186 +
        match s.as_str() {
187 +
            "read" => counts.read = n,
188 +
            "reading" => counts.reading = n,
189 +
            "want" => counts.want = n,
190 +
            _ => {}
191 +
        }
192 +
    }
193 +
    Ok(counts)
194 +
}
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) +571 −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, StatusCounts};
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 api_key: Option<String>,
33 +
    pub google_books_api_key: Option<String>,
34 +
    pub cookie_secure: bool,
35 +
    pub base_url: String,
36 +
}
37 +
38 +
// ── Templates ────────────────────────────────────────────────────────────
39 +
40 +
struct BookView {
41 +
    title: String,
42 +
    authors: String,
43 +
    cover_url: Option<String>,
44 +
    notes: Option<String>,
45 +
}
46 +
47 +
#[derive(Template)]
48 +
#[template(path = "index.html")]
49 +
struct IndexTemplate {
50 +
    base_url: String,
51 +
    tab: &'static str,
52 +
    tab_label: &'static str,
53 +
    books: Vec<BookView>,
54 +
    counts: StatusCounts,
55 +
}
56 +
57 +
#[derive(Template)]
58 +
#[template(path = "login.html")]
59 +
struct LoginTemplate {
60 +
    error: Option<String>,
61 +
}
62 +
63 +
struct AdminBookRow {
64 +
    id: i64,
65 +
    title: String,
66 +
    authors: String,
67 +
    isbn: Option<String>,
68 +
    cover_url: Option<String>,
69 +
    notes: Option<String>,
70 +
    status: String,
71 +
}
72 +
73 +
#[derive(Template)]
74 +
#[template(path = "admin.html")]
75 +
struct AdminTemplate {
76 +
    success: Option<String>,
77 +
    error: Option<String>,
78 +
    books: Vec<AdminBookRow>,
79 +
    api_key_configured: bool,
80 +
    google_key_configured: bool,
81 +
}
82 +
83 +
fn render_index(
84 +
    state: &AppState,
85 +
    status: BookStatus,
86 +
    tab: &'static str,
87 +
    label: &'static str,
88 +
) -> Response {
89 +
    let books = db::list_books(&state.db, Some(status))
90 +
        .unwrap_or_default()
91 +
        .into_iter()
92 +
        .map(|b: Book| BookView {
93 +
            title: b.title,
94 +
            authors: b.authors,
95 +
            cover_url: b.cover_url,
96 +
            notes: b.notes,
97 +
        })
98 +
        .collect();
99 +
    let counts = db::count_by_status(&state.db).unwrap_or_default();
100 +
    Html(
101 +
        IndexTemplate {
102 +
            base_url: state.base_url.clone(),
103 +
            tab,
104 +
            tab_label: label,
105 +
            books,
106 +
            counts,
107 +
        }
108 +
        .render()
109 +
        .unwrap(),
110 +
    )
111 +
    .into_response()
112 +
}
113 +
114 +
async fn root_redirect() -> Response {
115 +
    Redirect::to("/read").into_response()
116 +
}
117 +
118 +
async fn read_handler(State(state): State<Arc<AppState>>) -> Response {
119 +
    render_index(&state, BookStatus::Read, "read", "Read")
120 +
}
121 +
async fn reading_handler(State(state): State<Arc<AppState>>) -> Response {
122 +
    render_index(&state, BookStatus::Reading, "reading", "Reading")
123 +
}
124 +
async fn want_handler(State(state): State<Arc<AppState>>) -> Response {
125 +
    render_index(&state, BookStatus::Want, "want", "Want to Read")
126 +
}
127 +
128 +
async fn static_handler(Path(path): Path<String>) -> Response {
129 +
    match Static::get(&path) {
130 +
        Some(file) => {
131 +
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
132 +
            ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response()
133 +
        }
134 +
        None => StatusCode::NOT_FOUND.into_response(),
135 +
    }
136 +
}
137 +
138 +
// ── Admin ────────────────────────────────────────────────────────────────
139 +
140 +
#[derive(Deserialize, Default)]
141 +
struct FlashQuery {
142 +
    error: Option<String>,
143 +
    success: Option<String>,
144 +
}
145 +
146 +
#[derive(Deserialize)]
147 +
struct LoginForm {
148 +
    password: String,
149 +
}
150 +
151 +
async fn login_get_handler(Query(q): Query<FlashQuery>) -> Response {
152 +
    Html(LoginTemplate { error: q.error }.render().unwrap()).into_response()
153 +
}
154 +
155 +
async fn login_post_handler(
156 +
    State(state): State<Arc<AppState>>,
157 +
    Form(form): Form<LoginForm>,
158 +
) -> Response {
159 +
    let admin_password = match &state.admin_password {
160 +
        Some(p) => p,
161 +
        None => {
162 +
            return Redirect::to("/admin/login?error=No+admin+password+configured").into_response();
163 +
        }
164 +
    };
165 +
    if !auth::verify_password(&form.password, admin_password) {
166 +
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
167 +
    }
168 +
169 +
    let token = auth::generate_session_token();
170 +
    if let Err(e) = auth::create_session(&state.db, &token) {
171 +
        tracing::error!("failed to create session: {e}");
172 +
        return Redirect::to("/admin/login?error=Session+error").into_response();
173 +
    }
174 +
    let _ = prune_expired_sessions(&state.db);
175 +
176 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
177 +
    let mut resp = Redirect::to("/admin").into_response();
178 +
    resp.headers_mut()
179 +
        .insert(header::SET_COOKIE, cookie.parse().unwrap());
180 +
    resp
181 +
}
182 +
183 +
async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
184 +
    if let Some(token) = auth::extract_session_cookie(&headers) {
185 +
        auth::delete_session(&state.db, &token);
186 +
    }
187 +
    let mut resp = Redirect::to("/admin/login").into_response();
188 +
    resp.headers_mut().insert(
189 +
        header::SET_COOKIE,
190 +
        auth::clear_session_cookie().parse().unwrap(),
191 +
    );
192 +
    resp
193 +
}
194 +
195 +
async fn admin_handler(
196 +
    _session: auth::AuthSession,
197 +
    State(state): State<Arc<AppState>>,
198 +
    Query(q): Query<FlashQuery>,
199 +
) -> Response {
200 +
    let books = db::list_books(&state.db, None)
201 +
        .unwrap_or_default()
202 +
        .into_iter()
203 +
        .map(|b| AdminBookRow {
204 +
            id: b.id,
205 +
            title: b.title,
206 +
            authors: b.authors,
207 +
            isbn: b.isbn,
208 +
            cover_url: b.cover_url,
209 +
            notes: b.notes,
210 +
            status: b.status,
211 +
        })
212 +
        .collect();
213 +
214 +
    Html(
215 +
        AdminTemplate {
216 +
            success: q.success,
217 +
            error: q.error,
218 +
            books,
219 +
            api_key_configured: state.api_key.is_some(),
220 +
            google_key_configured: state.google_books_api_key.is_some(),
221 +
        }
222 +
        .render()
223 +
        .unwrap(),
224 +
    )
225 +
    .into_response()
226 +
}
227 +
228 +
#[derive(Deserialize)]
229 +
struct SearchQuery {
230 +
    q: String,
231 +
}
232 +
233 +
async fn admin_search_handler(
234 +
    _session: auth::AuthSession,
235 +
    State(state): State<Arc<AppState>>,
236 +
    Query(q): Query<SearchQuery>,
237 +
) -> Response {
238 +
    match google_books::search(&q.q, state.google_books_api_key.as_deref()).await {
239 +
        Ok(hits) => Json(hits).into_response(),
240 +
        Err(e) => {
241 +
            tracing::warn!("google books search failed: {e}");
242 +
            (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": e })))
243 +
                .into_response()
244 +
        }
245 +
    }
246 +
}
247 +
248 +
#[derive(Deserialize)]
249 +
struct AddBookForm {
250 +
    google_id: Option<String>,
251 +
    title: String,
252 +
    authors: String,
253 +
    isbn: Option<String>,
254 +
    cover_url: Option<String>,
255 +
    status: String,
256 +
}
257 +
258 +
async fn admin_add_book(
259 +
    _session: auth::AuthSession,
260 +
    State(state): State<Arc<AppState>>,
261 +
    Form(form): Form<AddBookForm>,
262 +
) -> Response {
263 +
    let Some(status) = BookStatus::parse(&form.status) else {
264 +
        return Redirect::to("/admin?error=Invalid+status").into_response();
265 +
    };
266 +
    let new_book = NewBook {
267 +
        google_id: form.google_id.filter(|s| !s.is_empty()),
268 +
        title: form.title,
269 +
        authors: form.authors,
270 +
        isbn: form.isbn.filter(|s| !s.is_empty()),
271 +
        cover_url: form.cover_url.filter(|s| !s.is_empty()),
272 +
        notes: None,
273 +
        status,
274 +
    };
275 +
    match db::insert_book(&state.db, &new_book) {
276 +
        Ok(_) => Redirect::to("/admin?success=Book+added").into_response(),
277 +
        Err(e) => {
278 +
            tracing::error!("insert book: {e}");
279 +
            Redirect::to("/admin?error=Failed+to+add+book").into_response()
280 +
        }
281 +
    }
282 +
}
283 +
284 +
#[derive(Deserialize)]
285 +
struct UpdateStatusForm {
286 +
    status: String,
287 +
}
288 +
289 +
async fn admin_update_status(
290 +
    _session: auth::AuthSession,
291 +
    State(state): State<Arc<AppState>>,
292 +
    Path(id): Path<i64>,
293 +
    Form(form): Form<UpdateStatusForm>,
294 +
) -> Response {
295 +
    let Some(status) = BookStatus::parse(&form.status) else {
296 +
        return Redirect::to("/admin?error=Invalid+status").into_response();
297 +
    };
298 +
    let _ = db::update_book_status(&state.db, id, status);
299 +
    Redirect::to("/admin?success=Status+updated").into_response()
300 +
}
301 +
302 +
#[derive(Deserialize)]
303 +
struct UpdateNotesForm {
304 +
    notes: String,
305 +
}
306 +
307 +
async fn admin_update_notes(
308 +
    _session: auth::AuthSession,
309 +
    State(state): State<Arc<AppState>>,
310 +
    Path(id): Path<i64>,
311 +
    Form(form): Form<UpdateNotesForm>,
312 +
) -> Response {
313 +
    let trimmed = form.notes.trim();
314 +
    let notes = if trimmed.is_empty() { None } else { Some(trimmed) };
315 +
    let _ = db::update_book_notes(&state.db, id, notes);
316 +
    Redirect::to("/admin?success=Notes+saved").into_response()
317 +
}
318 +
319 +
async fn admin_delete_book(
320 +
    _session: auth::AuthSession,
321 +
    State(state): State<Arc<AppState>>,
322 +
    Path(id): Path<i64>,
323 +
) -> Response {
324 +
    let _ = db::delete_book(&state.db, id);
325 +
    Redirect::to("/admin?success=Book+removed").into_response()
326 +
}
327 +
328 +
// ── JSON API ─────────────────────────────────────────────────────────────
329 +
330 +
#[derive(Deserialize)]
331 +
struct ListBooksQuery {
332 +
    status: Option<String>,
333 +
}
334 +
335 +
async fn api_list_books(
336 +
    State(state): State<Arc<AppState>>,
337 +
    Query(q): Query<ListBooksQuery>,
338 +
) -> Response {
339 +
    let status = match q.status.as_deref() {
340 +
        None | Some("") | Some("all") => None,
341 +
        Some(s) => match BookStatus::parse(s) {
342 +
            Some(st) => Some(st),
343 +
            None => {
344 +
                return (
345 +
                    StatusCode::BAD_REQUEST,
346 +
                    Json(serde_json::json!({ "error": "invalid status" })),
347 +
                )
348 +
                    .into_response();
349 +
            }
350 +
        },
351 +
    };
352 +
    match db::list_books(&state.db, status) {
353 +
        Ok(books) => Json(books).into_response(),
354 +
        Err(e) => {
355 +
            tracing::error!("list books: {e}");
356 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
357 +
        }
358 +
    }
359 +
}
360 +
361 +
async fn api_get_book(
362 +
    State(state): State<Arc<AppState>>,
363 +
    Path(id): Path<i64>,
364 +
) -> Response {
365 +
    match db::get_book(&state.db, id) {
366 +
        Ok(Some(book)) => Json(book).into_response(),
367 +
        Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" })))
368 +
            .into_response(),
369 +
        Err(e) => {
370 +
            tracing::error!("get book: {e}");
371 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
372 +
        }
373 +
    }
374 +
}
375 +
376 +
#[derive(Deserialize)]
377 +
struct CreateBookBody {
378 +
    google_id: Option<String>,
379 +
    title: String,
380 +
    authors: String,
381 +
    isbn: Option<String>,
382 +
    cover_url: Option<String>,
383 +
    notes: Option<String>,
384 +
    status: String,
385 +
}
386 +
387 +
async fn api_create_book(
388 +
    _auth: auth::ApiAuth,
389 +
    State(state): State<Arc<AppState>>,
390 +
    Json(body): Json<CreateBookBody>,
391 +
) -> Response {
392 +
    let Some(status) = BookStatus::parse(&body.status) else {
393 +
        return (
394 +
            StatusCode::BAD_REQUEST,
395 +
            Json(serde_json::json!({ "error": "invalid status" })),
396 +
        )
397 +
            .into_response();
398 +
    };
399 +
    let new_book = NewBook {
400 +
        google_id: body.google_id,
401 +
        title: body.title,
402 +
        authors: body.authors,
403 +
        isbn: body.isbn,
404 +
        cover_url: body.cover_url,
405 +
        notes: body.notes,
406 +
        status,
407 +
    };
408 +
    match db::insert_book(&state.db, &new_book) {
409 +
        Ok(id) => match db::get_book(&state.db, id) {
410 +
            Ok(Some(book)) => (StatusCode::CREATED, Json(book)).into_response(),
411 +
            _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
412 +
        },
413 +
        Err(e) => {
414 +
            tracing::error!("create book: {e}");
415 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
416 +
        }
417 +
    }
418 +
}
419 +
420 +
#[derive(Deserialize)]
421 +
struct PatchBookBody {
422 +
    status: Option<String>,
423 +
    notes: Option<String>,
424 +
}
425 +
426 +
async fn api_patch_book(
427 +
    _auth: auth::ApiAuth,
428 +
    State(state): State<Arc<AppState>>,
429 +
    Path(id): Path<i64>,
430 +
    Json(body): Json<PatchBookBody>,
431 +
) -> Response {
432 +
    if let Some(s) = body.status.as_deref() {
433 +
        let Some(status) = BookStatus::parse(s) else {
434 +
            return (
435 +
                StatusCode::BAD_REQUEST,
436 +
                Json(serde_json::json!({ "error": "invalid status" })),
437 +
            )
438 +
                .into_response();
439 +
        };
440 +
        if let Err(e) = db::update_book_status(&state.db, id, status) {
441 +
            tracing::error!("update status: {e}");
442 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
443 +
        }
444 +
    }
445 +
    if let Some(notes) = body.notes.as_deref() {
446 +
        let trimmed = notes.trim();
447 +
        let n = if trimmed.is_empty() { None } else { Some(trimmed) };
448 +
        if let Err(e) = db::update_book_notes(&state.db, id, n) {
449 +
            tracing::error!("update notes: {e}");
450 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
451 +
        }
452 +
    }
453 +
    match db::get_book(&state.db, id) {
454 +
        Ok(Some(book)) => Json(book).into_response(),
455 +
        Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" })))
456 +
            .into_response(),
457 +
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
458 +
    }
459 +
}
460 +
461 +
async fn api_delete_book(
462 +
    _auth: auth::ApiAuth,
463 +
    State(state): State<Arc<AppState>>,
464 +
    Path(id): Path<i64>,
465 +
) -> Response {
466 +
    match db::delete_book(&state.db, id) {
467 +
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
468 +
        Ok(false) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" })))
469 +
            .into_response(),
470 +
        Err(e) => {
471 +
            tracing::error!("delete book: {e}");
472 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
473 +
        }
474 +
    }
475 +
}
476 +
477 +
async fn api_counts(State(state): State<Arc<AppState>>) -> Response {
478 +
    match db::count_by_status(&state.db) {
479 +
        Ok(counts) => Json(counts).into_response(),
480 +
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
481 +
    }
482 +
}
483 +
484 +
// ── main ─────────────────────────────────────────────────────────────────
485 +
486 +
#[tokio::main]
487 +
async fn main() {
488 +
    dotenvy::dotenv().ok();
489 +
    tracing_subscriber::fmt()
490 +
        .with_env_filter(
491 +
            tracing_subscriber::EnvFilter::try_from_default_env()
492 +
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,library=info")),
493 +
        )
494 +
        .init();
495 +
496 +
    let db_path =
497 +
        std::env::var("LIBRARY_DB_PATH").unwrap_or_else(|_| "library.sqlite".to_string());
498 +
    let conn = Connection::open(&db_path).expect("open sqlite");
499 +
    conn.execute_batch(SESSION_SCHEMA).expect("session schema");
500 +
    conn.execute_batch(db::BOOKS_SCHEMA).expect("books schema");
501 +
    let db: Db = Arc::new(Mutex::new(conn));
502 +
503 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
504 +
        .map(|v| v.eq_ignore_ascii_case("true"))
505 +
        .unwrap_or(false);
506 +
    let base_url =
507 +
        std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
508 +
509 +
    let api_key = std::env::var("API_KEY").ok().filter(|s| !s.is_empty());
510 +
    if api_key.is_none() {
511 +
        tracing::warn!("API_KEY not set; write API accessible via session cookie only");
512 +
    }
513 +
    let google_books_api_key = std::env::var("GOOGLE_BOOKS_API_KEY")
514 +
        .ok()
515 +
        .filter(|s| !s.is_empty());
516 +
517 +
    let state = Arc::new(AppState {
518 +
        db,
519 +
        admin_password: std::env::var("ADMIN_PASSWORD").ok(),
520 +
        api_key,
521 +
        google_books_api_key,
522 +
        cookie_secure,
523 +
        base_url,
524 +
    });
525 +
526 +
    let admin_router = Router::new()
527 +
        .route("/admin", get(admin_handler))
528 +
        .route(
529 +
            "/admin/login",
530 +
            get(login_get_handler).post(login_post_handler),
531 +
        )
532 +
        .route("/admin/logout", get(logout_handler))
533 +
        .route("/admin/search", get(admin_search_handler))
534 +
        .route("/admin/add", post(admin_add_book))
535 +
        .route("/admin/books/{id}/status", post(admin_update_status))
536 +
        .route("/admin/books/{id}/notes", post(admin_update_notes))
537 +
        .route("/admin/books/{id}/delete", post(admin_delete_book));
538 +
539 +
    let api_router = Router::new()
540 +
        .route("/api/books", get(api_list_books).post(api_create_book))
541 +
        .route(
542 +
            "/api/books/{id}",
543 +
            get(api_get_book).patch(api_patch_book).delete(api_delete_book),
544 +
        )
545 +
        .route("/api/counts", get(api_counts));
546 +
547 +
    let app = Router::new()
548 +
        .route("/", get(root_redirect))
549 +
        .route("/read", get(read_handler))
550 +
        .route("/reading", get(reading_handler))
551 +
        .route("/want", get(want_handler))
552 +
        .route("/static/{*path}", get(static_handler))
553 +
        .merge(admin_router)
554 +
        .merge(api_router)
555 +
        .merge(andromeda_darkmatter_css::router::<Arc<AppState>>())
556 +
        .with_state(state);
557 +
558 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
559 +
    let port: u16 = std::env::var("PORT")
560 +
        .ok()
561 +
        .and_then(|v| v.parse().ok())
562 +
        .unwrap_or(3000);
563 +
    let addr = format!("{host}:{port}");
564 +
    let listener = tokio::net::TcpListener::bind(&addr)
565 +
        .await
566 +
        .unwrap_or_else(|_| panic!("Failed to bind to {addr}"));
567 +
568 +
    tracing::info!("Library server running on http://{host}:{port}");
569 +
    axum::serve(listener, app).await.unwrap();
570 +
}
571 +
apps/library/src/templates/admin.html (added) +189 −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 +
      </div>
36 +
      <div id="search-status" class="search-status" style="display:none;"></div>
37 +
      <div id="search-results" class="search-results"></div>
38 +
      <p class="hint">Google Books API key: {% if google_key_configured %}configured{% else %}not set (default rate limits apply){% endif %}</p>
39 +
    </section>
40 +
41 +
    <section class="admin-subs">
42 +
      <h3>Library ({{ books.len() }})</h3>
43 +
      {% if books.is_empty() %}
44 +
      <p class="hint">No books yet. Search above to add one.</p>
45 +
      {% else %}
46 +
      <div class="books-list">
47 +
        {% for b in books %}
48 +
        <div class="book-card admin">
49 +
          {% if let Some(url) = b.cover_url %}
50 +
          <img class="book-cover" src="{{ url }}" alt="" loading="lazy" />
51 +
          {% else %}
52 +
          <div class="book-cover placeholder"></div>
53 +
          {% endif %}
54 +
          <div class="book-info">
55 +
            <h3 class="book-title">{{ b.title }}</h3>
56 +
            <p class="book-authors">{{ b.authors }}</p>
57 +
            {% if let Some(isbn) = b.isbn %}
58 +
            <p class="book-meta">ISBN: {{ isbn }}</p>
59 +
            {% endif %}
60 +
            <form method="POST" action="/admin/books/{{ b.id }}/status" class="inline">
61 +
              <select name="status" onchange="this.form.submit()">
62 +
                <option value="read"{% if b.status == "read" %} selected{% endif %}>Read</option>
63 +
                <option value="reading"{% if b.status == "reading" %} selected{% endif %}>Reading</option>
64 +
                <option value="want"{% if b.status == "want" %} selected{% endif %}>Want to Read</option>
65 +
              </select>
66 +
              <noscript><button type="submit">Save</button></noscript>
67 +
            </form>
68 +
            <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form">
69 +
              <textarea name="notes" rows="2" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea>
70 +
              <button type="submit">Save notes</button>
71 +
            </form>
72 +
            <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline">
73 +
              <button type="submit" class="danger">Delete</button>
74 +
            </form>
75 +
          </div>
76 +
        </div>
77 +
        {% endfor %}
78 +
      </div>
79 +
      {% endif %}
80 +
    </section>
81 +
82 +
    <p class="hint">API key: {% if api_key_configured %}configured{% else %}not set (write API requires session cookie){% endif %}</p>
83 +
84 +
    <script>
85 +
      async function searchBooks() {
86 +
        const q = document.getElementById('book-query').value.trim();
87 +
        if (!q) return;
88 +
        const btn = document.getElementById('search-btn');
89 +
        const status = document.getElementById('search-status');
90 +
        const results = document.getElementById('search-results');
91 +
        btn.disabled = true;
92 +
        btn.textContent = 'Searching...';
93 +
        status.style.display = 'none';
94 +
        results.innerHTML = '';
95 +
        try {
96 +
          const resp = await fetch('/admin/search?q=' + encodeURIComponent(q));
97 +
          const data = await resp.json();
98 +
          if (!resp.ok) {
99 +
            status.textContent = data.error || 'Search failed';
100 +
            status.className = 'search-status error';
101 +
            status.style.display = 'block';
102 +
            return;
103 +
          }
104 +
          if (!data.length) {
105 +
            status.textContent = 'No results';
106 +
            status.className = 'search-status';
107 +
            status.style.display = 'block';
108 +
            return;
109 +
          }
110 +
          data.forEach(function(hit) {
111 +
            results.appendChild(renderHit(hit));
112 +
          });
113 +
        } catch (e) {
114 +
          status.textContent = 'Request failed';
115 +
          status.className = 'search-status error';
116 +
          status.style.display = 'block';
117 +
        } finally {
118 +
          btn.disabled = false;
119 +
          btn.textContent = 'Search';
120 +
        }
121 +
      }
122 +
123 +
      function renderHit(hit) {
124 +
        const card = document.createElement('form');
125 +
        card.method = 'POST';
126 +
        card.action = '/admin/add';
127 +
        card.className = 'book-card hit';
128 +
129 +
        const cover = document.createElement('div');
130 +
        if (hit.cover_url) {
131 +
          const img = document.createElement('img');
132 +
          img.src = hit.cover_url;
133 +
          img.className = 'book-cover';
134 +
          img.loading = 'lazy';
135 +
          card.appendChild(img);
136 +
        } else {
137 +
          cover.className = 'book-cover placeholder';
138 +
          card.appendChild(cover);
139 +
        }
140 +
141 +
        const info = document.createElement('div');
142 +
        info.className = 'book-info';
143 +
        info.innerHTML =
144 +
          '<h3 class="book-title"></h3>' +
145 +
          '<p class="book-authors"></p>' +
146 +
          (hit.isbn ? '<p class="book-meta">ISBN: ' + escapeHtml(hit.isbn) + '</p>' : '');
147 +
        info.querySelector('.book-title').textContent = hit.title;
148 +
        info.querySelector('.book-authors').textContent = hit.authors;
149 +
150 +
        const hidden = function(name, value) {
151 +
          const el = document.createElement('input');
152 +
          el.type = 'hidden';
153 +
          el.name = name;
154 +
          el.value = value || '';
155 +
          return el;
156 +
        };
157 +
        info.appendChild(hidden('google_id', hit.google_id));
158 +
        info.appendChild(hidden('title', hit.title));
159 +
        info.appendChild(hidden('authors', hit.authors));
160 +
        info.appendChild(hidden('isbn', hit.isbn));
161 +
        info.appendChild(hidden('cover_url', hit.cover_url));
162 +
163 +
        const select = document.createElement('select');
164 +
        select.name = 'status';
165 +
        ['want', 'reading', 'read'].forEach(function(s) {
166 +
          const o = document.createElement('option');
167 +
          o.value = s;
168 +
          o.textContent = s === 'want' ? 'Want to Read' : s.charAt(0).toUpperCase() + s.slice(1);
169 +
          select.appendChild(o);
170 +
        });
171 +
        info.appendChild(select);
172 +
173 +
        const btn = document.createElement('button');
174 +
        btn.type = 'submit';
175 +
        btn.textContent = 'Add';
176 +
        info.appendChild(btn);
177 +
178 +
        card.appendChild(info);
179 +
        return card;
180 +
      }
181 +
182 +
      function escapeHtml(s) {
183 +
        return String(s || '').replace(/[&<>"']/g, function(c) {
184 +
          return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"})[c];
185 +
        });
186 +
      }
187 +
    </script>
188 +
  </body>
189 +
</html>
apps/library/src/templates/index.html (added) +65 −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="/admin">add</a>
33 +
      </nav>
34 +
    </div>
35 +
36 +
    <nav class="tabs">
37 +
      <a href="/read" class="tab{% if tab == "read" %} active{% endif %}">Read <span class="count">{{ counts.read }}</span></a>
38 +
      <a href="/reading" class="tab{% if tab == "reading" %} active{% endif %}">Reading <span class="count">{{ counts.reading }}</span></a>
39 +
      <a href="/want" class="tab{% if tab == "want" %} active{% endif %}">Want to Read <span class="count">{{ counts.want }}</span></a>
40 +
    </nav>
41 +
42 +
    {% if books.is_empty() %}
43 +
    <p class="no-books">No books in {{ tab_label }}.</p>
44 +
    {% else %}
45 +
    <div class="books-list">
46 +
      {% for book in books %}
47 +
      <article class="book-card">
48 +
        {% if let Some(url) = book.cover_url %}
49 +
        <img class="book-cover" src="{{ url }}" alt="" loading="lazy" />
50 +
        {% else %}
51 +
        <div class="book-cover placeholder"></div>
52 +
        {% endif %}
53 +
        <div class="book-info">
54 +
          <h3 class="book-title">{{ book.title }}</h3>
55 +
          <p class="book-authors">{{ book.authors }}</p>
56 +
          {% if let Some(n) = book.notes %}
57 +
          <p class="book-notes">{{ n }}</p>
58 +
          {% endif %}
59 +
        </div>
60 +
      </article>
61 +
      {% endfor %}
62 +
    </div>
63 +
    {% endif %}
64 +
  </body>
65 +
</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) +219 −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 +
/* Tabs */
12 +
13 +
.tabs {
14 +
  display: flex;
15 +
  gap: 1.5rem;
16 +
  padding: 0.75rem 0;
17 +
  border-bottom: 1px solid #333;
18 +
  margin-bottom: 1.5rem;
19 +
}
20 +
21 +
.tab {
22 +
  font-size: 14px;
23 +
  text-decoration: none;
24 +
  opacity: 0.5;
25 +
  display: inline-flex;
26 +
  align-items: center;
27 +
  gap: 0.4rem;
28 +
}
29 +
30 +
.tab:hover {
31 +
  opacity: 0.7;
32 +
}
33 +
34 +
.tab.active {
35 +
  opacity: 1;
36 +
}
37 +
38 +
.tab .count {
39 +
  font-size: 12px;
40 +
  opacity: 0.5;
41 +
}
42 +
43 +
/* Books list */
44 +
45 +
.books-list {
46 +
  width: 100%;
47 +
  display: flex;
48 +
  flex-direction: column;
49 +
  gap: 1.25rem;
50 +
}
51 +
52 +
.book-card {
53 +
  display: flex;
54 +
  gap: 1rem;
55 +
  padding: 1rem 0;
56 +
  border-bottom: 1px solid #333;
57 +
}
58 +
59 +
.book-card:last-child {
60 +
  border-bottom: none;
61 +
}
62 +
63 +
.book-cover {
64 +
  width: 72px;
65 +
  height: 108px;
66 +
  object-fit: cover;
67 +
  background: #1e1c1f;
68 +
  flex-shrink: 0;
69 +
  display: block;
70 +
}
71 +
72 +
.book-cover.placeholder {
73 +
  background: #1e1c1f;
74 +
}
75 +
76 +
.book-info {
77 +
  display: flex;
78 +
  flex-direction: column;
79 +
  gap: 0.4rem;
80 +
  flex: 1;
81 +
  min-width: 0;
82 +
}
83 +
84 +
.book-title {
85 +
  font-size: 16px;
86 +
  font-weight: 400;
87 +
  line-height: 1.4;
88 +
}
89 +
90 +
.book-authors {
91 +
  font-size: 14px;
92 +
  opacity: 0.7;
93 +
}
94 +
95 +
.book-meta {
96 +
  font-size: 12px;
97 +
  opacity: 0.5;
98 +
}
99 +
100 +
.book-notes {
101 +
  font-size: 13px;
102 +
  opacity: 0.7;
103 +
  line-height: 1.5;
104 +
}
105 +
106 +
.no-books {
107 +
  text-align: center;
108 +
  opacity: 0.5;
109 +
  padding: 2rem;
110 +
}
111 +
112 +
/* Admin */
113 +
114 +
.admin-form {
115 +
  display: flex;
116 +
  flex-direction: column;
117 +
  gap: 0.75rem;
118 +
  width: 100%;
119 +
  margin-bottom: 1.5rem;
120 +
}
121 +
122 +
.admin-form h3 {
123 +
  font-size: 14px;
124 +
  font-weight: 400;
125 +
  opacity: 0.5;
126 +
}
127 +
128 +
.hint {
129 +
  font-size: 12px;
130 +
  opacity: 0.5;
131 +
  line-height: 1.4;
132 +
}
133 +
134 +
.search-row {
135 +
  display: flex;
136 +
  gap: 0.5rem;
137 +
  width: 100%;
138 +
}
139 +
140 +
.search-row input {
141 +
  flex: 1;
142 +
}
143 +
144 +
.search-status {
145 +
  font-size: 12px;
146 +
}
147 +
148 +
.search-results {
149 +
  display: flex;
150 +
  flex-direction: column;
151 +
  gap: 0.5rem;
152 +
  width: 100%;
153 +
}
154 +
155 +
.book-card.hit,
156 +
.book-card.admin {
157 +
  border: 1px solid #333;
158 +
  padding: 0.75rem;
159 +
  border-bottom: 1px solid #333;
160 +
}
161 +
162 +
.book-card.admin .inline {
163 +
  display: flex;
164 +
  gap: 0.5rem;
165 +
  align-items: center;
166 +
  margin-top: 0.4rem;
167 +
}
168 +
169 +
.book-card.admin .notes-form {
170 +
  flex-direction: column;
171 +
  align-items: stretch;
172 +
}
173 +
174 +
.book-card.admin textarea {
175 +
  width: 100%;
176 +
  font-family: inherit;
177 +
  font-size: 13px;
178 +
  background: #121113;
179 +
  color: #ffffff;
180 +
  border: 1px solid #333;
181 +
  padding: 0.4rem;
182 +
  resize: vertical;
183 +
}
184 +
185 +
.admin-subs {
186 +
  width: 100%;
187 +
  display: flex;
188 +
  flex-direction: column;
189 +
  gap: 0.75rem;
190 +
}
191 +
192 +
.admin-subs h3 {
193 +
  font-size: 14px;
194 +
  opacity: 0.5;
195 +
  font-weight: 400;
196 +
}
197 +
198 +
button.danger,
199 +
.btn.danger {
200 +
  opacity: 0.5;
201 +
}
202 +
203 +
button.danger:hover,
204 +
.btn.danger:hover {
205 +
  opacity: 0.3;
206 +
}
207 +
208 +
@media (max-width: 480px) {
209 +
  .book-card {
210 +
    gap: 0.75rem;
211 +
  }
212 +
  .book-cover {
213 +
    width: 56px;
214 +
    height: 84px;
215 +
  }
216 +
  .book-title {
217 +
    font-size: 14px;
218 +
  }
219 +
}