From d686692b1ac8eba600c7a92671c1c58be352a48c Mon Sep 17 00:00:00 2001 From: Alexey Date: Fri, 24 Apr 2026 20:09:14 +0500 Subject: [PATCH] mantra v0.3: selection popover + persistent inspector + source hero + landing polish --- crates/mantra-ui/Cargo.toml | 6 + crates/mantra-ui/src/pages/artifact.rs | 204 +++++++--- crates/mantra-ui/src/pages/inspector.rs | 485 +++++++++++++++++++++++ crates/mantra-ui/src/pages/landing.rs | 31 +- crates/mantra-ui/src/pages/margin.rs | 355 ----------------- crates/mantra-ui/src/pages/mod.rs | 2 +- crates/mantra-ui/src/pages/source.rs | 300 +++++++++++---- sass/main.scss | 492 ++++++++++++++++++++---- 8 files changed, 1307 insertions(+), 568 deletions(-) create mode 100644 crates/mantra-ui/src/pages/inspector.rs delete mode 100644 crates/mantra-ui/src/pages/margin.rs diff --git a/crates/mantra-ui/Cargo.toml b/crates/mantra-ui/Cargo.toml index fa80baa..017aec3 100644 --- a/crates/mantra-ui/Cargo.toml +++ b/crates/mantra-ui/Cargo.toml @@ -26,6 +26,12 @@ web-sys = { version = "0.3", optional = true, features = [ "DomTokenList", "Event", "EventTarget", + "Selection", + "Range", + "Node", + "DomRect", + "Navigator", + "Clipboard", "console", ] } # Server-only — pulled in under ssr feature for notes I/O + Claude HTTP. diff --git a/crates/mantra-ui/src/pages/artifact.rs b/crates/mantra-ui/src/pages/artifact.rs index 15fdce4..b6a2692 100644 --- a/crates/mantra-ui/src/pages/artifact.rs +++ b/crates/mantra-ui/src/pages/artifact.rs @@ -1,9 +1,4 @@ //! `/artifact/:slug` — closing document of a cycle. -//! -//! Currently renders: manifest.md (cycle 1) and applied-principles.md -//! (cycle 2). Same book-grade typography as the source page, same -//! paragraph-id → margin drawer wiring so readers can annotate the -//! artifacts too. use leptos::prelude::*; use leptos_router::hooks::{use_params, use_query_map}; @@ -11,7 +6,7 @@ use leptos_router::params::Params; use crate::api::{fetch_artifact, ArtifactPageData}; use crate::corpus::Lang; -use crate::pages::margin::{ActivePara, MarginDrawer}; +use crate::pages::inspector::{Anchor, Inspector, PopoverPos, SelectionPopover, Tab}; use crate::pages::shared::LangToggle; #[derive(Params, PartialEq, Clone, Debug)] @@ -31,12 +26,20 @@ pub fn ArtifactPage() -> impl IntoView { Lang::from_query(query.read().get("lang").as_deref()) }); - let (active, set_active) = signal::>(None); + let (anchor, set_anchor) = signal::>(None); + let (tab, set_tab) = signal(Tab::Note); + let (popover_pos, set_popover_pos) = signal::>(None); let notes_tick = RwSignal::new(0u32); let slug_sig: Signal = Signal::derive(move || slug.get()); let lang_sig: Signal = Signal::derive(move || lang.get()); + Effect::new(move |_| { + let _ = slug.get(); + set_anchor.set(None); + set_popover_pos.set(None); + }); + let data = Resource::new( move || (slug.get(), lang.get().as_str().to_string()), |(s, l)| fetch_artifact(s, l), @@ -61,68 +64,86 @@ pub fn ArtifactPage() -> impl IntoView { view! { - "…"

}> - {move || { - data.get().map(|res| { - let lang_val = lang.get(); - let home_href = format!("/?lang={}", lang_val.as_str()); - let label_back = match lang_val { - Lang::Ru => "все источники", - Lang::En => "all sources", - }; +
+ "…"

}> + {move || { + data.get().map(|res| { + let lang_val = lang.get(); + let home_href = format!("/?lang={}", lang_val.as_str()); + let label_back = match lang_val { + Lang::Ru => "все источники", + Lang::En => "all sources", + }; - match res { - Ok(Some(ArtifactPageData { artifact })) => view! { -
- + match res { + Ok(Some(ArtifactPageData { artifact })) => view! { +
+ -
+ on:click=move |ev| { + if popover_pos.get_untracked().is_some() { + return; + } + if let Some(a) = paragraph_from_event(&ev) { + set_anchor.set(Some(a)); + } + } + > - -
- }.into_any(), - Ok(None) => view! { -
-

"artifact not found"

- "← back" -
- }.into_any(), - Err(e) => view! { -
-

{format!("{e}")}

-
- }.into_any(), - } - }) - }} - + +
+ }.into_any(), + Ok(None) => view! { +
+

"artifact not found"

+ "← back" +
+ }.into_any(), + Err(e) => view! { +
+

{format!("{e}")}

+
+ }.into_any(), + } + }) + }} +
- +
+ + } } #[cfg(feature = "hydrate")] -fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option { +fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option { use wasm_bindgen::JsCast; let target = ev.target()?; let mut el = target.dyn_into::().ok()?; @@ -131,14 +152,77 @@ fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option { let id = el.get_attribute("data-para-id")?; let text = el.text_content().unwrap_or_default(); let excerpt = text.trim().to_string(); - return Some(ActivePara { id, excerpt }); + return Some(Anchor { + para_id: id, + excerpt, + is_selection: false, + }); } el = el.parent_element()?; } } #[cfg(not(feature = "hydrate"))] -fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option { +fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option { + None +} + +#[cfg(feature = "hydrate")] +fn handle_mouseup( + _ev: &leptos::ev::MouseEvent, + set_pos: &WriteSignal>, +) { + let Some(win) = web_sys::window() else { return }; + let Ok(Some(sel)) = win.get_selection() else { + set_pos.set(None); + return; + }; + if sel.is_collapsed() { + set_pos.set(None); + return; + } + let text = match sel.to_string().as_string() { + Some(s) => s.trim().to_string(), + None => { set_pos.set(None); return; } + }; + if text.len() < 2 { set_pos.set(None); return; } + let Ok(range) = sel.get_range_at(0) else { + set_pos.set(None); + return; + }; + let rect = range.get_bounding_client_rect(); + let start_node = range.start_container().ok(); + let para_id = start_node.as_ref().and_then(find_para_id_from_node); + let Some(para_id) = para_id else { + set_pos.set(None); + return; + }; + set_pos.set(Some(PopoverPos { + x: rect.left() + rect.width() / 2.0, + y: rect.top() - 8.0, + anchor: Anchor { para_id, excerpt: text, is_selection: true }, + })); +} + +#[cfg(not(feature = "hydrate"))] +fn handle_mouseup( + _ev: &leptos::ev::MouseEvent, + _set_pos: &WriteSignal>, +) {} + +#[cfg(feature = "hydrate")] +fn find_para_id_from_node(node: &web_sys::Node) -> Option { + use wasm_bindgen::JsCast; + let mut current: Option = node + .dyn_ref::() + .cloned() + .or_else(|| node.parent_element()); + while let Some(el) = current { + if el.has_attribute("data-para-id") { + return el.get_attribute("data-para-id"); + } + current = el.parent_element(); + } None } diff --git a/crates/mantra-ui/src/pages/inspector.rs b/crates/mantra-ui/src/pages/inspector.rs new file mode 100644 index 0000000..179eb41 --- /dev/null +++ b/crates/mantra-ui/src/pages/inspector.rs @@ -0,0 +1,485 @@ +//! The right-side inspector panel — persistent, not modal. +//! +//! v0.3 replacement for the margin drawer. Two tabs: Ask (ask Claude +//! about current anchor) and Notes (list + add notes for this source). +//! Driven by an `anchor` signal that tracks the reader's focus: +//! selected text (via mouseup → getSelection) OR a whole paragraph +//! (fallback for touch / narrow / no-selection cases). +//! +//! Also exports `SelectionPopover` — the small floating pill that +//! appears above a text selection with quick actions. + +use leptos::html; +use leptos::prelude::*; +use leptos::task::spawn_local; + +use crate::api::{ask_claude, fetch_notes, save_note, NoteEntry, NoteKind}; +use crate::corpus::Lang; + +/// Anchor — the "thing the inspector is currently about." Either a +/// selection (is_selection=true; excerpt is the selected text) or a +/// whole paragraph (is_selection=false; excerpt is the para's text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Anchor { + pub para_id: String, + pub excerpt: String, + pub is_selection: bool, +} + +/// Popover position + anchor it describes. +#[derive(Debug, Clone)] +pub struct PopoverPos { + pub x: f64, + pub y: f64, + pub anchor: Anchor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Note, + Ask, +} + +#[component] +pub fn Inspector( + slug: Signal, + lang: Signal, + anchor: ReadSignal>, + set_anchor: WriteSignal>, + tab: ReadSignal, + set_tab: WriteSignal, + /// Bumped when notes change, so the source page can re-annotate dots. + notes_tick: RwSignal, +) -> impl IntoView { + let (notes, set_notes) = signal::>(Vec::new()); + let (draft, set_draft) = signal(String::new()); + let (pending, set_pending) = signal(false); + let (error, set_error) = signal::>(None); + let (author, set_author) = signal(initial_author()); + + let textarea_ref: NodeRef = NodeRef::new(); + + let reload = move |slug_s: String| { + spawn_local(async move { + match fetch_notes(slug_s).await { + Ok(v) => { + set_notes.set(v); + notes_tick.update(|n| *n = n.wrapping_add(1)); + } + Err(e) => { + log(&format!("fetch_notes error: {e}")); + } + } + }); + }; + + // Reload when slug changes. + Effect::new(move |_| { + let s = slug.get(); + if !s.is_empty() { + reload(s); + } + }); + + // Reset draft when anchor changes. + Effect::new(move |_| { + let _ = anchor.get(); + set_draft.set(String::new()); + set_error.set(None); + clear_textarea(&textarea_ref); + }); + + // Notes filtered to current anchor's paragraph. + let filtered = Memo::new(move |_| { + let a = anchor.get(); + notes.with(|all| { + if let Some(a) = a { + all.iter().filter(|e| e.para_id == a.para_id).cloned().collect::>() + } else { + all.clone() + } + }) + }); + + let clear_anchor = move |_| set_anchor.set(None); + + let submit = move || { + let Some(a) = anchor.get_untracked() else { + log("submit: no anchor"); + return; + }; + let text = draft.get_untracked().trim().to_string(); + if text.is_empty() { + return; + } + let author_name = { + let a = author.get_untracked(); + if a.trim().is_empty() { "anon".to_string() } else { a } + }; + let slug_s = slug.get_untracked(); + let para_id = a.para_id.clone(); + let excerpt = a.excerpt.clone(); + let current_tab = tab.get_untracked(); + set_pending.set(true); + set_error.set(None); + spawn_local(async move { + let res = match current_tab { + Tab::Note => save_note( + slug_s.clone(), para_id, excerpt, text, author_name, + ).await.map(|_| ()), + Tab::Ask => ask_claude( + slug_s.clone(), para_id, excerpt, text, author_name, + ).await.map(|_| ()), + }; + set_pending.set(false); + match res { + Ok(()) => { + set_draft.set(String::new()); + clear_textarea(&textarea_ref); + reload(slug_s); + } + Err(e) => { + set_error.set(Some(format!("{e}"))); + } + } + }); + }; + + let label_note = move || match lang.get() { Lang::Ru => "заметка", Lang::En => "note" }; + let label_ask = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" }; + let placeholder_note = move || match lang.get() { + Lang::Ru => "запишите мысль…", + Lang::En => "write a thought…", + }; + let placeholder_ask = move || match lang.get() { + Lang::Ru => "спросите Клода о пассаже…", + Lang::En => "ask Claude about the passage…", + }; + let label_save = move || match lang.get() { Lang::Ru => "сохранить", Lang::En => "save" }; + let label_ask_go = move || match lang.get() { Lang::Ru => "спросить", Lang::En => "ask" }; + let label_pending_note = move || match lang.get() { + Lang::Ru => "сохраняю…", + Lang::En => "saving…", + }; + let label_pending_ask = move || match lang.get() { + Lang::Ru => "думаю…", + Lang::En => "thinking…", + }; + let label_you = move || match lang.get() { Lang::Ru => "вы:", Lang::En => "you:" }; + let label_selection_hint = move || match lang.get() { + Lang::Ru => "выделите фрагмент или кликните параграф", + Lang::En => "select a fragment or click a paragraph", + }; + let label_all_notes = move || match lang.get() { + Lang::Ru => "все заметки источника", + Lang::En => "all notes for this source", + }; + let label_clear = move || match lang.get() { + Lang::Ru => "убрать якорь", + Lang::En => "clear anchor", + }; + + view! { +