gh_cli 0.1.0

A terminal UI for browsing Hacker News
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);
}