feat(blog): design a nicer layout and make post rendering functional

This commit is contained in:
Erica Marigold 2025-08-15 19:02:20 +01:00
parent 0383f796e2
commit ad38a34c5a
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
5 changed files with 110 additions and 47 deletions

1
Cargo.lock generated
View file

@ -5308,6 +5308,7 @@ dependencies = [
"atrium-xrpc-client", "atrium-xrpc-client",
"better-panic", "better-panic",
"bstr", "bstr",
"chrono",
"clap", "clap",
"color-eyre", "color-eyre",
"config", "config",

View file

@ -17,6 +17,7 @@ blog = [
"dep:reqwest", "dep:reqwest",
"dep:ipld-core", "dep:ipld-core",
"dep:tui-markdown", "dep:tui-markdown",
"dep:chrono"
] ]
[dependencies] [dependencies]
@ -29,6 +30,7 @@ atrium-xrpc = { version = "0.12.3", optional = true }
atrium-xrpc-client = { version = "0.5.14", optional = true } atrium-xrpc-client = { version = "0.5.14", optional = true }
better-panic = "0.3.0" better-panic = "0.3.0"
bstr = "1.11.3" bstr = "1.11.3"
chrono = { version = "0.4.41", optional = true }
clap = { version = "4.5.20", features = [ clap = { version = "4.5.20", features = [
"derive", "derive",
"cargo", "cargo",

View file

@ -1,23 +1,29 @@
use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action; use crate::action::Action;
use crate::com;
use crate::components::{Component, SelectionList}; use crate::components::{Component, SelectionList};
pub type Post = Arc<com::whtwnd::blog::entry::Record>;
#[derive(Debug)] #[derive(Debug)]
pub struct BlogPosts { pub struct BlogPosts {
titles: SelectionList, list: SelectionList<Post>,
contents: Vec<String>, posts: Vec<Post>,
in_post: Option<usize>, in_post: Option<usize>,
} }
impl BlogPosts { impl BlogPosts {
pub fn new(posts: Vec<(String, String)>) -> Self { pub fn new(posts: Vec<Post>) -> Self {
let (titles, contents): (Vec<String>, Vec<String>) = let posts_ref = posts.to_vec();
posts.iter().cloned().unzip(); Self {
list: SelectionList::new(posts),
Self { titles: SelectionList::new(titles), contents, in_post: None } posts: posts_ref,
in_post: None,
}
} }
pub fn is_in_post(&self) -> bool { pub fn is_in_post(&self) -> bool {
@ -30,18 +36,18 @@ impl Component for BlogPosts {
&mut self, &mut self,
config: crate::config::Config, config: crate::config::Config,
) -> Result<()> { ) -> Result<()> {
self.titles.register_config_handler(config) self.list.register_config_handler(config)
} }
fn register_action_handler( fn register_action_handler(
&mut self, &mut self,
tx: UnboundedSender<Action>, tx: UnboundedSender<Action>,
) -> Result<()> { ) -> Result<()> {
self.titles.register_action_handler(tx) self.list.register_action_handler(tx)
} }
fn update(&mut self, action: Action) -> Result<Option<Action>> { fn update(&mut self, action: Action) -> Result<Option<Action>> {
match self.titles.update(action.clone())?.unwrap() { match self.list.update(action.clone())?.unwrap() {
// safe to unwrap, guaranteed to not be `None` // safe to unwrap, guaranteed to not be `None`
Action::Tick => {} Action::Tick => {}
Action::Render => {} Action::Render => {}
@ -63,15 +69,16 @@ impl Component for BlogPosts {
) -> Result<()> { ) -> Result<()> {
if let Some(post_id_inner) = self.in_post { if let Some(post_id_inner) = self.in_post {
let post_body = self let post_body = self
.contents .posts
.get(post_id_inner) .get(post_id_inner)
.cloned() .map_or(String::from("404 - Blog not found!"), |post| {
.unwrap_or("404 - Blog not found!".to_string()); post.content.clone()
});
let post_widget = tui_markdown::from_str(&post_body); let post_widget = tui_markdown::from_str(&post_body);
post_widget.render(area, frame.buffer_mut()); post_widget.render(area, frame.buffer_mut());
} else { } else {
self.titles.draw(frame, area)?; self.list.draw(frame, area)?;
} }
Ok(()) Ok(())

View file

@ -10,6 +10,8 @@ use tokio::sync::mpsc::UnboundedSender;
use super::Component; use super::Component;
use crate::action::Action; use crate::action::Action;
#[cfg(feature = "blog")]
use crate::components::Post;
use crate::config::Config; use crate::config::Config;
#[derive(Default)] #[derive(Default)]
@ -212,16 +214,12 @@ 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, String)>> { pub async fn blog_content(&self) -> Result<Vec<Post>> {
Ok(crate::atproto::blog::get_all_posts() Ok(crate::atproto::blog::get_all_posts()
.await? .await?
.iter() .iter()
.map_while(|post| { .map(|post| Arc::new(post.clone()))
post.title .collect())
.clone()
.map(|title| (title, post.content.clone()))
})
.collect::<Vec<(String, String)>>())
} }
} }

View file

@ -1,45 +1,44 @@
use chrono::DateTime;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{List, ListState}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action; use crate::action::Action;
use crate::components::Component; use crate::components::{Component, Post};
use crate::config::Config; use crate::config::Config;
#[derive(Default, Debug)] fn truncate(s: &str, max: usize) -> String {
pub struct SelectionList { s.char_indices()
.find(|(idx, ch)| idx + ch.len_utf8() > max)
.map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...")
}
#[derive(Debug)]
pub struct SelectionList<T> {
config: Config, config: Config,
options: List<'static>, pub(super) options: Vec<T>,
list_state: ListState, pub(super) list_state: ListState,
action_tx: Option<UnboundedSender<Action>>, action_tx: Option<UnboundedSender<Action>>,
} }
impl SelectionList { impl<T> SelectionList<T> {
pub fn new(options: Vec<String>) -> Self { pub fn new(options: Vec<T>) -> Self {
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select_first(); list_state.select_first();
Self { Self { config: Config::default(), options, list_state, action_tx: None }
options: List::new(options)
.highlight_style(Style::default().fg(Color::Yellow)),
list_state,
..Default::default()
}
} }
} }
impl Component for SelectionList { impl Component for SelectionList<Post> {
fn register_config_handler(&mut self, config: Config) -> Result<()> { fn register_config_handler(&mut self, config: Config) -> Result<()> {
tracing::warn!("handle config");
self.config = config; self.config = config;
Ok(()) Ok(())
} }
fn register_action_handler( fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.action_tx = Some(tx); self.action_tx = Some(tx);
Ok(()) Ok(())
} }
@ -48,8 +47,10 @@ 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 { Action::Continue(None) => {
tx.send(Action::Continue(self.list_state.selected()))?; 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(),
@ -64,8 +65,62 @@ impl Component for SelectionList {
frame: &mut ratatui::Frame, frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect, area: ratatui::prelude::Rect,
) -> Result<()> { ) -> Result<()> {
let items = self.options.iter().enumerate().map(|(i, post)| {
let bold_style = Style::default().add_modifier(Modifier::BOLD);
let accent_style = bold_style.fg(Color::LightMagenta);
let post_creation_date = post
.created_at
.as_ref()
.map(|dt| DateTime::parse_from_rfc3339(dt.as_str()))
.and_then(Result::ok)
.map_or(DateTime::UNIX_EPOCH.date_naive().to_string(), |dt| {
dt.date_naive().to_string()
});
let arrow_or_pad =
if self.list_state.selected().is_some_and(|selection| selection == i) {
"".to_string()
} else {
format!("{:>2}", " ")
};
let padded_date = format!("{:>10}", post_creation_date);
let title_spans = vec![
Span::styled(arrow_or_pad, accent_style),
Span::raw(" "),
Span::styled(padded_date, bold_style),
Span::styled("", accent_style),
Span::styled(
post.title.clone().unwrap_or("[object Object]".to_string()), // LMAOOO
accent_style,
),
];
let mut list_content = vec![Line::from(title_spans)];
let line_format = [
Span::raw(format!("{:>14}", " ")),
Span::styled("", Style::default().add_modifier(Modifier::DIM)),
];
let subtitle_span = Span::raw(
[" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))]
.concat(),
);
list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].concat()));
list_content.push(Line::from([line_format.as_slice(), &[Span::raw("")]].concat()));
ListItem::new(list_content)
});
frame.render_stateful_widget( frame.render_stateful_widget(
self.options.clone(), List::new(items)
.block(Block::default().borders(Borders::NONE))
.highlight_style(Style::default())
.highlight_symbol(""),
area, area,
&mut self.list_state, &mut self.list_state,
); );