feat(blog): make posts interactible and render markdown contents

This commit is contained in:
Erica Marigold 2025-08-15 12:41:24 +01:00
parent ccf08c5511
commit 0383f796e2
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
10 changed files with 1496 additions and 696 deletions

View file

@ -10,6 +10,7 @@
"<left>": "PrevTab", // Go to the previous tab "<left>": "PrevTab", // Go to the previous tab
"<down>": "SelectNext", // Go to the next selection in options "<down>": "SelectNext", // Go to the next selection in options
"<up>": "SelectPrev", // Go to the previous selection in options "<up>": "SelectPrev", // Go to the previous selection in options
"<enter>": "Continue", // Continue with the current selection
}, },
} }
} }

1966
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ blog = [
"dep:atrium-common", "dep:atrium-common",
"dep:reqwest", "dep:reqwest",
"dep:ipld-core", "dep:ipld-core",
"dep:tui-markdown",
] ]
[dependencies] [dependencies]
@ -37,7 +38,7 @@ clap = { version = "4.5.20", features = [
"unstable-styles", "unstable-styles",
] } ] }
color-eyre = "0.6.3" color-eyre = "0.6.3"
config = "0.14.0" config = "0.15.14"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
derive_deref = "1.1.1" derive_deref = "1.1.1"
directories = "5.0.1" directories = "5.0.1"
@ -65,6 +66,7 @@ tokio-util = "0.7.12"
tracing = "0.1.40" tracing = "0.1.40"
tracing-error = "0.2.0" tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
tui-markdown = { version = "0.3.5", optional = true }
[build-dependencies] [build-dependencies]
anyhow = "1.0.90" anyhow = "1.0.90"

View file

@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize}; use std::fmt;
use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize};
use strum::Display; use strum::Display;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)]
#[allow(dead_code)]
pub enum Action { pub enum Action {
Tick, Tick,
Render, Render,
@ -20,4 +23,65 @@ pub enum Action {
// Selection management // Selection management
SelectNext, SelectNext,
SelectPrev, SelectPrev,
Continue(Option<usize>),
}
// HACK: should probably make this nicer
impl<'de> Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>
{
struct ActionVisitor;
impl<'de> Visitor<'de> for ActionVisitor {
type Value = Action;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "an Action enum variant")
}
fn visit_str<E>(self, v: &str) -> Result<Action, E>
where E: de::Error
{
if v == "Continue" {
Ok(Action::Continue(None))
} else {
// fallback: let serde handle all other strings
#[derive(Deserialize)]
enum Helper {
Tick,
Render,
Suspend,
Resume,
Quit,
ClearScreen,
Help,
NextTab,
PrevTab,
SelectNext,
SelectPrev,
}
let helper: Helper = serde_json::from_str(&format!("\"{}\"", v))
.map_err(|_| de::Error::unknown_variant(v, &["Continue"]))?;
Ok(match helper {
Helper::Tick => Action::Tick,
Helper::Render => Action::Render,
Helper::Suspend => Action::Suspend,
Helper::Resume => Action::Resume,
Helper::Quit => Action::Quit,
Helper::ClearScreen => Action::ClearScreen,
Helper::Help => Action::Help,
Helper::NextTab => Action::NextTab,
Helper::PrevTab => Action::PrevTab,
Helper::SelectNext => Action::SelectNext,
Helper::SelectPrev => Action::SelectPrev,
})
}
}
}
deserializer.deserialize_any(ActionVisitor)
}
} }

View file

@ -41,7 +41,7 @@ pub struct App {
content: Arc<Mutex<Content>>, content: Arc<Mutex<Content>>,
cat: Arc<Mutex<Cat>>, cat: Arc<Mutex<Cat>>,
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
selection_list: Arc<Mutex<SelectionList>>, blog_posts: Arc<Mutex<BlogPosts>>,
} }
#[derive( #[derive(
@ -76,8 +76,8 @@ impl App {
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
let rt = tokio::runtime::Handle::current(); let rt = tokio::runtime::Handle::current();
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
let selection_list = Arc::new(Mutex::new(SelectionList::new( let blog_posts = Arc::new(Mutex::new(BlogPosts::new(
rt.block_on(content.blocking_lock().blog_content())?, rt.block_on(content.try_lock()?.blog_content())?,
))); )));
Ok(Self { Ok(Self {
@ -100,7 +100,7 @@ impl App {
content, content,
cat, cat,
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
selection_list, blog_posts,
}) })
} }
@ -134,7 +134,11 @@ impl App {
self.cat self.cat
.try_lock()? .try_lock()?
.register_action_handler(self.action_tx.clone())?; .register_action_handler(self.action_tx.clone())?;
#[cfg(feature = "blog")]
self.blog_posts
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
// Register config handlers // Register config handlers
self.tabs self.tabs
.try_lock()? .try_lock()?
@ -145,13 +149,20 @@ impl App {
self.cat self.cat
.try_lock()? .try_lock()?
.register_config_handler(self.config.clone())?; .register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")]
self.blog_posts
.try_lock()?
.register_config_handler(self.config.clone())?;
// Initialize components // Initialize components
let size = tui.terminal.try_lock()?.size()?; let size = tui.terminal.try_lock()?.size()?;
self.tabs.try_lock()?.init(size)?; self.tabs.try_lock()?.init(size)?;
self.content.try_lock()?.init(size)?; self.content.try_lock()?.init(size)?;
#[cfg(feature = "blog")]
self.cat.try_lock()?.init(size)?; self.cat.try_lock()?.init(size)?;
self.blog_posts.try_lock()?.init(size)?;
Ok::<_, eyre::Error>(()) Ok::<_, eyre::Error>(())
})?; })?;
@ -247,7 +258,11 @@ impl App {
Action::Tick => { Action::Tick => {
self.last_tick_key_events.drain(..); self.last_tick_key_events.drain(..);
} }
Action::Quit => self.should_quit = true, Action::Quit => {
if !self.blog_posts.try_lock()?.is_in_post() {
self.should_quit = true;
}
}
Action::Suspend => self.should_suspend = true, Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false, Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.try_lock()?.clear()?, Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
@ -277,7 +292,7 @@ impl App {
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
if let Some(action) = if let Some(action) =
self.selection_list.try_lock()?.update(action.clone())? self.blog_posts.try_lock()?.update(action.clone())?
{ {
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
@ -412,7 +427,7 @@ impl App {
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
{ {
// Render the post selection list if the blog tab is selected // Render the post selection list if the blog tab is selected
self.selection_list self.blog_posts
.try_lock() .try_lock()
.map_err(std::io::Error::other)? .map_err(std::io::Error::other)?
.draw(frame, content_rect) .draw(frame, content_rect)

View file

@ -11,13 +11,16 @@ use crate::tui::Event;
// //
// Component re-exports // Component re-exports
// //
#[cfg(feature = "blog")]
mod blog;
mod cat; mod cat;
mod content; mod content;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
mod selection_list; mod selection_list;
mod tabs; mod tabs;
#[cfg(feature = "blog")]
pub use blog::*;
pub use cat::*; pub use cat::*;
pub use content::*; pub use content::*;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]

79
src/components/blog.rs Normal file
View file

@ -0,0 +1,79 @@
use color_eyre::Result;
use ratatui::widgets::Widget;
use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action;
use crate::components::{Component, SelectionList};
#[derive(Debug)]
pub struct BlogPosts {
titles: SelectionList,
contents: Vec<String>,
in_post: Option<usize>,
}
impl BlogPosts {
pub fn new(posts: Vec<(String, String)>) -> Self {
let (titles, contents): (Vec<String>, Vec<String>) =
posts.iter().cloned().unzip();
Self { titles: SelectionList::new(titles), contents, in_post: None }
}
pub fn is_in_post(&self) -> bool {
self.in_post.is_some()
}
}
impl Component for BlogPosts {
fn register_config_handler(
&mut self,
config: crate::config::Config,
) -> Result<()> {
self.titles.register_config_handler(config)
}
fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.titles.register_action_handler(tx)
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match self.titles.update(action.clone())?.unwrap() {
// safe to unwrap, guaranteed to not be `None`
Action::Tick => {}
Action::Render => {}
Action::Quit | Action::PrevTab | Action::NextTab => {
self.in_post = None
}
Action::Continue(post_id) => self.in_post = post_id,
_ => {}
};
Ok(None)
}
fn draw(
&mut self,
frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect,
) -> Result<()> {
if let Some(post_id_inner) = self.in_post {
let post_body = self
.contents
.get(post_id_inner)
.cloned()
.unwrap_or("404 - Blog not found!".to_string());
let post_widget = tui_markdown::from_str(&post_body);
post_widget.render(area, frame.buffer_mut());
} else {
self.titles.draw(frame, area)?;
}
Ok(())
}
}

View file

@ -204,6 +204,7 @@ impl Content {
Ok(content) Ok(content)
} }
/// Generate the content for the "Projects" tab /// Generate the content for the "Projects" tab
fn projects_content(&self) -> Vec<Line<'static>> { fn projects_content(&self) -> Vec<Line<'static>> {
vec![Line::from("WIP")] vec![Line::from("WIP")]
@ -211,12 +212,16 @@ impl Content {
/// Generate the content for the "Blog" tab /// Generate the content for the "Blog" tab
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub async fn blog_content(&self) -> Result<Vec<String>> { pub async fn blog_content(&self) -> Result<Vec<(String, String)>> {
Ok(crate::atproto::blog::get_all_posts() Ok(crate::atproto::blog::get_all_posts()
.await? .await?
.iter() .iter()
.map(|post| post.title.clone().unwrap_or("<unknown>".to_string())) .map_while(|post| {
.collect::<Vec<String>>()) post.title
.clone()
.map(|title| (title, post.content.clone()))
})
.collect::<Vec<(String, String)>>())
} }
} }

View file

@ -7,12 +7,12 @@ use crate::action::Action;
use crate::components::Component; use crate::components::Component;
use crate::config::Config; use crate::config::Config;
#[derive(Default)] #[derive(Default, Debug)]
pub struct SelectionList { pub struct SelectionList {
command_tx: Option<UnboundedSender<Action>>,
config: Config, config: Config,
options: List<'static>, options: List<'static>,
list_state: ListState, list_state: ListState,
action_tx: Option<UnboundedSender<Action>>,
} }
impl SelectionList { impl SelectionList {
@ -30,16 +30,17 @@ impl SelectionList {
} }
impl Component for SelectionList { impl Component for SelectionList {
fn register_config_handler(&mut self, config: Config) -> Result<()> {
tracing::warn!("handle config");
self.config = config;
Ok(())
}
fn register_action_handler( fn register_action_handler(
&mut self, &mut self,
tx: UnboundedSender<Action>, tx: UnboundedSender<Action>,
) -> Result<()> { ) -> Result<()> {
self.command_tx = Some(tx); self.action_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(()) Ok(())
} }
@ -47,12 +48,15 @@ impl Component for SelectionList {
match action { match action {
Action::Tick => {} Action::Tick => {}
Action::Render => {} Action::Render => {}
Action::Continue(None) => if let Some(tx) = &self.action_tx {
tx.send(Action::Continue(self.list_state.selected()))?;
}
Action::SelectNext => self.list_state.select_next(), Action::SelectNext => self.list_state.select_next(),
Action::SelectPrev => self.list_state.select_previous(), Action::SelectPrev => self.list_state.select_previous(),
_ => {} _ => {}
}; };
Ok(None) Ok(Some(action))
} }
fn draw( fn draw(

View file

@ -44,7 +44,8 @@ pub fn init() -> Result<()> {
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy() env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy()
}) })
.add_directive("russh::cipher=info".parse().unwrap()); .add_directive("russh::cipher=info".parse().unwrap())
.add_directive("tui_markdown=info".parse().unwrap());
// Stage 3: Enable directives to reduce verbosity for release mode builds // Stage 3: Enable directives to reduce verbosity for release mode builds
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]