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
"<down>": "SelectNext", // Go to the next 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:reqwest",
"dep:ipld-core",
"dep:tui-markdown",
]
[dependencies]
@ -37,7 +38,7 @@ clap = { version = "4.5.20", features = [
"unstable-styles",
] }
color-eyre = "0.6.3"
config = "0.14.0"
config = "0.15.14"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
derive_deref = "1.1.1"
directories = "5.0.1"
@ -65,6 +66,7 @@ tokio-util = "0.7.12"
tracing = "0.1.40"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
tui-markdown = { version = "0.3.5", optional = true }
[build-dependencies]
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;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)]
#[allow(dead_code)]
pub enum Action {
Tick,
Render,
@ -20,4 +23,65 @@ pub enum Action {
// Selection management
SelectNext,
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>>,
cat: Arc<Mutex<Cat>>,
#[cfg(feature = "blog")]
selection_list: Arc<Mutex<SelectionList>>,
blog_posts: Arc<Mutex<BlogPosts>>,
}
#[derive(
@ -76,8 +76,8 @@ impl App {
#[cfg(feature = "blog")]
let rt = tokio::runtime::Handle::current();
#[cfg(feature = "blog")]
let selection_list = Arc::new(Mutex::new(SelectionList::new(
rt.block_on(content.blocking_lock().blog_content())?,
let blog_posts = Arc::new(Mutex::new(BlogPosts::new(
rt.block_on(content.try_lock()?.blog_content())?,
)));
Ok(Self {
@ -100,7 +100,7 @@ impl App {
content,
cat,
#[cfg(feature = "blog")]
selection_list,
blog_posts,
})
}
@ -134,6 +134,10 @@ impl App {
self.cat
.try_lock()?
.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
self.tabs
@ -145,13 +149,20 @@ impl App {
self.cat
.try_lock()?
.register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")]
self.blog_posts
.try_lock()?
.register_config_handler(self.config.clone())?;
// Initialize components
let size = tui.terminal.try_lock()?.size()?;
self.tabs.try_lock()?.init(size)?;
self.content.try_lock()?.init(size)?;
#[cfg(feature = "blog")]
self.cat.try_lock()?.init(size)?;
self.blog_posts.try_lock()?.init(size)?;
Ok::<_, eyre::Error>(())
})?;
@ -247,7 +258,11 @@ impl App {
Action::Tick => {
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::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
@ -277,7 +292,7 @@ impl App {
#[cfg(feature = "blog")]
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)?;
}
@ -412,7 +427,7 @@ impl App {
#[cfg(feature = "blog")]
{
// Render the post selection list if the blog tab is selected
self.selection_list
self.blog_posts
.try_lock()
.map_err(std::io::Error::other)?
.draw(frame, content_rect)

View file

@ -11,13 +11,16 @@ use crate::tui::Event;
//
// Component re-exports
//
#[cfg(feature = "blog")]
mod blog;
mod cat;
mod content;
#[cfg(feature = "blog")]
mod selection_list;
mod tabs;
#[cfg(feature = "blog")]
pub use blog::*;
pub use cat::*;
pub use content::*;
#[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)
}
/// Generate the content for the "Projects" tab
fn projects_content(&self) -> Vec<Line<'static>> {
vec![Line::from("WIP")]
@ -211,12 +212,16 @@ impl Content {
/// Generate the content for the "Blog" tab
#[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()
.await?
.iter()
.map(|post| post.title.clone().unwrap_or("<unknown>".to_string()))
.collect::<Vec<String>>())
.map_while(|post| {
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::config::Config;
#[derive(Default)]
#[derive(Default, Debug)]
pub struct SelectionList {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
options: List<'static>,
list_state: ListState,
action_tx: Option<UnboundedSender<Action>>,
}
impl SelectionList {
@ -30,16 +30,17 @@ impl 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(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
self.action_tx = Some(tx);
Ok(())
}
@ -47,12 +48,15 @@ impl Component for SelectionList {
match action {
Action::Tick => {}
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::SelectPrev => self.list_state.select_previous(),
_ => {}
};
Ok(None)
Ok(Some(action))
}
fn draw(

View file

@ -44,7 +44,8 @@ pub fn init() -> Result<()> {
.unwrap_or_else(|_| {
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
#[cfg(not(debug_assertions))]