diff --git a/AGENTS.md b/AGENTS.md index 1186352..2cf451b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,6 +306,16 @@ Kobo device. Internal refactors are fine; wire-visible changes are not. served entirely from uploads. - **No migration framework** — schema lives as an inline SQL array in `run_migrations()` in `db/app_db.rs`. Append new migrations; do not reorder. +- **Runtime settings + per-user book sync.** Two things are user-toggleable at + runtime from the web Settings page (`AppConfig` stays startup-only): a + key/value `settings` table holds `proxy_unimplemented` (whether + `handle_unimplemented` 307-redirects unknown store paths to `storeapi.kobo.com` + or returns 404) and `sync_new_uploads` (default sync flag stamped on new + uploads). Per-book sync is per-user: `books.default_sync` is the book's default + and `book_sync_prefs(user_id, book_id, enabled)` overrides it; a user's + syncable set is `COALESCE(pref, default_sync)=1`, filtered in + `BookStore::fetch_syncable_books_with_format(user_id)` so a toggled-off book + drops out of that user's next sync-point snapshot (emitted as removed). - **Resources dictionary** in `kobo/resources.rs` has 131 entries; only six are rewritten (`image_host`, `image_url_template`, `image_url_quality_template`, `library_sync`, `device_auth`, diff --git a/src/db/app_db.rs b/src/db/app_db.rs index e11dab1..7e3a4ea 100644 --- a/src/db/app_db.rs +++ b/src/db/app_db.rs @@ -340,6 +340,21 @@ impl AppDb { r#"CREATE INDEX IF NOT EXISTS idx_books_source ON books(source)"#, r#"CREATE INDEX IF NOT EXISTS idx_books_content_hash ON books(owner_user_id, content_hash)"#, + // Global server settings as a simple key/value store. Values are + // TEXT; booleans are stored as "1"/"0". An absent key means "use the + // built-in default", so upgrades preserve prior behavior. + r#"CREATE TABLE IF NOT EXISTS settings ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL + )"#, + // Per-user override of a book's sync eligibility. A present row wins + // over `books.default_sync`; absence falls back to that default. + r#"CREATE TABLE IF NOT EXISTS book_sync_prefs ( + user_id INTEGER NOT NULL, + book_id INTEGER NOT NULL, + enabled INTEGER NOT NULL, + PRIMARY KEY (user_id, book_id) + )"#, ]; for sql in &statements { @@ -364,6 +379,12 @@ impl AppDb { .change_context(Error::Database) .attach("Failed to create oidc_sub index")?; + // A book's default sync eligibility, applied to any user without an + // explicit `book_sync_prefs` row. `DEFAULT 1` keeps every existing book + // syncable across the upgrade. + self.add_column_if_missing("books", "default_sync", "INTEGER NOT NULL DEFAULT 1") + .await?; + Ok(()) } @@ -1031,6 +1052,66 @@ impl AppDb { Ok(()) } + // ---- Settings & Sync Preferences ---- + + /// Read a boolean setting, returning `default` when the key is absent. + pub async fn get_bool_setting(&self, key: &str, default: bool) -> Result { + let row = sqlx::query("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to read setting")?; + + Ok(row + .map(|r| r.get::("value") == "1") + .unwrap_or(default)) + } + + /// Upsert a boolean setting, stored as `"1"`/`"0"`. + pub async fn set_bool_setting(&self, key: &str, value: bool) -> Result<()> { + sqlx::query( + r#" + INSERT INTO settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + "#, + ) + .bind(key) + .bind(if value { "1" } else { "0" }) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to write setting")?; + + Ok(()) + } + + /// Set a user's per-book sync override. + pub async fn set_book_sync_pref( + &self, + user_id: i64, + book_id: i64, + enabled: bool, + ) -> Result<()> { + sqlx::query( + r#" + INSERT INTO book_sync_prefs (user_id, book_id, enabled) + VALUES (?, ?, ?) + ON CONFLICT(user_id, book_id) DO UPDATE SET enabled = excluded.enabled + "#, + ) + .bind(user_id) + .bind(book_id) + .bind(enabled) + .execute(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to set book sync preference")?; + + Ok(()) + } + // ---- Shelf Operations ---- /// Get all shelves for a user. diff --git a/src/db/book_store.rs b/src/db/book_store.rs index 359af9a..9fe70b7 100644 --- a/src/db/book_store.rs +++ b/src/db/book_store.rs @@ -70,6 +70,10 @@ pub struct NewBook { pub content_hash: Option, pub has_cover: bool, pub owner_user_id: Option, + /// Default sync eligibility for the book, applied to any user without an + /// explicit `book_sync_prefs` override. Calibre ingest: always `true`. + /// Upload: the `sync_new_uploads` setting at upload time. + pub default_sync: bool, pub timestamp: DateTime, pub last_modified: DateTime, } @@ -89,6 +93,8 @@ pub struct CatalogBook { pub file_format: String, pub has_cover: bool, pub source: BookSource, + /// Effective sync state for the requesting user (override, else default). + pub sync_enabled: bool, } /// The full `books` row, as read back from the app DB. @@ -176,17 +182,22 @@ impl BookStore { // ---- Reads ---- - /// List every book with the fields the web UI needs (including `source` and - /// the stored `has_cover` flag), in one query. Ordered by title. - pub async fn list_catalog(&self) -> Result> { + /// List every book with the fields the web UI needs (including `source`, + /// the stored `has_cover` flag, and the requesting user's effective sync + /// state), in one query. Ordered by title. + pub async fn list_catalog(&self, user_id: i64) -> Result> { let rows = sqlx::query( r#" - SELECT id, uuid, title, authors, publisher, language, description, - series_name, series_index, file_format, has_cover, source - FROM books - ORDER BY title COLLATE NOCASE ASC + SELECT b.id, b.uuid, b.title, b.authors, b.publisher, b.language, + b.description, b.series_name, b.series_index, b.file_format, + b.has_cover, b.source, + COALESCE(p.enabled, b.default_sync) AS sync_enabled + FROM books b + LEFT JOIN book_sync_prefs p ON p.book_id = b.id AND p.user_id = ?1 + ORDER BY b.title COLLATE NOCASE ASC "#, ) + .bind(user_id) .fetch_all(&self.pool) .await .change_context(Error::Database) @@ -216,6 +227,7 @@ impl BookStore { source: BookSource::parse( &r.get::, _>("source").unwrap_or_default(), ), + sync_enabled: r.get::, _>("sync_enabled").unwrap_or(1) != 0, } }) .collect()) @@ -237,14 +249,24 @@ impl BookStore { .collect()) } - /// Fetch every book paired with its preferred format + size. One row per - /// book. Used to populate a sync-point snapshot. - pub async fn fetch_syncable_books_with_format(&self) -> Result> { - let rows = sqlx::query(sqlx::AssertSqlSafe(Self::select("ORDER BY id ASC"))) - .fetch_all(&self.pool) - .await - .change_context(Error::Database) - .attach("Failed to fetch syncable books")?; + /// Fetch every book that `user_id` syncs, paired with its preferred format + /// and size. One row per book. Used to populate a sync-point snapshot. A + /// book is included when the user's `book_sync_prefs` override (else the + /// book's default) resolves to enabled. + pub async fn fetch_syncable_books_with_format( + &self, + user_id: i64, + ) -> Result> { + let rows = sqlx::query(sqlx::AssertSqlSafe(Self::select( + "LEFT JOIN book_sync_prefs p ON p.book_id = books.id AND p.user_id = ?1 \ + WHERE COALESCE(p.enabled, books.default_sync) = 1 \ + ORDER BY id ASC", + ))) + .bind(user_id) + .fetch_all(&self.pool) + .await + .change_context(Error::Database) + .attach("Failed to fetch syncable books")?; Ok(rows .iter() @@ -456,8 +478,8 @@ const INSERT_BOOK_SQL: &str = r#" (id, uuid, title, authors, publisher, language, description, series_name, series_index, pubdate, file_format, file_size, source, locator, original_filename, content_hash, has_cover, - owner_user_id, timestamp, last_modified) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + owner_user_id, default_sync, timestamp, last_modified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#; /// Bind a [`NewBook`]'s fields onto the shared insert statement. @@ -485,6 +507,7 @@ fn bind_new_book<'q>( .bind(&book.content_hash) .bind(book.has_cover as i64) .bind(book.owner_user_id) + .bind(book.default_sync as i64) .bind(format_dt(book.timestamp)) .bind(format_dt(book.last_modified)) } diff --git a/src/ingest/calibre.rs b/src/ingest/calibre.rs index cc2efa1..02f38fa 100644 --- a/src/ingest/calibre.rs +++ b/src/ingest/calibre.rs @@ -90,6 +90,9 @@ fn new_book_from( content_hash: None, has_cover, owner_user_id: None, + // Calibre books are syncable by default; a user can still opt out + // per-book via `book_sync_prefs`. + default_sync: true, timestamp: full.book.timestamp, last_modified: full.book.last_modified, } diff --git a/src/kobo/handlers.rs b/src/kobo/handlers.rs index 9a08f6f..661c245 100644 --- a/src/kobo/handlers.rs +++ b/src/kobo/handlers.rs @@ -1,7 +1,7 @@ use axum::{ extract::{OriginalUri, Path, State}, http::{HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Redirect}, + response::{IntoResponse, Redirect, Response}, Json, }; use chrono::Utc; @@ -991,10 +991,25 @@ impl KoboHandlers { /// `storeapi.kobo.com` so a device with a real Kobo account still receives /// a valid response instead of a stall. Empty stub responses caused the /// device to abort the store session before reaching `library_sync`. - pub async fn handle_unimplemented(OriginalUri(uri): OriginalUri) -> impl IntoResponse { + /// + /// Gated by the `proxy_unimplemented` server setting (default on). When + /// disabled, unimplemented paths return `404` instead of redirecting — for + /// deployments that do not want the device talking to the real Kobo store. + pub async fn handle_unimplemented( + State(state): State, + OriginalUri(uri): OriginalUri, + ) -> Response { + let proxy = state + .app_db + .get_bool_setting("proxy_unimplemented", true) + .await + .unwrap_or(true); + if !proxy { + return StatusCode::NOT_FOUND.into_response(); + } let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); let rest = strip_kobo_prefix(path_and_query); - Redirect::temporary(&format!("https://storeapi.kobo.com{rest}")) + Redirect::temporary(&format!("https://storeapi.kobo.com{rest}")).into_response() } } diff --git a/src/kobo/sync_point.rs b/src/kobo/sync_point.rs index 16f15bd..b5a8146 100644 --- a/src/kobo/sync_point.rs +++ b/src/kobo/sync_point.rs @@ -42,8 +42,11 @@ impl<'a> SyncPointLifecycle<'a> { /// Materialize a fresh snapshot of the user's library state. pub async fn create_snapshot(&self) -> Result { - // Pass 1: read the unified catalog. - let calibre_books = self.books.fetch_syncable_books_with_format().await?; + // Pass 1: read the unified catalog, filtered to this user's synced books. + let calibre_books = self + .books + .fetch_syncable_books_with_format(self.user_id) + .await?; // Pass 2: read app-side fingerprints once. let reading_state_map = self diff --git a/src/upload/handler.rs b/src/upload/handler.rs index fa224e5..f4736fd 100644 --- a/src/upload/handler.rs +++ b/src/upload/handler.rs @@ -160,6 +160,12 @@ pub async fn api_upload( // Insert the catalog row with a freshly allocated upload id. let id = state.books.next_upload_id().await.map_err(internal)?; + // New uploads sync by default unless the server setting says otherwise. + let default_sync = state + .app_db + .get_bool_setting("sync_new_uploads", true) + .await + .map_err(internal)?; let now = Utc::now(); let title = meta .title @@ -186,6 +192,7 @@ pub async fn api_upload( content_hash: Some(hash), has_cover, owner_user_id: Some(user.id), + default_sync, timestamp: now, last_modified: now, }; diff --git a/src/web.rs b/src/web.rs index 398f4bf..913a6cd 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2,7 +2,7 @@ use axum::{ extract::{DefaultBodyLimit, Path, Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode, Uri}, response::{Html, IntoResponse, Redirect, Response}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Form, Json, Router, }; use axum_login::{AuthUser, AuthnBackend}; @@ -46,6 +46,9 @@ struct BookJson { formats: Vec, /// `"calibre"` or `"upload"` — the UI shows a delete control for uploads. source: String, + /// Whether this book syncs to the requesting user's devices (their + /// per-book override, else the book's default). Toggled from the library. + sync_enabled: bool, } /// JSON representation of the current user. @@ -68,6 +71,21 @@ struct TokenJson { device_serial: Option, } +/// Global server settings, exposed to and updated from the Settings page. +#[derive(Serialize, Deserialize)] +struct SettingsJson { + /// Redirect unimplemented Kobo store endpoints to the real store. + proxy_unimplemented: bool, + /// Newly uploaded books sync to devices by default. + sync_new_uploads: bool, +} + +/// Body of `PUT /api/books/{id}/sync` — the desired per-user sync state. +#[derive(Deserialize)] +struct BookSyncReq { + enabled: bool, +} + /// Build the web UI router (mounted at server root). /// /// The browser UI is a Leptos client-side SPA (`web-ui/`), served as static @@ -86,6 +104,11 @@ pub fn router(max_upload_size: usize) -> Router { .route("/api/register-device", post(api_register_device)) .route("/api/ingest/calibre", post(api_ingest_calibre)) .route("/api/tokens/{token_id}", delete(api_delete_token)) + .route( + "/api/settings", + get(api_get_settings).put(api_update_settings), + ) + .route("/api/books/{book_id}/sync", put(api_set_book_sync)) .route("/covers/{book_id}", get(cover)) // Upload routes authenticate via the `ApiUser` extractor (browser // session cookie OR `Authorization: Bearer` token) so the Calibre @@ -157,11 +180,11 @@ async fn api_books( auth_session: AuthSession, State(state): State, ) -> Result>, (StatusCode, String)> { - auth_session + let user = auth_session .user .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; - let books = state.books.list_catalog().await.map_err(|e| { + let books = state.books.list_catalog(user.id()).await.map_err(|e| { error!("Failed to fetch books: {e:?}"); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) })?; @@ -181,12 +204,89 @@ async fn api_books( has_cover: b.has_cover, formats: vec![b.file_format], source: b.source.as_str().to_string(), + sync_enabled: b.sync_enabled, }) .collect(); Ok(Json(result)) } +/// GET /api/settings — Return the global server settings. +async fn api_get_settings( + auth_session: AuthSession, + State(state): State, +) -> Result, (StatusCode, String)> { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let read = |key: &'static str| { + let db = state.app_db.clone(); + async move { db.get_bool_setting(key, true).await } + }; + let (proxy_unimplemented, sync_new_uploads) = + tokio::try_join!(read("proxy_unimplemented"), read("sync_new_uploads")).map_err(|e| { + error!("Failed to read settings: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + + Ok(Json(SettingsJson { + proxy_unimplemented, + sync_new_uploads, + })) +} + +/// PUT /api/settings — Persist the global server settings. +async fn api_update_settings( + auth_session: AuthSession, + State(state): State, + Json(req): Json, +) -> Result { + auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + let map_err = |e: error_stack::Report| { + error!("Failed to write settings: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + }; + state + .app_db + .set_bool_setting("proxy_unimplemented", req.proxy_unimplemented) + .await + .map_err(map_err)?; + state + .app_db + .set_bool_setting("sync_new_uploads", req.sync_new_uploads) + .await + .map_err(map_err)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// PUT /api/books/{book_id}/sync — Set the requesting user's per-book sync flag. +async fn api_set_book_sync( + auth_session: AuthSession, + State(state): State, + Path(book_id): Path, + Json(req): Json, +) -> Result { + let user = auth_session + .user + .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; + + state + .app_db + .set_book_sync_pref(user.id(), book_id, req.enabled) + .await + .map_err(|e| { + error!("Failed to set book sync preference: {e:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) + })?; + + Ok(StatusCode::NO_CONTENT) +} + /// GET /api/me — Return the currently logged-in user. async fn api_me(auth_session: AuthSession) -> Result, StatusCode> { let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?; diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index d5067b9..926c012 100644 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -5,7 +5,7 @@ //! credentials are threaded through here. A `401` surfaces as //! [`ApiError::Unauthorized`] so callers can bounce to the login route. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use wasm_bindgen::JsValue; use web_sys::FormData; @@ -19,6 +19,17 @@ pub struct Book { pub has_cover: bool, /// `"calibre"` or `"upload"` — only uploads get a delete control. pub source: String, + /// Whether this book syncs to the user's devices (per-book toggle state). + pub sync_enabled: bool, +} + +/// Global server settings (`GET`/`PUT /api/settings`). +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)] +pub struct Settings { + /// Redirect unimplemented Kobo store endpoints to the real store. + pub proxy_unimplemented: bool, + /// Newly uploaded books sync to devices by default. + pub sync_new_uploads: bool, } /// A Kobo auth token, as returned by `GET`/`POST /api/tokens`. @@ -129,6 +140,30 @@ pub async fn books() -> ApiResult> { Ok(check(resp).await?.json().await?) } +/// `GET /api/settings` — the global server settings. +pub async fn settings() -> ApiResult { + let resp = gloo_net::http::Request::get("/api/settings").send().await?; + Ok(check(resp).await?.json().await?) +} + +/// `PUT /api/settings` — persist the global server settings. +pub async fn update_settings(s: Settings) -> ApiResult<()> { + let resp = gloo_net::http::Request::put("/api/settings") + .json(&s)? + .send() + .await?; + check(resp).await.map(|_| ()) +} + +/// `PUT /api/books/{id}/sync` — set this user's per-book sync flag. +pub async fn set_book_sync(id: i64, enabled: bool) -> ApiResult<()> { + let resp = gloo_net::http::Request::put(&format!("/api/books/{id}/sync")) + .json(&serde_json::json!({ "enabled": enabled }))? + .send() + .await?; + check(resp).await.map(|_| ()) +} + /// `GET /api/tokens` — the user's Kobo auth tokens. pub async fn tokens() -> ApiResult> { let resp = gloo_net::http::Request::get("/api/tokens").send().await?; @@ -171,7 +206,10 @@ pub async fn delete_token(id: i64, force: bool) -> ApiResult { pub async fn register_device(token: &str) -> ApiResult<()> { let resp = gloo_net::http::Request::post("/api/register-device") .header("Content-Type", "application/json") - .body(format!("{{\"token\":{}}}", serde_json::to_string(token).unwrap_or_default()))? + .body(format!( + "{{\"token\":{}}}", + serde_json::to_string(token).unwrap_or_default() + ))? .send() .await?; check(resp).await.map(|_| ()) diff --git a/web-ui/src/components/app.rs b/web-ui/src/components/app.rs index 9851da0..0a15ead 100644 --- a/web-ui/src/components/app.rs +++ b/web-ui/src/components/app.rs @@ -9,6 +9,7 @@ use leptos_router::path; use crate::components::library::Library; use crate::components::login::Login; use crate::components::register::Register; +use crate::components::settings::Settings; use crate::components::setup::Setup; #[component] @@ -20,6 +21,7 @@ pub fn App() -> impl IntoView { + } diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index 88e339f..3659540 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -124,6 +124,7 @@ pub fn Library() -> impl IntoView { _ => String::new(), }} + "Settings" "Log out" @@ -230,6 +231,7 @@ pub fn Library() -> impl IntoView { let cards = list.into_iter().map(|b| { let id = b.id; let is_upload = b.source == "upload"; + let sync_enabled = b.sync_enabled; let title = b.title.clone(); let author = if b.authors.is_empty() { "Unknown".to_string() @@ -257,6 +259,20 @@ pub fn Library() -> impl IntoView {

{title.clone()}

{author}

+ } }).collect_view(); diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs index 914becc..a5fd767 100644 --- a/web-ui/src/components/mod.rs +++ b/web-ui/src/components/mod.rs @@ -4,4 +4,5 @@ pub mod app; pub mod library; pub mod login; pub mod register; +pub mod settings; pub mod setup; diff --git a/web-ui/src/components/settings.rs b/web-ui/src/components/settings.rs new file mode 100644 index 0000000..d262a31 --- /dev/null +++ b/web-ui/src/components/settings.rs @@ -0,0 +1,130 @@ +//! `/settings` — global server settings. Two toggles: proxy unimplemented Kobo +//! store endpoints to the real store, and whether newly uploaded books sync by +//! default. Loaded from and saved to the protected `/api/settings` route; an +//! unauthenticated load bounces to `/login`. + +use leptos::prelude::*; +use leptos_router::hooks::use_navigate; +use wasm_bindgen_futures::spawn_local; + +use crate::api::{self, ApiError}; + +#[component] +pub fn Settings() -> impl IntoView { + let navigate = use_navigate(); + + // Working copy of the two flags; `None` until the initial load resolves. + let proxy = RwSignal::new(None::); + let sync_uploads = RwSignal::new(None::); + let load_error = RwSignal::new(None::); + let saving = RwSignal::new(false); + // (message, alert-class) shown after a save attempt. + let status = RwSignal::new(None::<(String, &'static str)>); + + // Load current settings (also the auth gate). + { + let navigate = navigate.clone(); + spawn_local(async move { + match api::settings().await { + Ok(s) => { + proxy.set(Some(s.proxy_unimplemented)); + sync_uploads.set(Some(s.sync_new_uploads)); + } + Err(ApiError::Unauthorized) => navigate("/login", Default::default()), + Err(e) => load_error.set(Some(e.to_string())), + } + }); + } + + let save = move |_| { + let (Some(proxy_unimplemented), Some(sync_new_uploads)) = (proxy.get(), sync_uploads.get()) + else { + return; + }; + saving.set(true); + status.set(None); + spawn_local(async move { + let result = api::update_settings(api::Settings { + proxy_unimplemented, + sync_new_uploads, + }) + .await; + match result { + Ok(()) => status.set(Some(("Settings saved.".into(), "alert-success"))), + Err(e) => status.set(Some((format!("Save failed: {e}"), "alert-error"))), + } + saving.set(false); + }); + }; + + view! { +
+ + "kobors" + " / Settings" + + "← Library" +
+ +
+ {move || load_error.get().map(|e| view! { +
+ {format!("Failed to load settings: {e}")} +
+ })} + + {move || match (proxy.get(), sync_uploads.get()) { + (Some(proxy_on), Some(uploads_on)) => view! { +
+
+
+

"Proxy unknown requests to Kobo"

+

+ "Redirect store endpoints kobors does not serve to the real Kobo \ + store, so a device with a Kobo account still gets a response. \ + Turn off to keep the device from talking to Kobo (unknown \ + endpoints return 404)." +

+
+ +
+ +
+ +
+
+

"Sync newly uploaded books by default"

+

+ "When on, a freshly uploaded book syncs to your devices \ + automatically. When off, it is added to the library but excluded \ + from sync until you enable it per-book." +

+
+ +
+
+ + {move || status.get().map(|(msg, kind)| view! { +

{msg}

+ })} + +
+ +
+ }.into_any(), + _ => view! {
}.into_any(), + }} +
+ } +} diff --git a/web-ui/src/components/setup.rs b/web-ui/src/components/setup.rs index ce071f7..a0640af 100644 --- a/web-ui/src/components/setup.rs +++ b/web-ui/src/components/setup.rs @@ -75,8 +75,11 @@ pub fn Setup() -> impl IntoView { } }; - let endpoint_preview = - move || selected_token().map(|t| t.api_endpoint).unwrap_or_else(|| "—".to_string()); + let endpoint_preview = move || { + selected_token() + .map(|t| t.api_endpoint) + .unwrap_or_else(|| "—".to_string()) + }; let generate = move |_| { spawn_local(async move { diff --git a/web-ui/styles.css b/web-ui/styles.css index 794b909..b7f3dd0 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -298,6 +298,72 @@ code { font-family: var(--mono); } .book-card:hover .book-del { opacity: 1; } .book-del:hover { background: #dc2626; } +/* ---- Toggle switch ---- */ +.switch { + position: relative; + display: inline-block; + width: 38px; + height: 22px; + flex: none; +} +.switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} +.switch .slider { + position: absolute; + inset: 0; + background: var(--bg-elev-2); + border: 1px solid var(--border-strong); + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.switch .slider::before { + content: ""; + position: absolute; + height: 14px; + width: 14px; + left: 3px; + top: 3px; + background: var(--text-dim); + border-radius: 50%; + transition: transform 0.15s, background 0.15s; +} +.switch input:checked + .slider { + background: var(--accent); + border-color: transparent; +} +.switch input:checked + .slider::before { + transform: translateX(16px); + background: #fff; +} +.switch input:focus-visible + .slider { + box-shadow: 0 0 0 3px var(--accent-quiet); +} + +/* Per-book sync toggle under a book card. */ +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 6px; + cursor: pointer; +} +.switch-text { font-size: 12px; color: var(--text-faint); } + +/* A labelled setting line on the Settings page. */ +.setting-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} +.setting-text { min-width: 0; } +.setting-name { font-size: 15px; font-weight: 550; margin-bottom: 4px; } + /* ---- Alerts ---- */ .alert {