diff --git a/web-ui/src/components/kobo_setup.rs b/web-ui/src/components/kobo_setup.rs new file mode 100644 index 0000000..e94fd90 --- /dev/null +++ b/web-ui/src/components/kobo_setup.rs @@ -0,0 +1,213 @@ +//! Kobo device setup: mint, copy, and revoke the auth tokens a Kobo presents to +//! the sync API. Lives on `/settings`; the library page renders the same section +//! with [`KoboSetup`]`(first_run_only=true)` so it shows there only until the +//! first device binds to a token. + +use leptos::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api::{self, ApiResult, Token}; +use crate::style::{ALERT_ERROR, BTN, BTN_ACCENT, BTN_PRIMARY, BTN_SM, H2, LINK, SECTION_HEAD}; + +/// A token deletion awaiting confirmation in the modal. +#[derive(Clone)] +enum Pending { + /// First-stage delete: the server may still refuse with a 409 if the token + /// is device-bound, which escalates to [`Pending::ForceDelete`]. + Delete(i64), + /// Confirmed delete of a device-bound token (retries with `force`). The + /// device serial is already in the prompt text stored alongside it. + ForceDelete(i64), +} + +/// Token list with a generate button, a copyable `api_endpoint`, and a link to +/// the one-click `/setup` flow. +#[component] +pub fn KoboSetup( + /// Render nothing once any token is bound to a device. The library page sets + /// this so the section is a first-run prompt there and lives in Settings + /// from then on. + #[prop(optional)] + first_run_only: bool, +) -> impl IntoView { + // ---- tokens (reloadable) ---- + let tokens = RwSignal::new(None::>>); + let reload = move || { + spawn_local(async move { tokens.set(Some(api::tokens().await)) }); + }; + reload(); + + let generating = RwSignal::new(false); + let copied = RwSignal::new(None::); + let confirm = RwSignal::new(None::<(String, Pending)>); + + // Hidden while tokens load (and on error) so the first-run prompt never + // flashes on a device that is already set up. + let visible = move || { + !first_run_only + || matches!(tokens.get(), Some(Ok(list)) if !list.iter().any(|t| t.device_serial.is_some())) + }; + + let generate = move |_| { + generating.set(true); + spawn_local(async move { + let _ = api::create_token().await; + reload(); + generating.set(false); + }); + }; + + let copy = move |id: i64, endpoint: String| { + if let Some(clip) = web_sys::window().map(|w| w.navigator().clipboard()) { + let _ = clip.write_text(&endpoint); + copied.set(Some(id)); + set_timeout( + move || { + if copied.get_untracked() == Some(id) { + copied.set(None); + } + }, + std::time::Duration::from_millis(1500), + ); + } + }; + + view! { + {move || visible().then(|| view! { +
+
+

"Kobo Setup"

+
+ "Set up a device" + +
+
+ {move || match tokens.get() { + None => view! {

"Loading tokens\u{2026}"

}.into_any(), + Some(Err(e)) => view! { +

{format!("Failed to load tokens: {e}")}

+ }.into_any(), + Some(Ok(list)) if list.is_empty() => view! { +

+ "No tokens yet. Generate one to connect your Kobo device." +

+ }.into_any(), + Some(Ok(list)) => { + let cards = list.into_iter().map(|t| { + let endpoint = t.api_endpoint.clone(); + let ep_copy = endpoint.clone(); + let id = t.id; + let serial = t.device_serial.clone(); + view! { +
+
+ + "\u{1F511}" + {t.token.clone()} + + {serial.map(|s| view! { + + "\u{1F4F1} " {s} + + })} + + +
+
+ + +
+

+ "Set as " "api_endpoint" + " on the device, or use " + "Set up a device" "." +

+
+ } + }).collect_view(); + view! {
{cards}
}.into_any() + } + }} +
+ +
+ })} + + // ---- Confirm modal ---- + {move || confirm.get().map(|(msg, pending)| { + view! { +
+
+

{msg}

+
+ + +
+
+
+ } + })} + } +} diff --git a/web-ui/src/components/library.rs b/web-ui/src/components/library.rs index 261954d..1fde3ce 100644 --- a/web-ui/src/components/library.rs +++ b/web-ui/src/components/library.rs @@ -1,5 +1,6 @@ -//! `/` — the main library view: current user, Kobo token management, and the -//! book grid with EPUB upload. All data comes from the protected `/api/...` +//! `/` — the main library view: current user and the book grid with EPUB +//! upload, plus the Kobo setup section until a device is registered (it lives +//! on `/settings` from then on). All data comes from the protected `/api/...` //! routes; an unauthenticated `/api/me` bounces the browser to `/login`. use leptos::prelude::*; @@ -8,27 +9,17 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use web_sys::{FormData, HtmlInputElement}; -use crate::api::{self, ApiError, ApiResult, Book, Token, User}; +use crate::api::{self, ApiError, ApiResult, Book, User}; use crate::components::book_editor::BookEditor; +use crate::components::kobo_setup::KoboSetup; use crate::style::{ - ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BRAND, BTN, BTN_ACCENT, BTN_PRIMARY, BTN_SM, HEADER, - LINK, SWITCH, SWITCH_INPUT, SWITCH_SLIDER, + ALERT_ERROR, ALERT_INFO, ALERT_SUCCESS, BRAND, BTN, BTN_PRIMARY, BTN_SM, H2, HEADER, LINK, + SECTION_HEAD, SWITCH, SWITCH_INPUT, SWITCH_SLIDER, }; -/// Section heading (`

`). -const H2: &str = "text-base font-semibold m-0 tracking-[-0.01em]"; -/// Section-head row: title left, actions right. -const SECTION_HEAD: &str = "flex items-center justify-between gap-4 mb-[18px]"; - /// A destructive action awaiting confirmation in the modal. #[derive(Clone)] enum Pending { - /// First-stage token delete: the server may still refuse with a 409 if the - /// token is device-bound, which escalates to [`Pending::ForceDeleteToken`]. - DeleteToken(i64), - /// Confirmed delete of a device-bound token (retries with `force`). The - /// device serial is already in the prompt text stored alongside it. - ForceDeleteToken(i64), /// Permanently delete an uploaded book (purge files); only reachable from the /// "Deleted" view. Soft delete itself is one-click and needs no confirm. PurgeBook(i64), @@ -76,13 +67,6 @@ pub fn Library() -> impl IntoView { }); } - // ---- tokens (reloadable) ---- - let tokens = RwSignal::new(None::>>); - let reload_tokens = move || { - spawn_local(async move { tokens.set(Some(api::tokens().await)) }); - }; - reload_tokens(); - // ---- books (reloadable) ---- let books = RwSignal::new(None::>>); let reload_books = move || { @@ -94,37 +78,11 @@ pub fn Library() -> impl IntoView { let sort = RwSignal::new(Sort::Title); let filter = RwSignal::new(Filter::All); let book_edit = RwSignal::new(None::); - let generating = RwSignal::new(false); let upload_status = RwSignal::new(None::<(String, &'static str)>); - let copied = RwSignal::new(None::); let confirm = RwSignal::new(None::<(String, Pending)>); let file_input: NodeRef = NodeRef::new(); // ---- handlers ---- - let generate = move |_| { - generating.set(true); - spawn_local(async move { - let _ = api::create_token().await; - reload_tokens(); - generating.set(false); - }); - }; - - let copy = move |id: i64, endpoint: String| { - if let Some(clip) = web_sys::window().map(|w| w.navigator().clipboard()) { - let _ = clip.write_text(&endpoint); - copied.set(Some(id)); - set_timeout( - move || { - if copied.get_untracked() == Some(id) { - copied.set(None); - } - }, - std::time::Duration::from_millis(1500), - ); - } - }; - let on_file = move |ev: leptos::ev::Event| { let Some(target) = ev.target() else { return }; let Ok(input) = target.dyn_into::() else { @@ -173,94 +131,9 @@ pub fn Library() -> impl IntoView {
- // ---- Kobo setup ---- -
-
-

"Kobo Setup"

-
- "Set up a device" - -
-
- {move || match tokens.get() { - None => view! {

"Loading tokens\u{2026}"

}.into_any(), - Some(Err(e)) => view! { -

{format!("Failed to load tokens: {e}")}

- }.into_any(), - Some(Ok(list)) if list.is_empty() => view! { -

- "No tokens yet. Generate one to connect your Kobo device." -

- }.into_any(), - Some(Ok(list)) => { - let cards = list.into_iter().map(|t| { - let endpoint = t.api_endpoint.clone(); - let ep_copy = endpoint.clone(); - let id = t.id; - let serial = t.device_serial.clone(); - view! { -
-
- - "\u{1F511}" - {t.token.clone()} - - {serial.map(|s| view! { - - "\u{1F4F1} " {s} - - })} - - -
-
- - -
-

- "Set as " "api_endpoint" - " on the device, or use " - "Set up a device" "." -

-
- } - }).collect_view(); - view! {
{cards}
}.into_any() - } - }} -
- -
+ // Setup lives on /settings; here it is a first-run prompt that + // disappears once a device is bound to a token. + // ---- Library ----
@@ -475,25 +348,6 @@ pub fn Library() -> impl IntoView { let p = pending.clone(); confirm.set(None); match p { - Pending::DeleteToken(id) => spawn_local(async move { - match api::delete_token(id, false).await { - Ok(api::DeleteOutcome::Deleted) => reload_tokens(), - // Server refused: token is device-bound. Ask again, - // naming the device, and retry with force on confirm. - Ok(api::DeleteOutcome::DeviceBound(serial)) => { - let msg = format!( - "This token is bound to device {serial}. Deleting it \ - stops that device from syncing. Delete anyway?" - ); - confirm.set(Some((msg, Pending::ForceDeleteToken(id)))); - } - Err(_) => {} - } - }), - Pending::ForceDeleteToken(id) => spawn_local(async move { - let _ = api::delete_token(id, true).await; - reload_tokens(); - }), Pending::PurgeBook(id) => spawn_local(async move { let _ = api::delete_upload(id).await; reload_books(); diff --git a/web-ui/src/components/mod.rs b/web-ui/src/components/mod.rs index 01fe720..8f6568e 100644 --- a/web-ui/src/components/mod.rs +++ b/web-ui/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod book_editor; +pub mod kobo_setup; pub mod library; pub mod login; pub mod register; diff --git a/web-ui/src/components/settings.rs b/web-ui/src/components/settings.rs index 7474617..9155ae2 100644 --- a/web-ui/src/components/settings.rs +++ b/web-ui/src/components/settings.rs @@ -1,13 +1,14 @@ -//! `/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`. +//! `/settings` — Kobo device setup plus 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}; +use crate::components::kobo_setup::KoboSetup; use crate::style::{ ALERT_ERROR, ALERT_SUCCESS, BRAND, BTN, BTN_PRIMARY, CARD, HEADER, LINK, SPINNER, SWITCH, SWITCH_INPUT, SWITCH_SLIDER, @@ -103,6 +104,10 @@ pub fn Settings() -> impl IntoView {
+ // Token management: independent of the settings load below, so it + // still works if `/api/settings` fails. + + {move || load_error.get().map(|e| view! {
{format!("Failed to load settings: {e}")} diff --git a/web-ui/src/style.rs b/web-ui/src/style.rs index 58ff644..3e30417 100644 --- a/web-ui/src/style.rs +++ b/web-ui/src/style.rs @@ -62,6 +62,11 @@ pub const SWITCH_SLIDER: &str = "absolute inset-0 rounded-full bg-elev2 border b after:bg-dim after:rounded-full after:transition-[transform,background] after:duration-150 \ peer-checked:after:translate-x-4 peer-checked:after:bg-white"; +/// Section heading (`

`). +pub const H2: &str = "text-base font-semibold m-0 tracking-[-0.01em]"; +/// Section-head row: title left, actions right. +pub const SECTION_HEAD: &str = "flex items-center justify-between gap-4 mb-[18px]"; + /// Page header bar. pub const HEADER: &str = "flex items-center justify-between px-6 py-4 border-b border-border \ bg-bg/70 backdrop-blur-md sticky top-0 z-20";