use serde::Deserialize;
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::collections::HashMap;
#[derive(Deserialize)]
struct Story {
title: String,
url: Option<String>,
score: u32,
descendants: Option<u32>,
}
struct App {
stories: HashMap<String, Vec<Story>>,
selected: u32,
feed: String,
}
impl App{
fn new(stories: HashMap<String, Vec<Story>>) -> App {
App { stories, selected: 0, feed: "top".to_string() }
}
}
fn setup_terminal() -> Terminal<CrosstermBackend<io::Stdout>> {
enable_raw_mode().expect("Failed to enable raw mode");
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).expect("Failed to enter alternate screen");
let backend = CrosstermBackend::new(io::stdout());
Terminal::new(backend).expect("Failed to create terminal")
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) {
disable_raw_mode().expect("Failed to disable raw mode");
execute!(terminal.backend_mut(), LeaveAlternateScreen).expect("Failed to leave alternate screen");
}
fn fetch_ids(feed: &str) -> Vec<u64>{
let client = reqwest::blocking::Client::new();
let url = format!("https://hacker-news.firebaseio.com/{}.json", feed);
let ids: Vec<u64> = client
.get(url)
.send()
.expect("Failed to fetch top stories")
.json()
.expect("Failed to parse story IDs");
ids
}
fn fetch_story(client: &reqwest::blocking::Client, id: u64) -> Story {
let url = format!("https://hacker-news.firebaseio.com/v0/item/{id}.json");
let story: Story = client
.get(&url)
.send()
.expect("Failed to fetch story")
.json()
.expect("Failed to parse story");
story
}
fn ui(f: &mut ratatui::Frame, app: &App) {
use ratatui::{
layout::{Constraint, Direction, Layout},
widgets::{Block, Borders, Paragraph, List, ListItem},
style::{Style, Color, Modifier},
text::Line,
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(f.area());
let header = Paragraph::new("
██╗ ██╗ █████╗ ██████╗██╗ ██╗███████╗██████╗ ███╗ ██╗███████╗██╗ ██╗███████╗
██║ ██║██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗████╗ ██║██╔════╝██║ ██║██╔════╝
███████║███████║██║ █████╔╝ █████╗ ██████╔╝██╔██╗ ██║█████╗ ██║ █╗ ██║███████╗
██╔══██║██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ██║███╗██║╚════██║
██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║██║ ╚████║███████╗╚███╔███╔╝███████║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝ ╚══╝╚══╝ ╚══════╝
")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
f.render_widget(header, chunks[0]);
let footer = Paragraph::new("↑↓ Navigate | Enter: Open | Tab: Switch Feed | Q: Quit")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray));
f.render_widget(footer, chunks[3]);
let feeds = ["top", "new", "best", "ask", "show"];
let feed_spans: Vec<ratatui::text::Span> = feeds.iter().map(|f| {
if *f == app.feed.as_str() {
ratatui::text::Span::styled(
format!(" {} ", f),
Style::default().fg(Color::Black).bg(Color::Yellow),
)
} else {
ratatui::text::Span::styled(
format!(" {} ", f),
Style::default().fg(Color::White),
)
}
}).collect();
let feed_selector = Paragraph::new(Line::from(feed_spans))
.block(Block::default().borders(Borders::ALL).title(" Feeds "));
f.render_widget(feed_selector, chunks[1]);
let current_stories = app.stories.get(&app.feed).map(|v| v.as_slice()).unwrap_or(&[]);
let items: Vec<ListItem> = current_stories.iter().enumerate().map(|(i, story)| {
let style = if i == app.selected as usize {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
ListItem::new(format!(
"{}. {} ({} pts | {} comments)",
i + 1,
story.title,
story.score,
story.descendants.unwrap_or(0)
)).style(style)
}).collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Stories "));
f.render_widget(list, chunks[2]);
}
fn main(){
let client = reqwest::blocking::Client::new();
let feeds = ["top", "new", "best", "ask", "show"];
let mut all_stories: HashMap<String, Vec<Story>> = HashMap::new();
for feed in feeds.iter() {
let ids = fetch_ids(&format!("v0/{}stories", feed));
let mut stories: Vec<Story> = Vec::new();
for id in ids.iter().take(10) {
let story = fetch_story(&client, *id);
stories.push(story);
}
all_stories.insert(feed.to_string(), stories);
}
let mut app = App::new(all_stories);
let mut terminal = setup_terminal();
loop{
terminal.draw(|f| ui(f, &app)).expect("Failed to draw");
if event::poll(std::time::Duration::from_millis(100)).expect("Failed to poll") {
if let Event::Key(key) = event::read().expect("Failed to read event") {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Down => {
if app.selected <9 {
app.selected += 1;
}
}
KeyCode::Up => {
if app.selected > 0 {
app.selected -= 1;
}
}
KeyCode::Enter => {
if let Some(stories) = app.stories.get(&app.feed) {
if let Some(story) = stories.get(app.selected as usize) {
if let Some(url) = &story.url {
open::that(url).ok();
}
}
}
}
KeyCode::Tab => {
let feeds = ["top", "new", "best", "ask", "show"];
let current = feeds.iter().position(|f| *f == app.feed.as_str()).unwrap_or(0);
let next = (current + 1) % feeds.len();
app.feed = feeds[next].to_string();
app.selected = 0;
}
_=> {}
}
}
}
}
restore_terminal(&mut terminal);
}