forked from DevComp/ssh-portfolio
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
|
||||
"<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
1966
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
29
src/app.rs
29
src/app.rs
|
@ -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)
|
||||
|
|
|
@ -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
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)
|
||||
}
|
||||
|
||||
/// 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)>>())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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))]
|
||||
|
|
Loading…
Add table
Reference in a new issue