mantra v0.2: multi-cycle library + artifacts (cycle 2 + manifest + applied-principles)
This commit is contained in:
parent
716cf74864
commit
d501b1b700
12 changed files with 765 additions and 217 deletions
|
|
@ -1,11 +1,21 @@
|
|||
//! 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
|
||||
//! cycle-1-philosophy/ ← EN cycle 1 sources
|
||||
//! _index.md
|
||||
//! <source-slug>.md × N
|
||||
//! cycle-1-philosophy-ru/ ← RU cycle 1 sources (parallel to en)
|
||||
//! _index.md
|
||||
//! <source-slug>.md × N
|
||||
//! cycle-2-design-theory/ ← cycle 2 sources (Russian only for v0.2)
|
||||
//! _index.md
|
||||
//! <source-slug>.md × N
|
||||
//! manifest.md ← cycle 1 closing artifact
|
||||
//! applied-principles.md ← cycle 2 closing artifact
|
||||
//!
|
||||
//! The loader returns an in-memory `Corpus` ready for SSR renders.
|
||||
//! Each lang build of the Corpus includes: cycle-1 in that lang +
|
||||
//! cycle-2 (Russian) + artifacts (Russian). For lang=en, cycles 2 and
|
||||
//! artifacts show Russian content — cycle 2 is not yet translated.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
|
@ -19,7 +29,7 @@ use std::collections::hash_map::DefaultHasher;
|
|||
use std::hash::{Hash, Hasher};
|
||||
use serde::Deserialize;
|
||||
|
||||
use mantra_ui::corpus::{Corpus, Source, Theme};
|
||||
use mantra_ui::corpus::{Artifact, Corpus, Cycle, Source, Theme};
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
struct SourceFrontmatter {
|
||||
|
|
@ -33,23 +43,102 @@ struct SourceFrontmatter {
|
|||
_kind: String,
|
||||
}
|
||||
|
||||
pub fn load_corpus(root: &Path) -> Result<Corpus> {
|
||||
load_corpus_alt_cycle_dir(root, "cycle-1-philosophy")
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
struct ArtifactFrontmatter {
|
||||
#[serde(default, rename = "type")]
|
||||
_kind: String,
|
||||
#[serde(default)]
|
||||
cycle: Option<u64>,
|
||||
#[serde(default)]
|
||||
closes: Option<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
));
|
||||
/// Language mode for cycle 1. Cycle 2 is Russian-only for v0.2.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CycleOneLang {
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
/// Load the entire corpus for one language. Cycle 1 is language-aware;
|
||||
/// cycle 2 and artifacts are always loaded from their Russian-only
|
||||
/// source and shown as-is in both language modes.
|
||||
pub fn load_corpus(root: &Path, lang: CycleOneLang) -> Result<Corpus> {
|
||||
let mut cycles: Vec<Cycle> = Vec::new();
|
||||
|
||||
// --- Cycle 1 (bilingual) ---
|
||||
let cycle1_subdir = match lang {
|
||||
CycleOneLang::En => "cycle-1-philosophy",
|
||||
CycleOneLang::Ru => "cycle-1-philosophy-ru",
|
||||
};
|
||||
let cycle1_dir = root.join(cycle1_subdir);
|
||||
if cycle1_dir.is_dir() {
|
||||
let (title, subtitle) = match lang {
|
||||
CycleOneLang::En => ("philosophy", "cycle 1 · 20 sources"),
|
||||
CycleOneLang::Ru => ("философия", "цикл 1 · 20 источников"),
|
||||
};
|
||||
cycles.push(load_cycle(
|
||||
&cycle1_dir,
|
||||
"cycle-1-philosophy",
|
||||
title,
|
||||
subtitle,
|
||||
)?);
|
||||
}
|
||||
|
||||
// First pass: gather all source files (exclude _index.md).
|
||||
// --- Cycle 2 (Russian only for v0.2) ---
|
||||
let cycle2_dir = root.join("cycle-2-design-theory");
|
||||
if cycle2_dir.is_dir() {
|
||||
let (title, subtitle) = match lang {
|
||||
CycleOneLang::En => ("design theory", "cycle 2 · 14 masters · в переводе"),
|
||||
CycleOneLang::Ru => ("теория дизайна", "цикл 2 · 14 мастеров"),
|
||||
};
|
||||
cycles.push(load_cycle(
|
||||
&cycle2_dir,
|
||||
"cycle-2-design-theory",
|
||||
title,
|
||||
subtitle,
|
||||
)?);
|
||||
}
|
||||
|
||||
// --- Artifacts (Russian only for v0.2) ---
|
||||
let mut artifacts: Vec<Artifact> = Vec::new();
|
||||
for (slug, title_ru, title_en, subtitle_ru, subtitle_en) in [
|
||||
(
|
||||
"manifest",
|
||||
"манифест",
|
||||
"manifesto",
|
||||
"что делает форму живой · цикл 1",
|
||||
"what makes form alive · cycle 1",
|
||||
),
|
||||
(
|
||||
"applied-principles",
|
||||
"прикладные принципы",
|
||||
"applied principles",
|
||||
"невидимая эротика + 5 принципов · цикл 2",
|
||||
"invisible eros + 5 principles · cycle 2",
|
||||
),
|
||||
] {
|
||||
let path = root.join(format!("{slug}.md"));
|
||||
if path.exists() {
|
||||
let (title, subtitle) = match lang {
|
||||
CycleOneLang::En => (title_en, subtitle_en),
|
||||
CycleOneLang::Ru => (title_ru, subtitle_ru),
|
||||
};
|
||||
artifacts.push(load_artifact(&path, slug, title, subtitle)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Corpus { cycles, artifacts })
|
||||
}
|
||||
|
||||
fn load_cycle(
|
||||
cycle_dir: &Path,
|
||||
cycle_slug: &str,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
) -> Result<Cycle> {
|
||||
let mut source_paths: Vec<PathBuf> = Vec::new();
|
||||
for entry in fs::read_dir(&cycle_dir)? {
|
||||
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;
|
||||
|
|
@ -67,24 +156,63 @@ pub fn load_corpus_alt_cycle_dir(root: &Path, cycle_subdir: &str) -> Result<Corp
|
|||
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(|| {
|
||||
let src = parse_source(path, cycle_slug).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"))
|
||||
let themes = parse_themes(&cycle_dir.join("_index.md"), cycle_slug)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("no themes loaded: {e}");
|
||||
tracing::warn!("no themes loaded for {cycle_slug}: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
Ok(Corpus { order, sources, themes })
|
||||
Ok(Cycle {
|
||||
slug: cycle_slug.to_string(),
|
||||
title: title.to_string(),
|
||||
subtitle: subtitle.to_string(),
|
||||
order,
|
||||
sources,
|
||||
themes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_source(path: &Path) -> Result<Source> {
|
||||
fn load_artifact(
|
||||
path: &Path,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
) -> Result<Artifact> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&raw);
|
||||
let fm: ArtifactFrontmatter = parsed
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|p| p.deserialize().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let body_md = parsed.content;
|
||||
let body_html = md_to_html(&body_md);
|
||||
|
||||
let closes_cycle = fm.cycle.map(|n| match n {
|
||||
1 => "cycle-1-philosophy".to_string(),
|
||||
2 => "cycle-2-design-theory".to_string(),
|
||||
other => format!("cycle-{other}"),
|
||||
}).or(fm.closes);
|
||||
|
||||
Ok(Artifact {
|
||||
slug: slug.to_string(),
|
||||
title: title.to_string(),
|
||||
subtitle: subtitle.to_string(),
|
||||
closes_cycle,
|
||||
body_html,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_source(path: &Path, cycle_slug: &str) -> Result<Source> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&raw);
|
||||
|
|
@ -101,7 +229,6 @@ fn parse_source(path: &Path) -> Result<Source> {
|
|||
.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("# "))
|
||||
|
|
@ -135,10 +262,10 @@ fn parse_source(path: &Path) -> Result<Source> {
|
|||
fm.confidence
|
||||
},
|
||||
body_html,
|
||||
cycle: cycle_slug.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
@ -158,11 +285,6 @@ fn extract_section(md: &str, heading: &str) -> Option<String> {
|
|||
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);
|
||||
|
|
@ -171,15 +293,10 @@ fn md_to_html(md: &str) -> String {
|
|||
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;
|
||||
|
|
@ -201,12 +318,6 @@ fn md_to_html(md: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
|
@ -214,7 +325,6 @@ fn md_to_html(md: &str) -> String {
|
|||
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}">"#));
|
||||
|
|
@ -237,24 +347,16 @@ fn short_hash(s: &str) -> String {
|
|||
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>> {
|
||||
fn parse_themes(index_path: &Path, cycle_slug: &str) -> 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)
|
||||
let mut current: Option<(String, String, String)> = None;
|
||||
|
||||
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));
|
||||
themes.push(finalize_theme(slug, title, desc, cycle_slug));
|
||||
}
|
||||
// e.g. "### 1. Emptiness / absence как носитель формы"
|
||||
let rest = rest.trim();
|
||||
let after_num = rest
|
||||
.split_once('.')
|
||||
|
|
@ -263,9 +365,8 @@ fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
|
|||
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));
|
||||
themes.push(finalize_theme(slug, title, desc, cycle_slug));
|
||||
}
|
||||
} else if let Some((_, _, desc)) = current.as_mut() {
|
||||
desc.push_str(line);
|
||||
|
|
@ -273,12 +374,17 @@ fn parse_themes(index_path: &Path) -> Result<Vec<Theme>> {
|
|||
}
|
||||
}
|
||||
if let Some((slug, title, desc)) = current.take() {
|
||||
themes.push(finalize_theme(slug, title, desc));
|
||||
themes.push(finalize_theme(slug, title, desc, cycle_slug));
|
||||
}
|
||||
Ok(themes)
|
||||
}
|
||||
|
||||
fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme {
|
||||
fn finalize_theme(
|
||||
slug: String,
|
||||
title: String,
|
||||
desc_md: String,
|
||||
cycle_slug: &str,
|
||||
) -> Theme {
|
||||
let contributing = parse_theme_contributors(&desc_md);
|
||||
let description_html = md_to_html(desc_md.trim());
|
||||
Theme {
|
||||
|
|
@ -286,16 +392,11 @@ fn finalize_theme(slug: String, title: String, desc_md: String) -> Theme {
|
|||
title,
|
||||
description_html,
|
||||
contributing,
|
||||
cycle: cycle_slug.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
};
|
||||
|
|
@ -308,7 +409,6 @@ fn parse_theme_contributors(desc: &str) -> Vec<String> {
|
|||
.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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! 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.
|
||||
//! hands a shared `Arc<BilingualCorpus>` to every render pass via
|
||||
//! Leptos context. Server functions also get the context so they can
|
||||
//! read corpus data during their SSR-side execution.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
|
|
@ -24,6 +26,8 @@ use tracing::info;
|
|||
use mantra_ui::app::{shell, App};
|
||||
use mantra_ui::corpus::{BilingualCorpus, BilingualHandle};
|
||||
|
||||
use crate::corpus_loader::CycleOneLang;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
|
|
@ -38,46 +42,34 @@ async fn main() -> Result<()> {
|
|||
std::env::var("MANTRA_CONTENT_DIR")
|
||||
.unwrap_or_else(|_| "content".to_string()),
|
||||
);
|
||||
info!("loading bilingual corpus from {}", content_dir.display());
|
||||
info!("loading design-dna 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 en = corpus_loader::load_corpus(&content_dir, CycleOneLang::En)?;
|
||||
let ru = corpus_loader::load_corpus(&content_dir, CycleOneLang::Ru)?;
|
||||
|
||||
let bilingual: BilingualHandle = Arc::new(BilingualCorpus {
|
||||
ru: Arc::new(ru),
|
||||
en: Arc::new(en),
|
||||
});
|
||||
for cyc in &bilingual.ru.cycles {
|
||||
info!(
|
||||
"loaded: ru={} sources / en={} sources, themes={}",
|
||||
bilingual.ru.sources.len(),
|
||||
bilingual.en.sources.len(),
|
||||
bilingual.ru.themes.len()
|
||||
"cycle ru={} sources={} themes={}",
|
||||
cyc.slug,
|
||||
cyc.sources.len(),
|
||||
cyc.themes.len()
|
||||
);
|
||||
}
|
||||
for cyc in &bilingual.en.cycles {
|
||||
info!(
|
||||
"cycle en={} sources={} themes={}",
|
||||
cyc.slug,
|
||||
cyc.sources.len(),
|
||||
cyc.themes.len()
|
||||
);
|
||||
}
|
||||
info!(
|
||||
"artifacts loaded: {}",
|
||||
bilingual.ru.artifacts.len()
|
||||
);
|
||||
|
||||
let conf = get_configuration(None)
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ pub mod types;
|
|||
|
||||
use leptos::prelude::*;
|
||||
|
||||
pub use types::{LandingData, NoteEntry, NoteKind, SourcePageData, ThemePageData};
|
||||
pub use types::{
|
||||
ArtifactPageData, ArtifactRef, CycleSnapshot, 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(
|
||||
|
|
@ -29,12 +28,13 @@ pub async fn fetch_source_page(
|
|||
.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 {
|
||||
let Some((cycle, src)) = corpus.get(&slug).map(|(c, s)| (c.clone(), s.clone())) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (prev, next) = corpus.neighbors(&slug);
|
||||
Ok(Some(SourcePageData {
|
||||
source: src,
|
||||
cycle,
|
||||
prev: prev.map(|s| s.to_string()),
|
||||
next: next.map(|s| s.to_string()),
|
||||
}))
|
||||
|
|
@ -57,16 +57,39 @@ pub async fn fetch_landing(lang: String) -> Result<LandingData, ServerFnError> {
|
|||
.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
|
||||
|
||||
let cycles = corpus
|
||||
.cycles
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let sources = c
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|slug| corpus.get(slug).cloned())
|
||||
.filter_map(|slug| c.sources.get(slug).cloned())
|
||||
.collect();
|
||||
Ok(LandingData {
|
||||
order: corpus.order.clone(),
|
||||
CycleSnapshot {
|
||||
slug: c.slug.clone(),
|
||||
title: c.title.clone(),
|
||||
subtitle: c.subtitle.clone(),
|
||||
order: c.order.clone(),
|
||||
sources,
|
||||
themes: corpus.themes.clone(),
|
||||
themes: c.themes.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let artifacts = corpus
|
||||
.artifacts
|
||||
.iter()
|
||||
.map(|a| ArtifactRef {
|
||||
slug: a.slug.clone(),
|
||||
title: a.title.clone(),
|
||||
subtitle: a.subtitle.clone(),
|
||||
closes_cycle: a.closes_cycle.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(LandingData { cycles, artifacts })
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
{
|
||||
|
|
@ -89,15 +112,47 @@ pub async fn fetch_theme_page(
|
|||
.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 {
|
||||
let Some((cycle, theme)) = corpus
|
||||
.get_theme(&slug)
|
||||
.map(|(c, t)| (c.clone(), t.clone())) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let contributing = theme
|
||||
.contributing
|
||||
.iter()
|
||||
.filter_map(|s| corpus.get(s).cloned())
|
||||
.filter_map(|s| cycle.sources.get(s).cloned())
|
||||
.collect();
|
||||
Ok(Some(ThemePageData { theme, contributing }))
|
||||
Ok(Some(ThemePageData {
|
||||
theme,
|
||||
cycle,
|
||||
contributing,
|
||||
}))
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
{
|
||||
let _ = (slug, lang);
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
// --- fetch_artifact ----------------------------------------------------
|
||||
|
||||
#[server(endpoint = "artifact_page")]
|
||||
pub async fn fetch_artifact(
|
||||
slug: String,
|
||||
lang: String,
|
||||
) -> Result<Option<ArtifactPageData>, 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(artifact) = corpus.get_artifact(&slug).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ArtifactPageData { artifact }))
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::corpus::{Source, Theme};
|
||||
use crate::corpus::{Artifact, Cycle, Source, Theme};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum NoteKind {
|
||||
|
|
@ -20,27 +20,53 @@ pub struct NoteEntry {
|
|||
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.
|
||||
/// A cycle-shaped snapshot for the landing page: everything needed to
|
||||
/// render one cycle's section (works list + themes list).
|
||||
#[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 struct CycleSnapshot {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub order: Vec<String>,
|
||||
pub sources: Vec<Source>,
|
||||
pub themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
/// Small artifact reference for landing-page listing (no body).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ArtifactRef {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub closes_cycle: Option<String>,
|
||||
}
|
||||
|
||||
/// Landing page snapshot — all cycles + all artifacts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LandingData {
|
||||
pub cycles: Vec<CycleSnapshot>,
|
||||
pub artifacts: Vec<ArtifactRef>,
|
||||
}
|
||||
|
||||
/// Snapshot of a single source + its neighbors for the source page.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SourcePageData {
|
||||
pub source: Source,
|
||||
pub cycle: Cycle,
|
||||
pub prev: Option<String>,
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
/// Snapshot for the theme page — one theme + contributing sources.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ThemePageData {
|
||||
pub theme: Theme,
|
||||
pub cycle: Cycle,
|
||||
pub contributing: Vec<Source>,
|
||||
}
|
||||
|
||||
/// Full artifact body for the artifact page.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ArtifactPageData {
|
||||
pub artifact: Artifact,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub fn App() -> impl IntoView {
|
|||
<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 />
|
||||
<Route path=path!("/artifact/:slug") view=pages::artifact::ArtifactPage />
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,103 @@
|
|||
//! 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
|
||||
//! The server crate owns the loaders 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.
|
||||
//!
|
||||
//! Library structure:
|
||||
//! Corpus
|
||||
//! └─ cycles: Vec<Cycle> ← each cycle is one research pass
|
||||
//! ├─ slug, title, subtitle
|
||||
//! ├─ order: reading order of source slugs
|
||||
//! ├─ sources: slug → Source (distillation)
|
||||
//! └─ themes: running patterns extracted from _index.md
|
||||
//! └─ artifacts: Vec<Artifact> ← closing documents (manifest, applied-principles)
|
||||
//! └─ slug, title, closes_cycle, body_html
|
||||
|
||||
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).
|
||||
/// One research cycle: a parallel distillation pass over a set of
|
||||
/// sources, bound by a root question. cycle-1-philosophy asks "why does
|
||||
/// form move the human heart?"; cycle-2-design-theory asks "how was
|
||||
/// that practiced by masters?".
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Cycle {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub order: Vec<String>,
|
||||
/// Slug → Source lookup.
|
||||
pub sources: HashMap<String, Source>,
|
||||
/// Running themes extracted from `_index.md`.
|
||||
pub themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
/// A closing document of a cycle — manifest (cycle 1), applied-principles
|
||||
/// (cycle 2), and future cycle 3's visual-signature document.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Artifact {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub closes_cycle: Option<String>,
|
||||
pub body_html: String,
|
||||
}
|
||||
|
||||
/// The entire loaded corpus. Shared via Leptos context as `Arc<Corpus>`
|
||||
/// on the server; UI reads via server fns + Resources.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Corpus {
|
||||
pub cycles: Vec<Cycle>,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
}
|
||||
|
||||
impl Corpus {
|
||||
pub fn get(&self, slug: &str) -> Option<&Source> {
|
||||
self.sources.get(slug)
|
||||
/// Find a source by slug across ALL cycles.
|
||||
pub fn get(&self, slug: &str) -> Option<(&Cycle, &Source)> {
|
||||
for cycle in &self.cycles {
|
||||
if let Some(src) = cycle.sources.get(slug) {
|
||||
return Some((cycle, src));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Neighbors (prev, next) within the same cycle — 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
|
||||
for cycle in &self.cycles {
|
||||
let Some(i) = cycle.order.iter().position(|s| s == slug) else {
|
||||
continue;
|
||||
};
|
||||
let next = if n > 1 {
|
||||
Some(self.order[(i + 1) % n].as_str())
|
||||
} else {
|
||||
let n = cycle.order.len();
|
||||
if n <= 1 {
|
||||
return (None, None);
|
||||
}
|
||||
let prev = Some(cycle.order[(i + n - 1) % n].as_str());
|
||||
let next = Some(cycle.order[(i + 1) % n].as_str());
|
||||
return (prev, next);
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Find a theme by slug across ALL cycles.
|
||||
pub fn get_theme(&self, slug: &str) -> Option<(&Cycle, &Theme)> {
|
||||
for cycle in &self.cycles {
|
||||
if let Some(t) = cycle.themes.iter().find(|t| t.slug == slug) {
|
||||
return Some((cycle, t));
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
(prev, next)
|
||||
}
|
||||
|
||||
/// Find an artifact by slug.
|
||||
pub fn get_artifact(&self, slug: &str) -> Option<&Artifact> {
|
||||
self.artifacts.iter().find(|a| a.slug == slug)
|
||||
}
|
||||
|
||||
/// Get a cycle by slug.
|
||||
pub fn get_cycle(&self, slug: &str) -> Option<&Cycle> {
|
||||
self.cycles.iter().find(|c| c.slug == slug)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +117,8 @@ pub struct Source {
|
|||
pub confidence: String,
|
||||
/// The full markdown body rendered to HTML.
|
||||
pub body_html: String,
|
||||
/// Which cycle this source belongs to.
|
||||
pub cycle: String,
|
||||
}
|
||||
|
||||
/// A running theme — pattern that proustep across multiple sources.
|
||||
|
|
@ -76,6 +130,8 @@ pub struct Theme {
|
|||
pub description_html: String,
|
||||
/// Sources that contribute to this theme (by slug).
|
||||
pub contributing: Vec<String>,
|
||||
/// Which cycle this theme belongs to.
|
||||
pub cycle: String,
|
||||
}
|
||||
|
||||
/// Convenience alias — we always thread the corpus through context as
|
||||
|
|
@ -104,9 +160,9 @@ impl Lang {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Bilingual container — same slugs in both languages where possible.
|
||||
/// Cycle 1 is bilingual; cycle 2 and artifacts are Russian-only, and
|
||||
/// both language variants point at the same data for those.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BilingualCorpus {
|
||||
pub ru: Arc<Corpus>,
|
||||
|
|
|
|||
162
crates/mantra-ui/src/pages/artifact.rs
Normal file
162
crates/mantra-ui/src/pages/artifact.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//! `/artifact/:slug` — closing document of a cycle.
|
||||
//!
|
||||
//! Currently renders: manifest.md (cycle 1) and applied-principles.md
|
||||
//! (cycle 2). Same book-grade typography as the source page, same
|
||||
//! paragraph-id → margin drawer wiring so readers can annotate the
|
||||
//! artifacts too.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::{use_params, use_query_map};
|
||||
use leptos_router::params::Params;
|
||||
|
||||
use crate::api::{fetch_artifact, ArtifactPageData};
|
||||
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 ArtifactPage() -> 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());
|
||||
|
||||
let data = Resource::new(
|
||||
move || (slug.get(), lang.get().as_str().to_string()),
|
||||
|(s, l)| fetch_artifact(s, l),
|
||||
);
|
||||
|
||||
#[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 label_back = match lang_val {
|
||||
Lang::Ru => "все источники",
|
||||
Lang::En => "all sources",
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(Some(ArtifactPageData { artifact })) => view! {
|
||||
<main class="source artifact">
|
||||
<nav class="source-breadcrumb">
|
||||
<a href=home_href.clone()>"design dna"</a>
|
||||
<span class="sep">" · "</span>
|
||||
<span class="source-author">{artifact.subtitle}</span>
|
||||
</nav>
|
||||
|
||||
<article
|
||||
class="source-body"
|
||||
inner_html=artifact.body_html
|
||||
on:click=move |ev| {
|
||||
if let Some(p) = paragraph_from_event(&ev) {
|
||||
set_active.set(Some(p));
|
||||
}
|
||||
}
|
||||
></article>
|
||||
|
||||
<footer class="source-foot">
|
||||
<a class="nav-home" href=home_href>{label_back}</a>
|
||||
</footer>
|
||||
</main>
|
||||
}.into_any(),
|
||||
Ok(None) => view! {
|
||||
<main class="source not-found">
|
||||
<p>"artifact 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
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
//! `/` — two-door entry into the corpus: works (20) and themes.
|
||||
//! `/` — two-door entry into the design-dna library.
|
||||
//!
|
||||
//! Shows all cycles (each with its works + themes columns) plus the
|
||||
//! artifacts strip. v0.2: cycle 1 (philosophy) + cycle 2 (design
|
||||
//! theory) + manifest + applied-principles.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_query_map;
|
||||
|
||||
use crate::api::{fetch_landing, LandingData};
|
||||
use crate::api::{fetch_landing, ArtifactRef, CycleSnapshot, LandingData};
|
||||
use crate::corpus::{Lang, Source, Theme};
|
||||
use crate::pages::shared::LangToggle;
|
||||
|
||||
|
|
@ -24,13 +28,17 @@ pub fn Landing() -> impl IntoView {
|
|||
Lang::En => "design dna",
|
||||
};
|
||||
let hero_subtitle = move || match lang.get() {
|
||||
Lang::Ru => "цикл 1 · философия · 20 источников",
|
||||
Lang::En => "cycle 1 · philosophy · 20 sources",
|
||||
Lang::Ru => "философия · теория дизайна · дистилляция",
|
||||
Lang::En => "philosophy · design theory · distillation",
|
||||
};
|
||||
let hero_question = move || match lang.get() {
|
||||
Lang::Ru => "почему форма трогает человеческое сердце?",
|
||||
Lang::En => "why does form move the human heart?",
|
||||
};
|
||||
let artifacts_label = move || match lang.get() {
|
||||
Lang::Ru => "артефакты",
|
||||
Lang::En => "artifacts",
|
||||
};
|
||||
let works_label = move || match lang.get() {
|
||||
Lang::Ru => "работы",
|
||||
Lang::En => "works",
|
||||
|
|
@ -39,10 +47,6 @@ pub fn Landing() -> impl IntoView {
|
|||
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">
|
||||
|
|
@ -57,49 +61,43 @@ pub fn Landing() -> impl IntoView {
|
|||
{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); }
|
||||
Ok(LandingData { cycles, artifacts }) => {
|
||||
view! {
|
||||
<section class="landing-grid">
|
||||
<div class="landing-col">
|
||||
<h2 class="landing-col-label">{works_label}</h2>
|
||||
<ul class="works-list">
|
||||
{if !artifacts.is_empty() {
|
||||
let art_label = artifacts_label();
|
||||
view! {
|
||||
<section class="landing-artifacts">
|
||||
<h2 class="landing-section-label">{art_label}</h2>
|
||||
<ul class="artifacts-list">
|
||||
<For
|
||||
each=move || order.clone()
|
||||
key=|slug| slug.clone()
|
||||
let:slug
|
||||
each=move || artifacts.clone()
|
||||
key=|a| a.slug.clone()
|
||||
let:artifact
|
||||
>
|
||||
<WorkLine
|
||||
source=by_slug.get(&slug).cloned()
|
||||
<ArtifactLine
|
||||
artifact=artifact
|
||||
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()
|
||||
} else { ().into_any() }}
|
||||
|
||||
<For
|
||||
each=move || cycles.clone()
|
||||
key=|c| c.slug.clone()
|
||||
let:cycle
|
||||
>
|
||||
<CycleSection
|
||||
cycle=cycle
|
||||
lang=lang_val
|
||||
works_label=works_label()
|
||||
themes_label=themes_label()
|
||||
/>
|
||||
</For>
|
||||
}.into_any()
|
||||
}
|
||||
Err(e) => view! { <p class="err">{format!("{e}")}</p> }.into_any(),
|
||||
}
|
||||
|
|
@ -110,6 +108,74 @@ pub fn Landing() -> impl IntoView {
|
|||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CycleSection(
|
||||
cycle: CycleSnapshot,
|
||||
lang: Lang,
|
||||
works_label: &'static str,
|
||||
themes_label: &'static str,
|
||||
) -> impl IntoView {
|
||||
let cycle_title = cycle.title.clone();
|
||||
let cycle_subtitle = cycle.subtitle.clone();
|
||||
let sources = cycle.sources.clone();
|
||||
let themes = cycle.themes.clone();
|
||||
let order = cycle.order.clone();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let sources_suffix = move |n: usize| match lang {
|
||||
Lang::Ru => format!(" · {n} источников"),
|
||||
Lang::En => format!(" · {n} sources"),
|
||||
};
|
||||
|
||||
view! {
|
||||
<section class="landing-cycle">
|
||||
<header class="landing-cycle-head">
|
||||
<h2 class="landing-cycle-title">{cycle_title}</h2>
|
||||
<p class="landing-cycle-subtitle">{cycle_subtitle}</p>
|
||||
</header>
|
||||
<div class="landing-grid">
|
||||
<div class="landing-col">
|
||||
<h3 class="landing-col-label">{works_label}</h3>
|
||||
<ul class="works-list">
|
||||
<For
|
||||
each=move || order.clone()
|
||||
key=|slug| slug.clone()
|
||||
let:slug
|
||||
>
|
||||
<WorkLine
|
||||
source=by_slug.get(&slug).cloned()
|
||||
lang=lang
|
||||
slug=slug
|
||||
/>
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="landing-col">
|
||||
<h3 class="landing-col-label">{themes_label}</h3>
|
||||
<ul class="themes-list">
|
||||
<For
|
||||
each=move || themes.clone()
|
||||
key=|t| t.slug.clone()
|
||||
let:theme
|
||||
>
|
||||
<ThemeLine
|
||||
theme=theme
|
||||
lang=lang
|
||||
suffix=sources_suffix.clone()
|
||||
/>
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn WorkLine(source: Option<Source>, slug: String, lang: Lang) -> impl IntoView {
|
||||
let href = format!("/source/{slug}?lang={}", lang.as_str());
|
||||
|
|
@ -144,3 +210,17 @@ where
|
|||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ArtifactLine(artifact: ArtifactRef, lang: Lang) -> impl IntoView {
|
||||
let href = format!("/artifact/{}?lang={}", artifact.slug, lang.as_str());
|
||||
view! {
|
||||
<li class="artifact-line">
|
||||
<a href=href>
|
||||
<span class="artifact-title">{artifact.title}</span>
|
||||
<span class="artifact-sep">" · "</span>
|
||||
<span class="artifact-subtitle">{artifact.subtitle}</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod artifact;
|
||||
pub mod landing;
|
||||
pub mod margin;
|
||||
pub mod shared;
|
||||
|
|
|
|||
|
|
@ -69,10 +69,6 @@ pub fn SourcePage() -> impl IntoView {
|
|||
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",
|
||||
|
|
@ -88,7 +84,7 @@ pub fn SourcePage() -> impl IntoView {
|
|||
|
||||
match res {
|
||||
Ok(Some(d)) => {
|
||||
let SourcePageData { source: src, prev, next } = d;
|
||||
let SourcePageData { source: src, cycle, 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! {
|
||||
|
|
@ -96,7 +92,7 @@ pub fn SourcePage() -> impl IntoView {
|
|||
<nav class="source-breadcrumb">
|
||||
<a href=home_href.clone()>"design dna"</a>
|
||||
<span class="sep">" · "</span>
|
||||
<span>{breadcrumb_cycle}</span>
|
||||
<span>{cycle.subtitle}</span>
|
||||
<span class="sep">" · "</span>
|
||||
<span class="source-author">{src.author}</span>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -51,11 +51,13 @@ pub fn ThemePage() -> impl IntoView {
|
|||
};
|
||||
|
||||
match res {
|
||||
Ok(Some(ThemePageData { theme, contributing })) => view! {
|
||||
Ok(Some(ThemePageData { theme, cycle, contributing })) => view! {
|
||||
<main class="theme">
|
||||
<nav class="source-breadcrumb">
|
||||
<a href=home_href.clone()>"design dna"</a>
|
||||
<span class="sep">" · "</span>
|
||||
<span>{cycle.subtitle}</span>
|
||||
<span class="sep">" · "</span>
|
||||
<span>{theme_label}</span>
|
||||
</nav>
|
||||
<h1 class="theme-h1">{theme.title}</h1>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ main {
|
|||
// --- Landing --------------------------------------------------------
|
||||
|
||||
.landing {
|
||||
max-width: 72ch;
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
.landing-head { margin-bottom: 4rem; }
|
||||
|
|
@ -148,6 +148,83 @@ main {
|
|||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.landing-section-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.5rem;
|
||||
}
|
||||
|
||||
.landing-artifacts {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
.artifacts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
border-top: 1px solid var(--mantra-hairline);
|
||||
border-bottom: 1px solid var(--mantra-hairline);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.artifact-line a {
|
||||
display: block;
|
||||
border-bottom: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.artifact-title {
|
||||
font-family: var(--mantra-serif);
|
||||
font-variation-settings: "opsz" 22, "SOFT" 40, "WONK" 0, "wght" 520;
|
||||
font-size: 1.05rem;
|
||||
color: var(--mantra-fg);
|
||||
}
|
||||
.artifact-sep { color: var(--mantra-faint); font-size: 0.85rem; }
|
||||
.artifact-subtitle {
|
||||
font-style: italic;
|
||||
color: var(--mantra-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.artifact-line a:hover .artifact-title { color: var(--mantra-accent); }
|
||||
|
||||
.landing-cycle {
|
||||
margin-bottom: 4.5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--mantra-hairline);
|
||||
|
||||
&:first-of-type { border-top: none; padding-top: 0; }
|
||||
}
|
||||
|
||||
.landing-cycle-head {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.landing-cycle-title {
|
||||
font-family: var(--mantra-serif);
|
||||
font-variation-settings: "opsz" 60, "SOFT" 50, "WONK" 0, "wght" 400;
|
||||
font-size: clamp(1.6rem, 3vw, 2.1rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
color: var(--mantra-fg);
|
||||
}
|
||||
|
||||
.landing-cycle-subtitle {
|
||||
font-family: var(--mantra-sans);
|
||||
font-variation-settings: "wght" 400;
|
||||
font-size: 0.72rem;
|
||||
color: var(--mantra-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.landing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue