feat(blog): make posts interactible and render markdown contents
This commit is contained in:
parent
ccf08c5511
commit
0383f796e2
10 changed files with 1496 additions and 696 deletions
|
@ -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
1966
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
33
src/app.rs
33
src/app.rs
|
@ -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)
|
||||||
|
|
|
@ -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
79
src/components/blog.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)>>())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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))]
|
||||||
|
|
Loading…
Add table
Reference in a new issue