forked from DevComp/ssh-portfolio
feat(blog): design a nicer layout and make post rendering functional
This commit is contained in:
parent
0383f796e2
commit
ad38a34c5a
5 changed files with 110 additions and 47 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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)>>())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue