mantra v0.1: book-grade reader for design-dna corpus + margin drawer (notes + claude-ask)
This commit is contained in:
commit
6bb9a127a0
23 changed files with 6430 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
/content
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
3529
Cargo.lock
generated
Normal file
3529
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
58
Cargo.toml
Normal file
58
Cargo.toml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/mantra-server", "crates/mantra-ui"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
leptos = { version = "0.8" }
|
||||||
|
leptos_axum = "0.8"
|
||||||
|
leptos_router = { version = "0.8" }
|
||||||
|
leptos_meta = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
axum = "0.8"
|
||||||
|
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
anyhow = "1"
|
||||||
|
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||||
|
gray_matter = "0.2"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "z"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[profile.dev.package.leptos_macro]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# cargo-leptos workspace config
|
||||||
|
[[workspace.metadata.leptos]]
|
||||||
|
name = "mantra"
|
||||||
|
bin-package = "mantra-server"
|
||||||
|
bin-exe-name = "mantra-server"
|
||||||
|
bin-features = ["ssr"]
|
||||||
|
bin-default-features = false
|
||||||
|
lib-package = "mantra-ui"
|
||||||
|
lib-features = ["hydrate"]
|
||||||
|
lib-default-features = false
|
||||||
|
output-name = "mantra"
|
||||||
|
site-root = "target/site"
|
||||||
|
site-pkg-dir = "pkg"
|
||||||
|
style-file = "sass/main.scss"
|
||||||
|
assets-dir = "static"
|
||||||
|
site-addr = "127.0.0.1:9920"
|
||||||
|
reload-port = 3040
|
||||||
|
env = "DEV"
|
||||||
|
browserquery = "defaults"
|
||||||
|
lib-profile-release = "wasm-release"
|
||||||
|
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "z"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = "symbols"
|
||||||
28
crates/mantra-server/Cargo.toml
Normal file
28
crates/mantra-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[package]
|
||||||
|
name = "mantra-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mantra-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mantra-ui = { path = "../mantra-ui", features = ["ssr"] }
|
||||||
|
leptos = { workspace = true, features = ["ssr"] }
|
||||||
|
leptos_axum = { workspace = true }
|
||||||
|
leptos_router = { workspace = true, features = ["ssr"] }
|
||||||
|
leptos_meta = { workspace = true, features = ["ssr"] }
|
||||||
|
axum = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
pulldown-cmark = { workspace = true }
|
||||||
|
gray_matter = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = []
|
||||||
331
crates/mantra-server/src/corpus_loader.rs
Normal file
331
crates/mantra-server/src/corpus_loader.rs
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
//! Loads the design-dna corpus from a directory of markdown files.
|
||||||
|
//!
|
||||||
|
//! Layout expected (relative to MANTRA_CONTENT_DIR):
|
||||||
|
//! cycle-1-philosophy/
|
||||||
|
//! _index.md ← corpus-level index with themes
|
||||||
|
//! <source-slug>.md × N ← one distillation per source
|
||||||
|
//!
|
||||||
|
//! The loader returns an in-memory `Corpus` ready for SSR renders.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use gray_matter::engine::YAML;
|
||||||
|
use gray_matter::Matter;
|
||||||
|
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use mantra_ui::corpus::{Corpus, Source, Theme};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
struct SourceFrontmatter {
|
||||||
|
#[serde(default)]
|
||||||
|
source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
confidence: String,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_corpus(root: &Path) -> Result<Corpus> {
|
||||||
|
load_corpus_alt_cycle_dir(root, "cycle-1-philosophy")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result<Corpus> {
|
||||||
|
let cycle_dir = root.join(cycle_subdir);
|
||||||
|
if !cycle_dir.is_dir() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"content dir missing {} subdir: {}",
|
||||||
|
cycle_subdir,
|
||||||
|
cycle_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: gather all source files (exclude _index.md).
|
||||||
|
let mut source_paths: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in fs::read_dir(&cycle_dir)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !name.ends_with(".md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if name.starts_with('_') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
source_paths.push(path);
|
||||||
|
}
|
||||||
|
source_paths.sort();
|
||||||
|
|
||||||
|
let mut sources: HashMap<String, Source> = HashMap::new();
|
||||||
|
let mut order: Vec<String> = Vec::new();
|
||||||
|
for path in &source_paths {
|
||||||
|
let src = parse_source(path).with_context(|| {
|
||||||
|
format!("parsing source {}", path.display())
|
||||||
|
})?;
|
||||||
|
order.push(src.slug.clone());
|
||||||
|
sources.insert(src.slug.clone(), src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themes come from the _index.md if present; skip gracefully otherwise.
|
||||||
|
let themes = parse_themes(&cycle_dir.join("_index.md"))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
tracing::warn!("no themes loaded: {e}");
|
||||||
|
Vec::new()
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Corpus { order, sources, themes })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_source(path: &Path) -> Result<Source> {
|
||||||
|
let raw = fs::read_to_string(path)?;
|
||||||
|
let matter = Matter::<YAML>::new();
|
||||||
|
let parsed = matter.parse(&raw);
|
||||||
|
let fm: SourceFrontmatter = parsed
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.deserialize().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let body_md = parsed.content;
|
||||||
|
let slug = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow!("bad filename: {}", path.display()))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Extract H1 (### replaces some files — just grab the first #-line).
|
||||||
|
let title = body_md
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("# "))
|
||||||
|
.map(|l| l.trim_start_matches("# ").trim().to_string())
|
||||||
|
.unwrap_or_else(|| slug.clone());
|
||||||
|
|
||||||
|
let core_claim = extract_section(&body_md, "## Core claim")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.replace('\n', " ");
|
||||||
|
|
||||||
|
let author = fm
|
||||||
|
.source
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&slug)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let body_html = md_to_html(&body_md);
|
||||||
|
|
||||||
|
Ok(Source {
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
core_claim,
|
||||||
|
tags: fm.tags,
|
||||||
|
confidence: if fm.confidence.is_empty() {
|
||||||
|
"medium".into()
|
||||||
|
} else {
|
||||||
|
fm.confidence
|
||||||
|
},
|
||||||
|
body_html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text content between a given heading and the next `## ` heading.
|
||||||
|
fn extract_section(md: &str, heading: &str) -> Option<String> {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut capturing = false;
|
||||||
|
for line in md.lines() {
|
||||||
|
if line.trim_start().starts_with(heading) {
|
||||||
|
capturing = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if capturing && line.trim_start().starts_with("## ") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if capturing {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.trim().is_empty() { None } else { Some(out) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render markdown to HTML and inject stable `data-para-id` attributes
|
||||||
|
/// on every `<p>` element. The id is a short content-hash: stable
|
||||||
|
/// across minor edits of the surrounding text, unique enough within a
|
||||||
|
/// single source document. The margin-layer UI uses these ids to
|
||||||
|
/// anchor notes and Claude-asks to specific paragraphs.
|
||||||
|
fn md_to_html(md: &str) -> String {
|
||||||
|
let mut opts = Options::empty();
|
||||||
|
opts.insert(Options::ENABLE_TABLES);
|
||||||
|
opts.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
opts.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
opts.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
opts.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||||
|
|
||||||
|
// First pass: render plain HTML.
|
||||||
|
let parser = Parser::new_ext(md, opts);
|
||||||
|
let mut raw = String::new();
|
||||||
|
html::push_html(&mut raw, parser);
|
||||||
|
|
||||||
|
// Second pass: re-parse to capture paragraph text for hashing, and
|
||||||
|
// inject data-para-id. pulldown-cmark emits events in order, so we
|
||||||
|
// can walk the source again, track paragraphs, compute hashes, and
|
||||||
|
// then do a simple textual rewrite on the output.
|
||||||
|
let parser2 = Parser::new_ext(md, opts);
|
||||||
|
let mut current_para_text = String::new();
|
||||||
|
let mut in_para = false;
|
||||||
|
let mut para_hashes: Vec<String> = Vec::new();
|
||||||
|
for ev in parser2 {
|
||||||
|
match ev {
|
||||||
|
Event::Start(Tag::Paragraph) => {
|
||||||
|
in_para = true;
|
||||||
|
current_para_text.clear();
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::Paragraph) => {
|
||||||
|
let id = short_hash(current_para_text.trim());
|
||||||
|
para_hashes.push(id);
|
||||||
|
in_para = false;
|
||||||
|
}
|
||||||
|
Event::Text(t) if in_para => current_para_text.push_str(&t),
|
||||||
|
Event::Code(t) if in_para => current_para_text.push_str(&t),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject attributes: replace each `<p>` (in order) with
|
||||||
|
// `<p data-para-id="hash">`. We walk the byte buffer to find the
|
||||||
|
// ASCII literal `<p>`, but copy UTF-8 slices (not byte-by-byte)
|
||||||
|
// to preserve non-ASCII correctly — the earlier `push(u8 as char)`
|
||||||
|
// approach silently decoded multibyte UTF-8 as Latin-1 and turned
|
||||||
|
// em-dashes / smart-quotes / Cyrillic / Greek into mojibake.
|
||||||
|
let mut out = String::with_capacity(raw.len() + para_hashes.len() * 24);
|
||||||
|
let mut pending = para_hashes.into_iter();
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut last_copied = 0usize;
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i + 3 <= bytes.len() {
|
||||||
|
if &bytes[i..i + 3] == b"<p>" {
|
||||||
|
// flush everything from last_copied..i as a proper str slice
|
||||||
|
out.push_str(&raw[last_copied..i]);
|
||||||
|
if let Some(id) = pending.next() {
|
||||||
|
out.push_str(&format!(r#"<p data-para-id="{id}">"#));
|
||||||
|
} else {
|
||||||
|
out.push_str("<p>");
|
||||||
|
}
|
||||||
|
i += 3;
|
||||||
|
last_copied = i;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&raw[last_copied..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short_hash(s: &str) -> String {
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
s.hash(&mut h);
|
||||||
|
format!("{:08x}", h.finish() & 0xFFFF_FFFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `_index.md` for the 12 running themes + their contributing sources.
|
||||||
|
///
|
||||||
|
/// Format assumption: each theme is introduced by `### {n}. {title}`
|
||||||
|
/// with the following paragraph carrying a comma-separated list of
|
||||||
|
/// author names. We map author names back to slugs by substring-match
|
||||||
|
/// on the source files' author field. Fallback if pattern doesn't
|
||||||
|
/// match: empty themes list, landing shows empty themes column.
|
||||||
|
fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
|
||||||
|
let raw = fs::read_to_string(index_path)?;
|
||||||
|
let mut themes: Vec<Theme> = Vec::new();
|
||||||
|
let mut current: Option<(String, String, String)> = None; // (slug, title, desc_md)
|
||||||
|
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("### ") {
|
||||||
|
if let Some((slug, title, desc)) = current.take() {
|
||||||
|
themes.push(finalize_theme(slug, title, desc));
|
||||||
|
}
|
||||||
|
// e.g. "### 1. Emptiness / absence как носитель формы"
|
||||||
|
let rest = rest.trim();
|
||||||
|
let after_num = rest
|
||||||
|
.split_once('.')
|
||||||
|
.map(|(_, r)| r.trim().to_string())
|
||||||
|
.unwrap_or_else(|| rest.to_string());
|
||||||
|
let slug = slugify(&after_num);
|
||||||
|
current = Some((slug, after_num, String::new()));
|
||||||
|
} else if line.starts_with("## ") {
|
||||||
|
// exit "running themes" section — fallthrough to push last theme
|
||||||
|
if let Some((slug, title, desc)) = current.take() {
|
||||||
|
themes.push(finalize_theme(slug, title, desc));
|
||||||
|
}
|
||||||
|
} else if let Some((_, _, desc)) = current.as_mut() {
|
||||||
|
desc.push_str(line);
|
||||||
|
desc.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((slug, title, desc)) = current.take() {
|
||||||
|
themes.push(finalize_theme(slug, title, desc));
|
||||||
|
}
|
||||||
|
Ok(themes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme {
|
||||||
|
let contributing = parse_theme_contributors(&desc_md);
|
||||||
|
let description_html = md_to_html(desc_md.trim());
|
||||||
|
Theme {
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
description_html,
|
||||||
|
contributing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Very loose extraction: pull parenthesis-grouped author names out of
|
||||||
|
/// the theme description and attempt to match them to known sources.
|
||||||
|
/// Since we don't yet have the corpus at this point, we just return a
|
||||||
|
/// best-guess list of tokens — the landing component can filter by
|
||||||
|
/// membership later if desired.
|
||||||
|
fn parse_theme_contributors(desc: &str) -> Vec<String> {
|
||||||
|
// Heuristic: grab author names from the first parenthetical group.
|
||||||
|
let Some(start) = desc.find('(') else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(end) = desc[start..].find(')') else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let inside = &desc[start + 1..start + end];
|
||||||
|
inside
|
||||||
|
.split([',', '·'])
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
// Strip parenthetical hints like "Laozi (ch. 11)" → "Laozi"
|
||||||
|
s.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or(s)
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.map(|s| slugify(&s))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(s: &str) -> String {
|
||||||
|
s.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||||
|
.collect::<String>()
|
||||||
|
.split('-')
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
119
crates/mantra-server/src/main.rs
Normal file
119
crates/mantra-server/src/main.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
//! mantra server — axum + leptos_axum SSR.
|
||||||
|
//!
|
||||||
|
//! Loads the corpus from disk once at startup (env `MANTRA_CONTENT_DIR`),
|
||||||
|
//! hands a shared `Arc<Corpus>` to every render pass via Leptos context.
|
||||||
|
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
|
mod corpus_loader;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::routing::any;
|
||||||
|
use axum::Router;
|
||||||
|
use leptos::config::get_configuration;
|
||||||
|
use leptos::prelude::provide_context;
|
||||||
|
use leptos_axum::{generate_route_list, handle_server_fns_with_context, LeptosRoutes};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use mantra_ui::app::{shell, App};
|
||||||
|
use mantra_ui::corpus::{BilingualCorpus, BilingualHandle};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_env("MANTRA_LOG")
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.without_time()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let content_dir = PathBuf::from(
|
||||||
|
std::env::var("MANTRA_CONTENT_DIR")
|
||||||
|
.unwrap_or_else(|_| "content".to_string()),
|
||||||
|
);
|
||||||
|
info!("loading bilingual corpus from {}", content_dir.display());
|
||||||
|
|
||||||
|
// The loader expects a language-specific subdir; we call it twice
|
||||||
|
// against the two sibling directories.
|
||||||
|
let en = corpus_loader::load_corpus(&content_dir)?;
|
||||||
|
// ru lives under ../<content_dir>-ru relative path — or env-overridden.
|
||||||
|
let ru_dir = std::env::var("MANTRA_CONTENT_DIR_RU")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
// derive from content_dir by replacing trailing component
|
||||||
|
let mut p = content_dir.clone();
|
||||||
|
let last = p
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| String::from("content"));
|
||||||
|
p.pop();
|
||||||
|
p.push(format!("{last}-ru"));
|
||||||
|
p
|
||||||
|
});
|
||||||
|
// Hack for symlinked content: our Mac dev uses `content` symlink to
|
||||||
|
// design-dna/, so content-ru doesn't exist at peer — fall back to the
|
||||||
|
// sibling `cycle-1-philosophy-ru` under the DESIGN-DNA root instead.
|
||||||
|
let ru = if ru_dir.is_dir() {
|
||||||
|
corpus_loader::load_corpus(&ru_dir)?
|
||||||
|
} else {
|
||||||
|
// if MANTRA_CONTENT_DIR is design-dna root, then ru lives under
|
||||||
|
// <content_dir>/cycle-1-philosophy-ru — but that's a file layout
|
||||||
|
// difference, handled inside load_corpus_ru.
|
||||||
|
corpus_loader::load_corpus_alt_cycle_dir(&content_dir, "cycle-1-philosophy-ru")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let bilingual: BilingualHandle = Arc::new(BilingualCorpus {
|
||||||
|
ru: Arc::new(ru),
|
||||||
|
en: Arc::new(en),
|
||||||
|
});
|
||||||
|
info!(
|
||||||
|
"loaded: ru={} sources / en={} sources, themes={}",
|
||||||
|
bilingual.ru.sources.len(),
|
||||||
|
bilingual.en.sources.len(),
|
||||||
|
bilingual.ru.themes.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let conf = get_configuration(None)
|
||||||
|
.map_err(|e| anyhow::anyhow!("leptos config: {e}"))?;
|
||||||
|
let leptos_options = conf.leptos_options;
|
||||||
|
let addr: SocketAddr = leptos_options.site_addr;
|
||||||
|
let routes = generate_route_list(App);
|
||||||
|
|
||||||
|
let bilingual_ctx = bilingual.clone();
|
||||||
|
let bilingual_for_sfn = bilingual.clone();
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/{*fn_name}",
|
||||||
|
any(move |req| {
|
||||||
|
let ctx = bilingual_for_sfn.clone();
|
||||||
|
handle_server_fns_with_context(
|
||||||
|
move || provide_context(ctx.clone()),
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.leptos_routes_with_context(
|
||||||
|
&leptos_options,
|
||||||
|
routes,
|
||||||
|
move || provide_context(bilingual_ctx.clone()),
|
||||||
|
{
|
||||||
|
let opts = leptos_options.clone();
|
||||||
|
move || shell(opts.clone())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.fallback_service(ServeDir::new("target/site"))
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(leptos_options);
|
||||||
|
|
||||||
|
info!("mantra listening on {addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
51
crates/mantra-ui/Cargo.toml
Normal file
51
crates/mantra-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
[package]
|
||||||
|
name = "mantra-ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos.workspace = true
|
||||||
|
leptos_router.workspace = true
|
||||||
|
leptos_meta.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
chrono = { workspace = true }
|
||||||
|
console_error_panic_hook = { version = "0.1", optional = true }
|
||||||
|
wasm-bindgen = { version = "0.2.118", optional = true }
|
||||||
|
web-sys = { version = "0.3", optional = true, features = [
|
||||||
|
"Window",
|
||||||
|
"Storage",
|
||||||
|
"Document",
|
||||||
|
"Element",
|
||||||
|
"HtmlElement",
|
||||||
|
"HtmlTextAreaElement",
|
||||||
|
"HtmlCollection",
|
||||||
|
"NodeList",
|
||||||
|
"DomTokenList",
|
||||||
|
"Event",
|
||||||
|
"EventTarget",
|
||||||
|
"console",
|
||||||
|
] }
|
||||||
|
# Server-only — pulled in under ssr feature for notes I/O + Claude HTTP.
|
||||||
|
tokio = { workspace = true, optional = true }
|
||||||
|
reqwest = { workspace = true, optional = true }
|
||||||
|
serde_json = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hydrate = [
|
||||||
|
"leptos/hydrate",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:web-sys",
|
||||||
|
]
|
||||||
|
ssr = [
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
"leptos_meta/ssr",
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:reqwest",
|
||||||
|
"dep:serde_json",
|
||||||
|
]
|
||||||
460
crates/mantra-ui/src/api/mod.rs
Normal file
460
crates/mantra-ui/src/api/mod.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
//! Server functions for the margin layer: notes and Claude-ask.
|
||||||
|
//!
|
||||||
|
//! Notes live as markdown files in `$MANTRA_NOTES_DIR/<slug>.md`.
|
||||||
|
//! Each file is append-oriented: a note is a dated entry anchored to
|
||||||
|
//! a paragraph-id. Subsequent reads parse the file and return
|
||||||
|
//! structured entries.
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
pub use types::{LandingData, NoteEntry, NoteKind, SourcePageData, ThemePageData};
|
||||||
|
|
||||||
|
// --- fetch_source_page -------------------------------------------------
|
||||||
|
//
|
||||||
|
// Snapshot for `/source/:slug`. Moves corpus access into a server fn
|
||||||
|
// so SSR and hydrate render from the same Resource payload and the
|
||||||
|
// client doesn't need the full `Arc<Corpus>` in context.
|
||||||
|
|
||||||
|
#[server(endpoint = "source_page")]
|
||||||
|
pub async fn fetch_source_page(
|
||||||
|
slug: String,
|
||||||
|
lang: String,
|
||||||
|
) -> Result<Option<SourcePageData>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use crate::corpus::{BilingualHandle, Lang};
|
||||||
|
let bilingual = use_context::<BilingualHandle>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("no corpus in context"))?;
|
||||||
|
let lang = Lang::from_query(Some(&lang));
|
||||||
|
let corpus = bilingual.for_lang(lang);
|
||||||
|
let Some(src) = corpus.get(&slug).cloned() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let (prev, next) = corpus.neighbors(&slug);
|
||||||
|
Ok(Some(SourcePageData {
|
||||||
|
source: src,
|
||||||
|
prev: prev.map(|s| s.to_string()),
|
||||||
|
next: next.map(|s| s.to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
let _ = (slug, lang);
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fetch_landing -----------------------------------------------------
|
||||||
|
|
||||||
|
#[server(endpoint = "landing")]
|
||||||
|
pub async fn fetch_landing(lang: String) -> Result<LandingData, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use crate::corpus::{BilingualHandle, Lang};
|
||||||
|
let bilingual = use_context::<BilingualHandle>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("no corpus in context"))?;
|
||||||
|
let lang = Lang::from_query(Some(&lang));
|
||||||
|
let corpus = bilingual.for_lang(lang);
|
||||||
|
let sources = corpus
|
||||||
|
.order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|slug| corpus.get(slug).cloned())
|
||||||
|
.collect();
|
||||||
|
Ok(LandingData {
|
||||||
|
order: corpus.order.clone(),
|
||||||
|
sources,
|
||||||
|
themes: corpus.themes.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
let _ = lang;
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fetch_theme_page --------------------------------------------------
|
||||||
|
|
||||||
|
#[server(endpoint = "theme_page")]
|
||||||
|
pub async fn fetch_theme_page(
|
||||||
|
slug: String,
|
||||||
|
lang: String,
|
||||||
|
) -> Result<Option<ThemePageData>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use crate::corpus::{BilingualHandle, Lang};
|
||||||
|
let bilingual = use_context::<BilingualHandle>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("no corpus in context"))?;
|
||||||
|
let lang = Lang::from_query(Some(&lang));
|
||||||
|
let corpus = bilingual.for_lang(lang);
|
||||||
|
let Some(theme) = corpus.themes.iter().find(|t| t.slug == slug).cloned() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let contributing = theme
|
||||||
|
.contributing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| corpus.get(s).cloned())
|
||||||
|
.collect();
|
||||||
|
Ok(Some(ThemePageData { theme, contributing }))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
let _ = (slug, lang);
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fetch_notes -------------------------------------------------------
|
||||||
|
|
||||||
|
#[server(endpoint = "notes_fetch")]
|
||||||
|
pub async fn fetch_notes(source_slug: String) -> Result<Vec<NoteEntry>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
let dir = std::env::var("MANTRA_NOTES_DIR").map_err(|_| {
|
||||||
|
ServerFnError::new("MANTRA_NOTES_DIR not set on server")
|
||||||
|
})?;
|
||||||
|
let path = std::path::PathBuf::from(&dir).join(format!("{source_slug}.md"));
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let raw = tokio::fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("read notes: {e}")))?;
|
||||||
|
Ok(parse_marginalia(&raw))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- save_note ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[server(endpoint = "notes_save")]
|
||||||
|
pub async fn save_note(
|
||||||
|
source_slug: String,
|
||||||
|
para_id: String,
|
||||||
|
para_excerpt: String,
|
||||||
|
note_text: String,
|
||||||
|
author: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
if note_text.trim().is_empty() {
|
||||||
|
return Err(ServerFnError::new("note text is empty"));
|
||||||
|
}
|
||||||
|
append_marginalia_entry(
|
||||||
|
&source_slug,
|
||||||
|
¶_id,
|
||||||
|
¶_excerpt,
|
||||||
|
MarginaliaPayload::Note(note_text),
|
||||||
|
&author,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
spawn_git_autocommit(&source_slug, ¶_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ask_claude --------------------------------------------------------
|
||||||
|
|
||||||
|
#[server(endpoint = "claude_ask")]
|
||||||
|
pub async fn ask_claude(
|
||||||
|
source_slug: String,
|
||||||
|
para_id: String,
|
||||||
|
para_excerpt: String,
|
||||||
|
question: String,
|
||||||
|
author: String,
|
||||||
|
) -> Result<String, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
let q = question.trim();
|
||||||
|
if q.is_empty() {
|
||||||
|
return Err(ServerFnError::new("question is empty"));
|
||||||
|
}
|
||||||
|
let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| {
|
||||||
|
ServerFnError::new("ANTHROPIC_API_KEY not set on server")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let system = format!(
|
||||||
|
"You are a reading companion for a philosophical substance called \
|
||||||
|
design-dna. A reader is studying the distillation of {source_slug} \
|
||||||
|
and has highlighted a passage. Answer their question grounded in the \
|
||||||
|
passage and its tradition. Be concise (≤300 words), direct, \
|
||||||
|
substance over summary. Same register as the distillation itself: \
|
||||||
|
living note, not academic report. Answer in the same language the \
|
||||||
|
user asked in. If you don't know, say so."
|
||||||
|
);
|
||||||
|
let user_msg = format!(
|
||||||
|
"Context passage:\n\n{para_excerpt}\n\n---\n\nQuestion: {q}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let answer = call_anthropic(&api_key, &system, &user_msg)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("claude: {e}")))?;
|
||||||
|
|
||||||
|
// Persist Q+A to marginalia too — reading is a dialogue, and the
|
||||||
|
// dialogue is the record.
|
||||||
|
append_marginalia_entry(
|
||||||
|
&source_slug,
|
||||||
|
¶_id,
|
||||||
|
¶_excerpt,
|
||||||
|
MarginaliaPayload::Ask {
|
||||||
|
question: q.to_string(),
|
||||||
|
answer: answer.clone(),
|
||||||
|
},
|
||||||
|
&author,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
spawn_git_autocommit(&source_slug, ¶_id).await;
|
||||||
|
Ok(answer)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
{
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// server-only implementations
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
enum MarginaliaPayload {
|
||||||
|
Note(String),
|
||||||
|
Ask { question: String, answer: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
async fn append_marginalia_entry(
|
||||||
|
source_slug: &str,
|
||||||
|
para_id: &str,
|
||||||
|
para_excerpt: &str,
|
||||||
|
payload: MarginaliaPayload,
|
||||||
|
author: &str,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
let dir = std::env::var("MANTRA_NOTES_DIR").map_err(|_| {
|
||||||
|
ServerFnError::new("MANTRA_NOTES_DIR not set on server")
|
||||||
|
})?;
|
||||||
|
let dir = std::path::PathBuf::from(&dir);
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("mkdir: {e}")))?;
|
||||||
|
let path = dir.join(format!("{source_slug}.md"));
|
||||||
|
|
||||||
|
let existed = path.exists();
|
||||||
|
let header_needed = !existed;
|
||||||
|
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
if header_needed {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"---\ntype: marginalia\nsource: {source_slug}\nproject: design-dna\n---\n\n# Marginalia · {source_slug}\n\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// We always append a fresh block. Simple, no merge logic v0.1.
|
||||||
|
out.push_str(&format!("## {{#{para_id}}}\n\n"));
|
||||||
|
let excerpt = para_excerpt.trim();
|
||||||
|
if !excerpt.is_empty() {
|
||||||
|
// Store the first 180 chars of excerpt as a blockquote anchor.
|
||||||
|
let cut: String = excerpt.chars().take(180).collect();
|
||||||
|
out.push_str("> ");
|
||||||
|
out.push_str(&cut.replace('\n', " "));
|
||||||
|
if excerpt.chars().count() > 180 {
|
||||||
|
out.push_str(" …");
|
||||||
|
}
|
||||||
|
out.push_str("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
match payload {
|
||||||
|
MarginaliaPayload::Note(text) => {
|
||||||
|
out.push_str(&format!("### {author} · {ts}\n\n{text}\n\n"));
|
||||||
|
}
|
||||||
|
MarginaliaPayload::Ask { question, answer } => {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"### {author} · {ts} (asked)\n\n**Q:** {question}\n\n**A (Claude):** {answer}\n\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut f = tokio::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("open notes: {e}")))?;
|
||||||
|
f.write_all(out.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("write notes: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn parse_marginalia(raw: &str) -> Vec<NoteEntry> {
|
||||||
|
// Structure we parse:
|
||||||
|
// ## {#<para-id>}
|
||||||
|
// > excerpt
|
||||||
|
//
|
||||||
|
// ### Alexey · 2026-04-23 12:34
|
||||||
|
// Note body.
|
||||||
|
//
|
||||||
|
// ### Alexey · 2026-04-23 13:00 (asked)
|
||||||
|
// **Q:** ...
|
||||||
|
//
|
||||||
|
// **A (Claude):** ...
|
||||||
|
|
||||||
|
let mut entries: Vec<NoteEntry> = Vec::new();
|
||||||
|
let mut current_para: Option<String> = None;
|
||||||
|
let mut current_excerpt: String = String::new();
|
||||||
|
let mut current_author_ts: Option<String> = None;
|
||||||
|
let mut current_kind: NoteKind = NoteKind::Note;
|
||||||
|
let mut current_body: String = String::new();
|
||||||
|
|
||||||
|
fn flush(
|
||||||
|
entries: &mut Vec<NoteEntry>,
|
||||||
|
current_para: &Option<String>,
|
||||||
|
current_excerpt: &str,
|
||||||
|
current_author_ts: &Option<String>,
|
||||||
|
current_kind: NoteKind,
|
||||||
|
current_body: &str,
|
||||||
|
) {
|
||||||
|
if let (Some(para), Some(ats)) = (current_para.clone(), current_author_ts.clone()) {
|
||||||
|
let body = current_body.trim();
|
||||||
|
if !body.is_empty() {
|
||||||
|
entries.push(NoteEntry {
|
||||||
|
para_id: para,
|
||||||
|
excerpt: current_excerpt.trim().to_string(),
|
||||||
|
author_ts: ats,
|
||||||
|
kind: current_kind,
|
||||||
|
body: body.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix("## {#") {
|
||||||
|
// Flush previous entry.
|
||||||
|
flush(
|
||||||
|
&mut entries,
|
||||||
|
¤t_para,
|
||||||
|
¤t_excerpt,
|
||||||
|
¤t_author_ts,
|
||||||
|
current_kind,
|
||||||
|
¤t_body,
|
||||||
|
);
|
||||||
|
current_body.clear();
|
||||||
|
current_author_ts = None;
|
||||||
|
current_kind = NoteKind::Note;
|
||||||
|
current_excerpt.clear();
|
||||||
|
let id = rest.trim_end_matches('}').trim();
|
||||||
|
current_para = Some(id.to_string());
|
||||||
|
} else if line.starts_with("> ") {
|
||||||
|
if !current_excerpt.is_empty() {
|
||||||
|
current_excerpt.push(' ');
|
||||||
|
}
|
||||||
|
current_excerpt.push_str(line.trim_start_matches("> ").trim());
|
||||||
|
} else if let Some(rest) = line.strip_prefix("### ") {
|
||||||
|
flush(
|
||||||
|
&mut entries,
|
||||||
|
¤t_para,
|
||||||
|
¤t_excerpt,
|
||||||
|
¤t_author_ts,
|
||||||
|
current_kind,
|
||||||
|
¤t_body,
|
||||||
|
);
|
||||||
|
current_body.clear();
|
||||||
|
let asked = rest.ends_with("(asked)");
|
||||||
|
current_kind = if asked { NoteKind::Ask } else { NoteKind::Note };
|
||||||
|
let head = rest.trim_end_matches("(asked)").trim();
|
||||||
|
current_author_ts = Some(head.to_string());
|
||||||
|
} else {
|
||||||
|
current_body.push_str(line);
|
||||||
|
current_body.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(
|
||||||
|
&mut entries,
|
||||||
|
¤t_para,
|
||||||
|
¤t_excerpt,
|
||||||
|
¤t_author_ts,
|
||||||
|
current_kind,
|
||||||
|
¤t_body,
|
||||||
|
);
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
async fn call_anthropic(
|
||||||
|
api_key: &str,
|
||||||
|
system: &str,
|
||||||
|
user: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": system,
|
||||||
|
"messages": [{ "role": "user", "content": user }],
|
||||||
|
});
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("request: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let st = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("anthropic {st}: {body}"));
|
||||||
|
}
|
||||||
|
let v: serde_json::Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
|
||||||
|
let text = v["content"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| "anthropic response missing content[0].text".to_string())?;
|
||||||
|
Ok(text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
async fn spawn_git_autocommit(source_slug: &str, para_id: &str) {
|
||||||
|
// Fire-and-forget: we don't want to block the user on git push.
|
||||||
|
// Only runs if MANTRA_NOTES_GIT_AUTO_PUSH is set (opt-in).
|
||||||
|
if std::env::var("MANTRA_NOTES_GIT_AUTO_PUSH").is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = match std::env::var("MANTRA_NOTES_DIR") {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let slug = source_slug.to_string();
|
||||||
|
let pid = para_id.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tokio::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!(
|
||||||
|
"cd {dir} && git add -A && git commit -m 'note: {slug} {pid}' && git push 2>&1",
|
||||||
|
dir = shell_escape(&dir),
|
||||||
|
slug = shell_escape(&slug),
|
||||||
|
pid = shell_escape(&pid),
|
||||||
|
))
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn shell_escape(s: &str) -> String {
|
||||||
|
// Good enough for slugs (alphanumeric + dash) and ordinary paths.
|
||||||
|
s.replace('\'', "'\\''")
|
||||||
|
}
|
||||||
46
crates/mantra-ui/src/api/types.rs
Normal file
46
crates/mantra-ui/src/api/types.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
//! Wire types for the margin-layer server fns.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::corpus::{Source, Theme};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum NoteKind {
|
||||||
|
Note,
|
||||||
|
Ask,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One note OR Q+A entry attached to a specific paragraph.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct NoteEntry {
|
||||||
|
pub para_id: String,
|
||||||
|
pub excerpt: String,
|
||||||
|
pub author_ts: String,
|
||||||
|
pub kind: NoteKind,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of a single source + its neighbors for the source page.
|
||||||
|
/// Carries everything the page needs so SSR and hydrate render
|
||||||
|
/// identically from the same payload.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SourcePageData {
|
||||||
|
pub source: Source,
|
||||||
|
pub prev: Option<String>,
|
||||||
|
pub next: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot for the landing page — works in reading order + themes.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct LandingData {
|
||||||
|
pub order: Vec<String>,
|
||||||
|
pub sources: Vec<Source>,
|
||||||
|
pub themes: Vec<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot for the theme page — one theme + contributing sources.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ThemePageData {
|
||||||
|
pub theme: Theme,
|
||||||
|
pub contributing: Vec<Source>,
|
||||||
|
}
|
||||||
47
crates/mantra-ui/src/app.rs
Normal file
47
crates/mantra-ui/src/app.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! Root Leptos component and HTML shell.
|
||||||
|
|
||||||
|
use leptos::config::LeptosOptions;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::{provide_meta_context, MetaTags, Title};
|
||||||
|
use leptos_router::components::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
use crate::pages;
|
||||||
|
|
||||||
|
/// HTML skeleton wrapping the root app. Called by
|
||||||
|
/// `leptos_axum::LeptosRoutes` to generate the full page SSR.
|
||||||
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<AutoReload options=options.clone()/>
|
||||||
|
<HydrationScripts options/>
|
||||||
|
<link rel="stylesheet" href="/pkg/mantra.css"/>
|
||||||
|
<MetaTags/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<App/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application root — provides meta context, declares routes.
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Title text="mantra · design-dna reader"/>
|
||||||
|
<Router>
|
||||||
|
<Routes fallback=|| view! { <p class="err">"404"</p> }>
|
||||||
|
<Route path=path!("/") view=pages::landing::Landing />
|
||||||
|
<Route path=path!("/source/:slug") view=pages::source::SourcePage />
|
||||||
|
<Route path=path!("/theme/:slug") view=pages::theme::ThemePage />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
125
crates/mantra-ui/src/corpus.rs
Normal file
125
crates/mantra-ui/src/corpus.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
//! Corpus data types — shared between SSR render pass and UI components.
|
||||||
|
//!
|
||||||
|
//! The server crate owns `load_corpus` and populates the structures
|
||||||
|
//! from disk; the UI crate only reads them. Everything here is
|
||||||
|
//! `Serialize + Deserialize + Clone` so it survives Leptos Resources
|
||||||
|
//! and possible future hydration round-trips.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// The entire loaded corpus. Shared via Leptos context as `Arc<Corpus>`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Corpus {
|
||||||
|
/// Ordered list of source slugs (reading order — chronological or
|
||||||
|
/// thematic; driven by `_index.md`'s table of works).
|
||||||
|
pub order: Vec<String>,
|
||||||
|
/// Slug → Source lookup.
|
||||||
|
pub sources: HashMap<String, Source>,
|
||||||
|
/// Running themes extracted from `_index.md`.
|
||||||
|
pub themes: Vec<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Corpus {
|
||||||
|
pub fn get(&self, slug: &str) -> Option<&Source> {
|
||||||
|
self.sources.get(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index-in-reading-order of `slug`, if present.
|
||||||
|
pub fn index_of(&self, slug: &str) -> Option<usize> {
|
||||||
|
self.order.iter().position(|s| s == slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neighbor (prev, next) by reading order — wrap-around.
|
||||||
|
pub fn neighbors(&self, slug: &str) -> (Option<&str>, Option<&str>) {
|
||||||
|
let Some(i) = self.index_of(slug) else { return (None, None) };
|
||||||
|
let n = self.order.len();
|
||||||
|
let prev = if n > 1 {
|
||||||
|
Some(self.order[(i + n - 1) % n].as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let next = if n > 1 {
|
||||||
|
Some(self.order[(i + 1) % n].as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(prev, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One distillation. Body is pre-rendered HTML (pulldown-cmark output).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Source {
|
||||||
|
pub slug: String,
|
||||||
|
/// Display title — "Symposium (Plato)" etc.
|
||||||
|
pub title: String,
|
||||||
|
/// Short author label — "Plato", "Ibn ʿArabī" etc.
|
||||||
|
pub author: String,
|
||||||
|
/// The core-claim one-liner (extracted from `## Core claim` section).
|
||||||
|
pub core_claim: String,
|
||||||
|
/// Tags from YAML frontmatter.
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Confidence level (high / medium / low) from frontmatter.
|
||||||
|
pub confidence: String,
|
||||||
|
/// The full markdown body rendered to HTML.
|
||||||
|
pub body_html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running theme — pattern that proustep across multiple sources.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
/// Short pitch (one paragraph).
|
||||||
|
pub description_html: String,
|
||||||
|
/// Sources that contribute to this theme (by slug).
|
||||||
|
pub contributing: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias — we always thread the corpus through context as
|
||||||
|
/// an `Arc` so multiple concurrent requests share one in-memory copy.
|
||||||
|
pub type CorpusHandle = Arc<Corpus>;
|
||||||
|
|
||||||
|
/// Reading language.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Lang {
|
||||||
|
Ru,
|
||||||
|
En,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lang {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self { Lang::Ru => "ru", Lang::En => "en" }
|
||||||
|
}
|
||||||
|
pub fn from_query(s: Option<&str>) -> Self {
|
||||||
|
match s {
|
||||||
|
Some("en") => Lang::En,
|
||||||
|
_ => Lang::Ru,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn other(&self) -> Lang {
|
||||||
|
match self { Lang::Ru => Lang::En, Lang::En => Lang::Ru }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bilingual container — same slugs in both languages. Each language
|
||||||
|
/// is held as an `Arc<Corpus>` so `for_lang` is cheap and callers can
|
||||||
|
/// keep their own handle without deep-cloning a 60kw corpus.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BilingualCorpus {
|
||||||
|
pub ru: Arc<Corpus>,
|
||||||
|
pub en: Arc<Corpus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BilingualCorpus {
|
||||||
|
pub fn for_lang(&self, lang: Lang) -> Arc<Corpus> {
|
||||||
|
match lang {
|
||||||
|
Lang::Ru => self.ru.clone(),
|
||||||
|
Lang::En => self.en.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type BilingualHandle = Arc<BilingualCorpus>;
|
||||||
22
crates/mantra-ui/src/lib.rs
Normal file
22
crates/mantra-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
//! mantra — Leptos UI.
|
||||||
|
//!
|
||||||
|
//! Reader for dense markdown distillate corpora. First consumer: the
|
||||||
|
//! design-dna philosophy corpus (20 sources, ~61k words). The server
|
||||||
|
//! crate loads the corpus into `Arc<Corpus>` at startup and provides
|
||||||
|
//! it via context; UI components read from that context.
|
||||||
|
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod app;
|
||||||
|
pub mod corpus;
|
||||||
|
pub mod pages;
|
||||||
|
|
||||||
|
/// WASM entry point. Called by the cargo-leptos-injected bootstrap
|
||||||
|
/// script once the bundle has downloaded in the browser.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
leptos::mount::hydrate_body(app::App);
|
||||||
|
}
|
||||||
146
crates/mantra-ui/src/pages/landing.rs
Normal file
146
crates/mantra-ui/src/pages/landing.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! `/` — two-door entry into the corpus: works (20) and themes.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
|
use crate::api::{fetch_landing, LandingData};
|
||||||
|
use crate::corpus::{Lang, Source, Theme};
|
||||||
|
use crate::pages::shared::LangToggle;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Landing() -> impl IntoView {
|
||||||
|
let query = use_query_map();
|
||||||
|
let lang = Memo::new(move |_| {
|
||||||
|
Lang::from_query(query.read().get("lang").as_deref())
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = Resource::new(
|
||||||
|
move || lang.get().as_str().to_string(),
|
||||||
|
|l| fetch_landing(l),
|
||||||
|
);
|
||||||
|
|
||||||
|
let hero_title = move || match lang.get() {
|
||||||
|
Lang::Ru => "дизайн днк",
|
||||||
|
Lang::En => "design dna",
|
||||||
|
};
|
||||||
|
let hero_subtitle = move || match lang.get() {
|
||||||
|
Lang::Ru => "цикл 1 · философия · 20 источников",
|
||||||
|
Lang::En => "cycle 1 · philosophy · 20 sources",
|
||||||
|
};
|
||||||
|
let hero_question = move || match lang.get() {
|
||||||
|
Lang::Ru => "почему форма трогает человеческое сердце?",
|
||||||
|
Lang::En => "why does form move the human heart?",
|
||||||
|
};
|
||||||
|
let works_label = move || match lang.get() {
|
||||||
|
Lang::Ru => "работы",
|
||||||
|
Lang::En => "works",
|
||||||
|
};
|
||||||
|
let themes_label = move || match lang.get() {
|
||||||
|
Lang::Ru => "темы",
|
||||||
|
Lang::En => "themes",
|
||||||
|
};
|
||||||
|
let sources_suffix = move |n: usize| match lang.get() {
|
||||||
|
Lang::Ru => format!(" · {n} источников"),
|
||||||
|
Lang::En => format!(" · {n} sources"),
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<main class="landing">
|
||||||
|
<LangToggle current=lang/>
|
||||||
|
<header class="landing-head">
|
||||||
|
<h1 class="landing-title">{hero_title}</h1>
|
||||||
|
<p class="landing-subtitle">{hero_subtitle}</p>
|
||||||
|
<p class="landing-question">{hero_question}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Suspense fallback=move || view! { <p class="landing-loading">"…"</p> }>
|
||||||
|
{move || {
|
||||||
|
data.get().map(|res| {
|
||||||
|
let lang_val = lang.get();
|
||||||
|
let suffix_for_themes = sources_suffix.clone();
|
||||||
|
match res {
|
||||||
|
Ok(LandingData { order, sources, themes }) => {
|
||||||
|
// Build slug -> Source map for cheap lookup in the For body
|
||||||
|
let mut by_slug: std::collections::HashMap<String, Source> =
|
||||||
|
std::collections::HashMap::with_capacity(sources.len());
|
||||||
|
for s in sources { by_slug.insert(s.slug.clone(), s); }
|
||||||
|
view! {
|
||||||
|
<section class="landing-grid">
|
||||||
|
<div class="landing-col">
|
||||||
|
<h2 class="landing-col-label">{works_label}</h2>
|
||||||
|
<ul class="works-list">
|
||||||
|
<For
|
||||||
|
each=move || order.clone()
|
||||||
|
key=|slug| slug.clone()
|
||||||
|
let:slug
|
||||||
|
>
|
||||||
|
<WorkLine
|
||||||
|
source=by_slug.get(&slug).cloned()
|
||||||
|
lang=lang_val
|
||||||
|
slug=slug
|
||||||
|
/>
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="landing-col">
|
||||||
|
<h2 class="landing-col-label">{themes_label}</h2>
|
||||||
|
<ul class="themes-list">
|
||||||
|
<For
|
||||||
|
each=move || themes.clone()
|
||||||
|
key=|t| t.slug.clone()
|
||||||
|
let:theme
|
||||||
|
>
|
||||||
|
<ThemeLine
|
||||||
|
theme=theme
|
||||||
|
lang=lang_val
|
||||||
|
suffix=suffix_for_themes.clone()
|
||||||
|
/>
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
Err(e) => view! { <p class="err">{format!("{e}")}</p> }.into_any(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView {
|
||||||
|
let href = format!("/source/{slug}?lang={}", lang.as_str());
|
||||||
|
match source {
|
||||||
|
None => view! { <li></li> }.into_any(),
|
||||||
|
Some(s) => view! {
|
||||||
|
<li class="work-line">
|
||||||
|
<a href=href>
|
||||||
|
<span class="work-author">{s.author}</span>
|
||||||
|
<span class="work-sep">" · "</span>
|
||||||
|
<span class="work-title">{s.title}</span>
|
||||||
|
<div class="work-claim">{s.core_claim}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ThemeLine<F>(theme: Theme, lang: Lang, suffix: F) -> impl IntoView
|
||||||
|
where
|
||||||
|
F: Fn(usize) -> String + 'static + Send + Sync + Clone,
|
||||||
|
{
|
||||||
|
let href = format!("/theme/{}?lang={}", theme.slug, lang.as_str());
|
||||||
|
let count = theme.contributing.len();
|
||||||
|
view! {
|
||||||
|
<li class="theme-line">
|
||||||
|
<a href=href>
|
||||||
|
<span class="theme-title">{theme.title}</span>
|
||||||
|
<span class="theme-count">{suffix(count)}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
355
crates/mantra-ui/src/pages/margin.rs
Normal file
355
crates/mantra-ui/src/pages/margin.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
//! Margin layer — the drawer that opens when a paragraph is clicked.
|
||||||
|
//!
|
||||||
|
//! Holds: a Notes tab (write a thought, permanent record in vault) and
|
||||||
|
//! an Ask tab (ask Claude about the passage; Q+A is also saved to the
|
||||||
|
//! vault — the dialogue IS the record).
|
||||||
|
//!
|
||||||
|
//! Author identity for v0.1: an inline input pinned at the bottom of
|
||||||
|
//! the drawer, prefilled from `localStorage` under `mantra.author`.
|
||||||
|
//! No modal prompts.
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// A paragraph the user opened the drawer on.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ActivePara {
|
||||||
|
pub id: String,
|
||||||
|
pub excerpt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Tab {
|
||||||
|
Note,
|
||||||
|
Ask,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn MarginDrawer(
|
||||||
|
slug: Signal<String>,
|
||||||
|
lang: Signal<Lang>,
|
||||||
|
active: ReadSignal<Option<ActivePara>>,
|
||||||
|
set_active: WriteSignal<Option<ActivePara>>,
|
||||||
|
/// Bumped whenever the set of notes for this slug changes, so the
|
||||||
|
/// source page can re-annotate paragraph dots.
|
||||||
|
notes_tick: RwSignal<u32>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (notes, set_notes) = signal::<Vec<NoteEntry>>(Vec::new());
|
||||||
|
let (tab, set_tab) = signal(Tab::Note);
|
||||||
|
let (draft, set_draft) = signal(String::new());
|
||||||
|
let (pending, set_pending) = signal(false);
|
||||||
|
let (error, set_error) = signal::<Option<String>>(None);
|
||||||
|
let (author, set_author) = signal(initial_author());
|
||||||
|
|
||||||
|
let textarea_ref: NodeRef<html::Textarea> = NodeRef::new();
|
||||||
|
|
||||||
|
// Refetch all notes for the current slug. Happens on: mount,
|
||||||
|
// slug change, after save, after ask.
|
||||||
|
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}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track slug changes — reload notes.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let s = slug.get();
|
||||||
|
if !s.is_empty() {
|
||||||
|
reload(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear draft/error/tab when switching paragraphs.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = active.get();
|
||||||
|
set_draft.set(String::new());
|
||||||
|
set_error.set(None);
|
||||||
|
set_tab.set(Tab::Note);
|
||||||
|
clear_textarea(&textarea_ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered = Memo::new(move |_| {
|
||||||
|
let Some(a) = active.get() else { return Vec::new() };
|
||||||
|
notes.with(|all| {
|
||||||
|
all.iter().filter(|e| e.para_id == a.id).cloned().collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let close = move |_| set_active.set(None);
|
||||||
|
|
||||||
|
let submit = move || {
|
||||||
|
log("submit clicked");
|
||||||
|
let Some(a) = active.get_untracked() else {
|
||||||
|
log("submit: no active paragraph");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let text = draft.get_untracked().trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
log("submit: empty draft");
|
||||||
|
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.id.clone();
|
||||||
|
let excerpt = a.excerpt.clone();
|
||||||
|
let current_tab = tab.get_untracked();
|
||||||
|
log(&format!(
|
||||||
|
"submit: tab={:?} slug={} para_id={} text_len={} author={}",
|
||||||
|
current_tab, slug_s, para_id, text.len(), author_name,
|
||||||
|
));
|
||||||
|
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(()) => {
|
||||||
|
log("submit: ok");
|
||||||
|
set_draft.set(String::new());
|
||||||
|
clear_textarea(&textarea_ref);
|
||||||
|
reload(slug_s);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("submit: error {e}"));
|
||||||
|
set_error.set(Some(format!("{e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let label_close = move || match lang.get() { Lang::Ru => "закрыть", Lang::En => "close" };
|
||||||
|
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:" };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show
|
||||||
|
when=move || active.get().is_some()
|
||||||
|
fallback=|| ().into_any()
|
||||||
|
>
|
||||||
|
<aside class="margin-drawer">
|
||||||
|
<header class="margin-drawer-head">
|
||||||
|
<div class="margin-drawer-tabs">
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:tab-active=move || tab.get() == Tab::Note
|
||||||
|
on:click=move |_| set_tab.set(Tab::Note)
|
||||||
|
>{label_note}</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:tab-active=move || tab.get() == Tab::Ask
|
||||||
|
on:click=move |_| set_tab.set(Tab::Ask)
|
||||||
|
>{label_ask}</button>
|
||||||
|
</div>
|
||||||
|
<button class="margin-drawer-close" on:click=close aria-label=label_close>"×"</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="margin-drawer-excerpt">
|
||||||
|
{move || active.get().map(|a| a.excerpt)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="margin-drawer-form">
|
||||||
|
<textarea
|
||||||
|
class="margin-drawer-input"
|
||||||
|
node_ref=textarea_ref
|
||||||
|
placeholder=move || match tab.get() {
|
||||||
|
Tab::Note => placeholder_note(),
|
||||||
|
Tab::Ask => placeholder_ask(),
|
||||||
|
}
|
||||||
|
on:input=move |ev| set_draft.set(event_target_value(&ev))
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
{move || error.get().map(|e| view! {
|
||||||
|
<p class="margin-drawer-error">{e}</p>
|
||||||
|
})}
|
||||||
|
<div class="margin-drawer-actions">
|
||||||
|
<label class="margin-drawer-author">
|
||||||
|
<span class="margin-drawer-author-label">{label_you}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="margin-drawer-author-input"
|
||||||
|
prop:value=move || author.get()
|
||||||
|
on:input=move |ev| {
|
||||||
|
let v = event_target_value(&ev);
|
||||||
|
persist_author(&v);
|
||||||
|
set_author.set(v);
|
||||||
|
}
|
||||||
|
placeholder="anon"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="margin-drawer-submit"
|
||||||
|
disabled=move || pending.get() || draft.get().trim().is_empty()
|
||||||
|
on:click=move |_| submit()
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
if pending.get() {
|
||||||
|
match tab.get() {
|
||||||
|
Tab::Note => label_pending_note(),
|
||||||
|
Tab::Ask => label_pending_ask(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match tab.get() {
|
||||||
|
Tab::Note => label_save(),
|
||||||
|
Tab::Ask => label_ask_go(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="margin-drawer-entries">
|
||||||
|
{move || {
|
||||||
|
let list = filtered.get();
|
||||||
|
list.into_iter().map(|e| view! {
|
||||||
|
<NoteCard entry=e/>
|
||||||
|
}).collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NoteCard(entry: NoteEntry) -> impl IntoView {
|
||||||
|
let is_ask = entry.kind == NoteKind::Ask;
|
||||||
|
let cls = if is_ask { "note-card note-card-ask" } else { "note-card" };
|
||||||
|
|
||||||
|
// For Ask entries the body is `**Q:** ...\n\n**A (Claude):** ...`;
|
||||||
|
// split so we can style them distinctly.
|
||||||
|
let (q, a) = if is_ask {
|
||||||
|
split_ask_body(&entry.body)
|
||||||
|
} else {
|
||||||
|
(None, Some(entry.body.clone()))
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<article class=cls>
|
||||||
|
<header class="note-card-meta">{entry.author_ts}</header>
|
||||||
|
{q.map(|s| view! { <p class="note-q">{s}</p> })}
|
||||||
|
{a.map(|s| view! { <p class="note-a">{s}</p> })}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_ask_body(body: &str) -> (Option<String>, Option<String>) {
|
||||||
|
let mut q = None;
|
||||||
|
let mut a = None;
|
||||||
|
if let Some(q_rest) = body.strip_prefix("**Q:**") {
|
||||||
|
if let Some((q_part, a_part)) = q_rest.split_once("**A (Claude):**") {
|
||||||
|
q = Some(q_part.trim().to_string());
|
||||||
|
a = Some(a_part.trim().to_string());
|
||||||
|
} else {
|
||||||
|
q = Some(q_rest.trim().to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a = Some(body.trim().to_string());
|
||||||
|
}
|
||||||
|
(q, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Author (inline, no modal prompt) ---------------------------------
|
||||||
|
|
||||||
|
fn initial_author() -> String {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(win) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = win.local_storage() {
|
||||||
|
if let Ok(Some(name)) = storage.get_item("mantra.author") {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_author(name: &str) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(win) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = win.local_storage() {
|
||||||
|
let _ = storage.set_item("mantra.author", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ let _ = name; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Textarea helpers -------------------------------------------------
|
||||||
|
|
||||||
|
fn clear_textarea(node: &NodeRef<html::Textarea>) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(el) = node.get_untracked() {
|
||||||
|
el.set_value("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ let _ = node; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Debug log --------------------------------------------------------
|
||||||
|
|
||||||
|
fn log(msg: &str) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(&format!("[mantra] {msg}").into());
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ let _ = msg; }
|
||||||
|
}
|
||||||
5
crates/mantra-ui/src/pages/mod.rs
Normal file
5
crates/mantra-ui/src/pages/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod landing;
|
||||||
|
pub mod margin;
|
||||||
|
pub mod shared;
|
||||||
|
pub mod source;
|
||||||
|
pub mod theme;
|
||||||
37
crates/mantra-ui/src/pages/shared.rs
Normal file
37
crates/mantra-ui/src/pages/shared.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
//! Shared UI atoms.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_location, use_query_map};
|
||||||
|
|
||||||
|
use crate::corpus::Lang;
|
||||||
|
|
||||||
|
/// Minimal top-right language toggle. Two links; current is dimmed,
|
||||||
|
/// the other is hot. Preserves the rest of the URL (path + remaining
|
||||||
|
/// query params) so toggling on a deep page keeps you there.
|
||||||
|
#[component]
|
||||||
|
pub fn LangToggle(current: Memo<Lang>) -> impl IntoView {
|
||||||
|
let location = use_location();
|
||||||
|
let query = use_query_map();
|
||||||
|
|
||||||
|
let make_href = move |target: Lang| {
|
||||||
|
let path = location.pathname.get();
|
||||||
|
let mut params = query.get();
|
||||||
|
params.replace("lang", target.as_str().to_string());
|
||||||
|
let qs = params.to_query_string();
|
||||||
|
if qs.is_empty() { path } else { format!("{path}{qs}") }
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="lang-toggle">
|
||||||
|
<a
|
||||||
|
href=move || make_href(Lang::Ru)
|
||||||
|
class:lang-active=move || current.get() == Lang::Ru
|
||||||
|
>"ru"</a>
|
||||||
|
<span class="lang-dot">"·"</span>
|
||||||
|
<a
|
||||||
|
href=move || make_href(Lang::En)
|
||||||
|
class:lang-active=move || current.get() == Lang::En
|
||||||
|
>"en"</a>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
194
crates/mantra-ui/src/pages/source.rs
Normal file
194
crates/mantra-ui/src/pages/source.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
//! `/source/:slug` — one distillation, book-page typography.
|
||||||
|
//!
|
||||||
|
//! Reads its data via the `source_page` server fn wrapped in a
|
||||||
|
//! `Resource` so SSR and hydrate render from the same payload.
|
||||||
|
//! This avoids needing the corpus `Arc<Corpus>` context on the
|
||||||
|
//! client (where it isn't available).
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_params, use_query_map};
|
||||||
|
use leptos_router::params::Params;
|
||||||
|
|
||||||
|
use crate::api::{fetch_source_page, SourcePageData};
|
||||||
|
use crate::corpus::Lang;
|
||||||
|
use crate::pages::margin::{ActivePara, MarginDrawer};
|
||||||
|
use crate::pages::shared::LangToggle;
|
||||||
|
|
||||||
|
#[derive(Params, PartialEq, Clone, Debug)]
|
||||||
|
struct SlugParam {
|
||||||
|
slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SourcePage() -> impl IntoView {
|
||||||
|
let params = use_params::<SlugParam>();
|
||||||
|
let slug = Memo::new(move |_| {
|
||||||
|
params.get().ok().and_then(|p| p.slug).unwrap_or_default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let query = use_query_map();
|
||||||
|
let lang = Memo::new(move |_| {
|
||||||
|
Lang::from_query(query.read().get("lang").as_deref())
|
||||||
|
});
|
||||||
|
|
||||||
|
let (active, set_active) = signal::<Option<ActivePara>>(None);
|
||||||
|
let notes_tick = RwSignal::new(0u32);
|
||||||
|
|
||||||
|
let slug_sig: Signal<String> = Signal::derive(move || slug.get());
|
||||||
|
let lang_sig: Signal<Lang> = Signal::derive(move || lang.get());
|
||||||
|
|
||||||
|
// Resource key = (slug, lang). Re-fetches when either changes.
|
||||||
|
let data = Resource::new(
|
||||||
|
move || (slug.get(), lang.get().as_str().to_string()),
|
||||||
|
|(s, l)| fetch_source_page(s, l),
|
||||||
|
);
|
||||||
|
|
||||||
|
// After hydration + notes load: annotate paragraphs that have
|
||||||
|
// marginalia so a dot appears in the left gutter.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
{
|
||||||
|
let slug_for_fx = slug_sig;
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = notes_tick.get();
|
||||||
|
let current_slug = slug_for_fx.get();
|
||||||
|
if current_slug.is_empty() { return; }
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
if let Ok(entries) = crate::api::fetch_notes(current_slug).await {
|
||||||
|
let ids: std::collections::HashSet<String> =
|
||||||
|
entries.into_iter().map(|e| e.para_id).collect();
|
||||||
|
annotate_notes(&ids);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<LangToggle current=lang/>
|
||||||
|
<Suspense fallback=move || view! { <p class="source-loading">"…"</p> }>
|
||||||
|
{move || {
|
||||||
|
data.get().map(|res| {
|
||||||
|
let lang_val = lang.get();
|
||||||
|
let home_href = format!("/?lang={}", lang_val.as_str());
|
||||||
|
let breadcrumb_cycle = match lang_val {
|
||||||
|
Lang::Ru => "цикл 1",
|
||||||
|
Lang::En => "cycle 1",
|
||||||
|
};
|
||||||
|
let label_all = match lang_val {
|
||||||
|
Lang::Ru => "все источники",
|
||||||
|
Lang::En => "all sources",
|
||||||
|
};
|
||||||
|
let label_prev = match lang_val {
|
||||||
|
Lang::Ru => "← предыдущий",
|
||||||
|
Lang::En => "← prev",
|
||||||
|
};
|
||||||
|
let label_next = match lang_val {
|
||||||
|
Lang::Ru => "следующий →",
|
||||||
|
Lang::En => "next →",
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(d)) => {
|
||||||
|
let SourcePageData { source: src, prev, next } = d;
|
||||||
|
let prev_href = prev.map(|p| format!("/source/{p}?lang={}", lang_val.as_str()));
|
||||||
|
let next_href = next.map(|n| format!("/source/{n}?lang={}", lang_val.as_str()));
|
||||||
|
view! {
|
||||||
|
<main class="source">
|
||||||
|
<nav class="source-breadcrumb">
|
||||||
|
<a href=home_href.clone()>"design dna"</a>
|
||||||
|
<span class="sep">" · "</span>
|
||||||
|
<span>{breadcrumb_cycle}</span>
|
||||||
|
<span class="sep">" · "</span>
|
||||||
|
<span class="source-author">{src.author}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class="source-body"
|
||||||
|
inner_html=src.body_html
|
||||||
|
on:click=move |ev| {
|
||||||
|
if let Some(p) = paragraph_from_event(&ev) {
|
||||||
|
set_active.set(Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
></article>
|
||||||
|
|
||||||
|
<footer class="source-foot">
|
||||||
|
{prev_href.map(|h| view! {
|
||||||
|
<a class="nav-prev" href=h>{label_prev}</a>
|
||||||
|
})}
|
||||||
|
<a class="nav-home" href=home_href>{label_all}</a>
|
||||||
|
{next_href.map(|h| view! {
|
||||||
|
<a class="nav-next" href=h>{label_next}</a>
|
||||||
|
})}
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
Ok(None) => view! {
|
||||||
|
<main class="source not-found">
|
||||||
|
<p>"source not found"</p>
|
||||||
|
<a class="back-link" href=home_href>"← back"</a>
|
||||||
|
</main>
|
||||||
|
}.into_any(),
|
||||||
|
Err(e) => view! {
|
||||||
|
<main class="source not-found">
|
||||||
|
<p class="err">{format!("{e}")}</p>
|
||||||
|
</main>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<MarginDrawer
|
||||||
|
slug=slug_sig
|
||||||
|
lang=lang_sig
|
||||||
|
active=active
|
||||||
|
set_active=set_active
|
||||||
|
notes_tick=notes_tick
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Climb up from the click target to find the nearest `[data-para-id]`
|
||||||
|
/// element; return its id + plain-text content to seed the drawer.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn paragraph_from_event(ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
let target = ev.target()?;
|
||||||
|
let mut el = target.dyn_into::<web_sys::Element>().ok()?;
|
||||||
|
loop {
|
||||||
|
if el.has_attribute("data-para-id") {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
el = el.parent_element()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
fn paragraph_from_event(_ev: &leptos::ev::MouseEvent) -> Option<ActivePara> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add `class="has-notes"` to every paragraph that has at least one
|
||||||
|
/// marginalia entry. Called after notes fetch.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
fn annotate_notes(ids: &std::collections::HashSet<String>) {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
let Some(win) = web_sys::window() else { return };
|
||||||
|
let Some(doc) = win.document() else { return };
|
||||||
|
let Ok(nodes) = doc.query_selector_all("[data-para-id]") else { return };
|
||||||
|
for i in 0..nodes.length() {
|
||||||
|
let Some(node) = nodes.item(i) else { continue };
|
||||||
|
let Ok(el) = node.dyn_into::<web_sys::Element>() else { continue };
|
||||||
|
let id = el.get_attribute("data-para-id").unwrap_or_default();
|
||||||
|
let classes = el.class_list();
|
||||||
|
if ids.contains(&id) {
|
||||||
|
let _ = classes.add_1("has-notes");
|
||||||
|
} else {
|
||||||
|
let _ = classes.remove_1("has-notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
117
crates/mantra-ui/src/pages/theme.rs
Normal file
117
crates/mantra-ui/src/pages/theme.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! `/theme/:slug` — one running theme, with contributing sources.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_params, use_query_map};
|
||||||
|
use leptos_router::params::Params;
|
||||||
|
|
||||||
|
use crate::api::{fetch_theme_page, ThemePageData};
|
||||||
|
use crate::corpus::{Lang, Source};
|
||||||
|
use crate::pages::shared::LangToggle;
|
||||||
|
|
||||||
|
#[derive(Params, PartialEq, Clone, Debug)]
|
||||||
|
struct SlugParam {
|
||||||
|
slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ThemePage() -> impl IntoView {
|
||||||
|
let params = use_params::<SlugParam>();
|
||||||
|
let slug = Memo::new(move |_| {
|
||||||
|
params.get().ok().and_then(|p| p.slug).unwrap_or_default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let query = use_query_map();
|
||||||
|
let lang = Memo::new(move |_| {
|
||||||
|
Lang::from_query(query.read().get("lang").as_deref())
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = Resource::new(
|
||||||
|
move || (slug.get(), lang.get().as_str().to_string()),
|
||||||
|
|(s, l)| fetch_theme_page(s, l),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<LangToggle current=lang/>
|
||||||
|
<Suspense fallback=move || view! { <p class="source-loading">"…"</p> }>
|
||||||
|
{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 themes",
|
||||||
|
};
|
||||||
|
let label_sources = match lang_val {
|
||||||
|
Lang::Ru => "источники",
|
||||||
|
Lang::En => "sources",
|
||||||
|
};
|
||||||
|
let theme_label = match lang_val {
|
||||||
|
Lang::Ru => "тема",
|
||||||
|
Lang::En => "theme",
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(ThemePageData { theme, contributing })) => view! {
|
||||||
|
<main class="theme">
|
||||||
|
<nav class="source-breadcrumb">
|
||||||
|
<a href=home_href.clone()>"design dna"</a>
|
||||||
|
<span class="sep">" · "</span>
|
||||||
|
<span>{theme_label}</span>
|
||||||
|
</nav>
|
||||||
|
<h1 class="theme-h1">{theme.title}</h1>
|
||||||
|
<div
|
||||||
|
class="theme-desc"
|
||||||
|
inner_html=theme.description_html
|
||||||
|
></div>
|
||||||
|
<section class="theme-contributing">
|
||||||
|
<h3 class="landing-col-label">{label_sources}</h3>
|
||||||
|
<ul class="works-list">
|
||||||
|
<For
|
||||||
|
each=move || contributing.clone()
|
||||||
|
key=|s| s.slug.clone()
|
||||||
|
let:source
|
||||||
|
>
|
||||||
|
<ContributingLine
|
||||||
|
source=source
|
||||||
|
lang=lang_val
|
||||||
|
/>
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<footer class="source-foot">
|
||||||
|
<a class="nav-home" href=home_href>{label_back}</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(None) => view! {
|
||||||
|
<main class="theme not-found">
|
||||||
|
<p>"theme not found"</p>
|
||||||
|
<a class="back-link" href=home_href>"← back"</a>
|
||||||
|
</main>
|
||||||
|
}.into_any(),
|
||||||
|
Err(e) => view! {
|
||||||
|
<main class="theme not-found">
|
||||||
|
<p class="err">{format!("{e}")}</p>
|
||||||
|
</main>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ContributingLine(source: Source, lang: Lang) -> impl IntoView {
|
||||||
|
let href = format!("/source/{}?lang={}", source.slug, lang.as_str());
|
||||||
|
view! {
|
||||||
|
<li class="work-line">
|
||||||
|
<a href=href>
|
||||||
|
<span class="work-author">{source.author}</span>
|
||||||
|
<span class="work-sep">" · "</span>
|
||||||
|
<span class="work-title">{source.title}</span>
|
||||||
|
<div class="work-claim">{source.core_claim}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
756
sass/main.scss
Normal file
756
sass/main.scss
Normal file
|
|
@ -0,0 +1,756 @@
|
||||||
|
// mantra · book-grade reader typography
|
||||||
|
// Fraunces (variable) body + IBM Plex Sans (labels) + IBM Plex Mono (non-Latin)
|
||||||
|
// Pergament-ink light · deep-ink dark · 62ch measure
|
||||||
|
// Headings H2 as margin-labels on wide, inline-before on mobile
|
||||||
|
|
||||||
|
// --- Fonts (self-hosted) ---------------------------------------------
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/fraunces-variable.woff2") format("woff2-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces";
|
||||||
|
src: url("/fonts/fraunces-italic-variable.woff2") format("woff2-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Sans";
|
||||||
|
src: url("/fonts/plex-sans-variable.woff2") format("woff2-variations");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Mono";
|
||||||
|
src: url("/fonts/plex-mono-variable.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Color tokens ----------------------------------------------------
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Pergament (light)
|
||||||
|
--mantra-bg: #fdfbf5;
|
||||||
|
--mantra-fg: #1c1917;
|
||||||
|
--mantra-muted: #78716c;
|
||||||
|
--mantra-faint: #d6d3d0;
|
||||||
|
--mantra-accent: #5e4b3a;
|
||||||
|
--mantra-hairline: rgba(28, 25, 23, 0.08);
|
||||||
|
|
||||||
|
--mantra-measure: 62ch;
|
||||||
|
--mantra-serif: "Fraunces", Georgia, "Times New Roman", serif;
|
||||||
|
--mantra-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
--mantra-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--mantra-bg: #0f1012;
|
||||||
|
--mantra-fg: #e7e3dd;
|
||||||
|
--mantra-muted: #8a857d;
|
||||||
|
--mantra-faint: #2a2826;
|
||||||
|
--mantra-accent: #c9b999;
|
||||||
|
--mantra-hairline: rgba(231, 227, 221, 0.10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset + base ----------------------------------------------------
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--mantra-accent);
|
||||||
|
color: var(--mantra-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--mantra-bg);
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.68;
|
||||||
|
letter-spacing: 0.002em;
|
||||||
|
// Variable-axis tuning: a warmer serif without eccentricity
|
||||||
|
font-variation-settings: "opsz" 18, "SOFT" 30, "WONK" 0, "wght" 400;
|
||||||
|
font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "onum" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid var(--mantra-hairline);
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
a:hover { border-bottom-color: var(--mantra-accent); color: var(--mantra-accent); }
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: var(--mantra-measure);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 6rem 1.5rem 8rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
main { padding: 3rem 1.25rem 5rem; }
|
||||||
|
body { font-size: 18px; line-height: 1.65; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Landing --------------------------------------------------------
|
||||||
|
|
||||||
|
.landing {
|
||||||
|
max-width: 72ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-head { margin-bottom: 4rem; }
|
||||||
|
|
||||||
|
.landing-title {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 50, "WONK" 0, "wght" 350;
|
||||||
|
font-size: clamp(3rem, 7vw, 4.5rem);
|
||||||
|
font-weight: 350;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-subtitle {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 400;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
margin: 1rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-question {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 36, "SOFT" 50, "WONK" 0, "wght" 380;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
margin: 0 0 4rem;
|
||||||
|
max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.landing-grid { grid-template-columns: 1fr; gap: 3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-col-label {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 500;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-list, .themes-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-line {
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
border-bottom: none;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
a:hover { color: var(--mantra-fg); }
|
||||||
|
a:hover .work-author { color: var(--mantra-accent); }
|
||||||
|
}
|
||||||
|
.work-author {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 18, "SOFT" 30, "WONK" 0, "wght" 500;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
.work-sep { color: var(--mantra-faint); }
|
||||||
|
.work-title {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
}
|
||||||
|
.work-claim {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 14, "SOFT" 30, "WONK" 0, "wght" 380;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-line a {
|
||||||
|
display: block;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.theme-title {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 18, "SOFT" 30, "WONK" 0, "wght" 500;
|
||||||
|
}
|
||||||
|
.theme-count {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Source page ----------------------------------------------------
|
||||||
|
|
||||||
|
.source-breadcrumb {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 450;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
.sep { margin: 0 0.5em; color: var(--mantra-faint); }
|
||||||
|
a { color: var(--mantra-muted); border-bottom: none; }
|
||||||
|
a:hover { color: var(--mantra-fg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-body {
|
||||||
|
// Positioning anchor for H2 margin-labels below.
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// H1 is the work title
|
||||||
|
h1 {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 96, "SOFT" 50, "WONK" 0, "wght" 380;
|
||||||
|
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.012em;
|
||||||
|
margin: 0 0 2.5rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// H2 as margin-label on wide screens (pulled into left margin)
|
||||||
|
h2 {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 500;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 3.5rem 0 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
h2 {
|
||||||
|
position: absolute;
|
||||||
|
// Pull the label into the left gutter, outside the article box.
|
||||||
|
// 16ch for the label + 2.5rem breathing room before the text
|
||||||
|
// column (which starts at the article's padding-left, i.e. 0).
|
||||||
|
left: calc(-16ch - 2.5rem);
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
width: 16ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
// Insert spacing where H2 would have been inline
|
||||||
|
h2 + * { margin-top: 3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 24, "SOFT" 40, "WONK" 0, "wght" 520;
|
||||||
|
font-size: 1.12rem;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
margin: 2.2rem 0 0.8rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.35rem;
|
||||||
|
hyphens: auto;
|
||||||
|
-webkit-hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
em, i {
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 18, "SOFT" 40, "WONK" 0, "wght" 400;
|
||||||
|
}
|
||||||
|
strong, b {
|
||||||
|
font-variation-settings: "opsz" 18, "SOFT" 30, "WONK" 0, "wght" 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
border-bottom: 1px solid var(--mantra-muted);
|
||||||
|
}
|
||||||
|
a:hover { border-bottom-color: var(--mantra-accent); }
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 2px solid var(--mantra-accent);
|
||||||
|
margin: 2rem 0 2rem -0.5rem;
|
||||||
|
padding: 0.3rem 0 0.3rem 1.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 17, "SOFT" 50, "WONK" 0, "wght" 400;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
p { margin-bottom: 0.6rem; &:last-child { margin-bottom: 0; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--mantra-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--mantra-faint);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: var(--mantra-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: var(--mantra-faint);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
code { background: none; padding: 0; font-size: inherit; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0 0 1.35rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
li { margin: 0.3rem 0; }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--mantra-hairline);
|
||||||
|
margin: 3rem auto;
|
||||||
|
width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--mantra-hairline);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph click-target + margin dot (M.3 margin-layer).
|
||||||
|
// - Empty paragraphs: faint dot appears only on hover (affordance).
|
||||||
|
// - Annotated paragraphs (.has-notes): dot is always visible in the
|
||||||
|
// accent colour, so readers see at a glance where the marginalia is.
|
||||||
|
p[data-para-id] {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
p[data-para-id]::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1.25rem;
|
||||||
|
top: 0.75em;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mantra-faint);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
p[data-para-id]:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--mantra-muted);
|
||||||
|
}
|
||||||
|
p[data-para-id].has-notes::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--mantra-accent);
|
||||||
|
}
|
||||||
|
p[data-para-id].has-notes:hover::before {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 5rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--mantra-hairline);
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
|
||||||
|
a { border-bottom: none; color: var(--mantra-muted); }
|
||||||
|
a:hover { color: var(--mantra-fg); }
|
||||||
|
|
||||||
|
.nav-prev { justify-self: start; }
|
||||||
|
.nav-home { justify-self: center; }
|
||||||
|
.nav-next { justify-self: end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Theme page -----------------------------------------------------
|
||||||
|
|
||||||
|
.theme-h1 {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-variation-settings: "opsz" 72, "SOFT" 50, "WONK" 0, "wght" 380;
|
||||||
|
font-size: clamp(2rem, 4.5vw, 2.75rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-desc {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
p { color: var(--mantra-fg); margin-bottom: 1.2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-contributing { margin-top: 3rem; }
|
||||||
|
|
||||||
|
// --- Misc -----------------------------------------------------------
|
||||||
|
|
||||||
|
// --- Language toggle (top-right) ---
|
||||||
|
.lang-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-variation-settings: "wght" 450;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--mantra-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0.1rem 0.1rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
a:hover { color: var(--mantra-fg); }
|
||||||
|
a.lang-active {
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
}
|
||||||
|
.lang-dot {
|
||||||
|
color: var(--mantra-faint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lang-toggle { top: 1rem; right: 1rem; font-size: 0.72rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
color: #b45309;
|
||||||
|
font-family: var(--mantra-mono);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.not-found { padding: 2rem 0; }
|
||||||
|
.back-link {
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Margin drawer (M.3) --------------------------------------------
|
||||||
|
|
||||||
|
.margin-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 100vh;
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
background: var(--mantra-bg);
|
||||||
|
border-left: 1px solid var(--mantra-hairline);
|
||||||
|
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.04);
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
animation: drawer-in 0.22s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawer-in {
|
||||||
|
from { transform: translateX(16px); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-head {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.9rem 1.1rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--mantra-hairline);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variation-settings: "wght" 450;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: var(--mantra-fg); }
|
||||||
|
&.tab-active {
|
||||||
|
color: var(--mantra-bg);
|
||||||
|
background: var(--mantra-accent);
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.55rem;
|
||||||
|
right: 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
&:hover { color: var(--mantra-fg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-excerpt {
|
||||||
|
padding: 0.8rem 1.1rem;
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
border-bottom: 1px solid var(--mantra-hairline);
|
||||||
|
max-height: 6.5em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries sit below the form (reading flows downward: write → record).
|
||||||
|
// They scroll within their own area when long; when empty, they add
|
||||||
|
// minimal height so the drawer collapses to just head+excerpt+form.
|
||||||
|
.margin-drawer-entries {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-height: 42vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.6rem 1.1rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
border-top: 1px solid var(--mantra-hairline);
|
||||||
|
|
||||||
|
&:empty { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-empty {
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 1.2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
border-left: 2px solid var(--mantra-hairline);
|
||||||
|
padding: 0.1rem 0 0.1rem 0.8rem;
|
||||||
|
|
||||||
|
&.note-card-ask { border-left-color: var(--mantra-accent); }
|
||||||
|
|
||||||
|
.note-card-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-variation-settings: "wght" 500;
|
||||||
|
}
|
||||||
|
.note-q {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
}
|
||||||
|
.note-a {
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-form {
|
||||||
|
padding: 0.9rem 1.1rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-input {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
background: var(--mantra-bg);
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
border: 1px solid var(--mantra-hairline);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-family: var(--mantra-serif);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--mantra-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-author {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.margin-drawer-author-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--mantra-muted);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
font-variation-settings: "wght" 500;
|
||||||
|
}
|
||||||
|
.margin-drawer-author-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px dashed var(--mantra-faint);
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--mantra-fg);
|
||||||
|
padding: 0.1rem 0 0.15rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
&:focus { border-bottom-color: var(--mantra-accent); }
|
||||||
|
&::placeholder { color: var(--mantra-faint); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-submit {
|
||||||
|
background: var(--mantra-accent);
|
||||||
|
color: var(--mantra-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.45rem 1.1rem;
|
||||||
|
font-family: var(--mantra-sans);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-variation-settings: "wght" 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.12s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-drawer-error {
|
||||||
|
color: #b45309;
|
||||||
|
font-family: var(--mantra-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: drawer becomes a bottom sheet
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.margin-drawer {
|
||||||
|
top: auto;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100vw;
|
||||||
|
max-height: 75vh;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--mantra-hairline);
|
||||||
|
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
animation: drawer-up 0.22s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes drawer-up {
|
||||||
|
from { transform: translateY(16px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/fonts/fraunces-italic-variable.woff2
Normal file
BIN
static/fonts/fraunces-italic-variable.woff2
Normal file
Binary file not shown.
BIN
static/fonts/fraunces-variable.woff2
Normal file
BIN
static/fonts/fraunces-variable.woff2
Normal file
Binary file not shown.
BIN
static/fonts/plex-mono-variable.woff2
Normal file
BIN
static/fonts/plex-mono-variable.woff2
Normal file
Binary file not shown.
BIN
static/fonts/plex-sans-variable.woff2
Normal file
BIN
static/fonts/plex-sans-variable.woff2
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue