Revamp Hacker News for Hashnode hackathon

That title is one hell of an alliteration, right?

In this article, I will outline my journey as I participate in the Hasnode Hackathon powered by Vercel. So tag along and feel free to check out the repo. Also, any changes to the product in the form of a pull request are welcome too.

🔗 Deployment 🔗 Repository

Inspiration

Screen Shot 2021-02-04 at 5.30.55 PM.png I joined Hashnode recently and while checking the progress of my first article here, I came across the news that Hashnode is hosting a hackathon partnering with Vercel. Also, I recently came across the original Hacker News website, which although provides great content, is not so great on the user experience. And voila! It struck me that I could develop a better UX version of the site if APIs were freely available, and it was. So here are the problems that I set out to solve:

  • The cluttered UX
  • No dark more (How can anything be awesome until it does not have a dark mode!)

Improvements possible

The first phase was the brainstorming phase when I thought about the things that could be improved. I knew the UX was bad, but how could I improve it?

  • One thing was that the site needed a lot of space as the original was just too cluttered.
  • Secondly, there was no clear "call to action". You only got to know that the link opened once you clicked the title.
  • Thirdly, I did not want to lose the speed one gets with pure HTML-based rendering by going for a client-side framework so it was clear that I wanted to implement some form on Server-side rendering.
  • Another thing missing in the original site was any feedback to the user like a loading indicator etc.

Tools and References

The next step was to choose the tools for the task. I knew even before starting that it had to be a React-based framework as I am such a huge react fanboy! As I also required server-side rendering, I finalized on Next.JS as the final choice. As I was going for a clean look, I chose Ant design as the choice for components. I also looked online for any mock UX revamps of hacker news and found this one on dribble from which I took inspiration for the table.

Screen Shot 2021-02-04 at 3.49.05 PM.png

For playing around and exploring the APIs, I used this site. And lastly, what better platform for hosting the site than Vercel as they have a smooth, 2-minute process to deploy your Next.JS site with a custom domain name!

Features Journey

And then coming to the bare bones, the coding part. I attacked the code piece by piece and kept developing smaller chunks to finally reach the larger goal. Just posting a few snippets here. Feel free to check the entire code in the repo link mentioned above.

As it was a hackathon, some of the code is non-optimal and some best practices have been skipped. Feel free to create pull requests if you feel the need to do so.

The network layer and SSR

First things first, I integrated the network layer and got all the articles & their details in the getServerSideProps() method.

export async function getServerSideProps(context) {

  let { pagesize=10, page=1 } = context.query;
  let posts = await fetchAllWithCache(API_URL);

  page = page == 0 ? 0 : page - 1;
  const slicedPosts = posts.slice(Number(page)*Number(pagesize), (Number(page)+1)*Number(pagesize));
  const jsonArticles = slicedPosts.map(post => getPostsViaCache(`https://hacker-news.firebaseio.com/v0/item/${post}.json?print=pretty`));

  savePostsToCache(slicedPosts, jsonArticles);
  const returnedData = await Promise.all(jsonArticles);

  return {
    props: {
       values: returnedData,
       totalPosts: posts.length
    }
}

The object returned from the above function is passed to the component as props. I also implemented some functions to fetch these details via a cache layer in between.

The Table row component

Screen Shot 2021-01-21 at 9.21.23 PM.png Next came the row component, for which I had the dribble mock to take inspiration.

export default function TableRow({item, jobs}) {
  const { hostname } = new URL(item.url || 'https://news.ycombinator.com');
  return (
    <Row gutter={16} className={styles.fullWidth}>
      {jobs && <Col span={3}>
        <Stats title="COMPANY" value={item.org}  />
      </Col>}
      {jobs && <Col span={3}>
        <Stats title="YC CODE" value={item.code}  />
      </Col>}
      {!jobs && <Col span={3}>
        <Stats title="POINTS" value={item.score}  />
      </Col>}
      {!jobs && <Col span={3}>
        <Stats title="COMMENTS" value={item.descendants}  />
      </Col>}
      <Col span={16} className={styles.separator}>
        <h2 className={styles.mainText}>{item.title}</h2>
        <ClockCircleOutlined style={{marginRight: 4, color: '#898989'}}/><span style={{color: '#898989'}}>{getElapsedTime(item.time)}</span>
        <Divider type="vertical"/>
        <UserOutlined style={{marginRight: 4, color: '#898989'}} /> <span style={{color: '#898989'}}>{item.by}</span>
        <Divider type="vertical"/>
        <LinkOutlined style={{marginRight: 4, color: '#898989'}} /> <span style={{color: '#898989'}}>{hostname}</span>
      </Col>
      <Col span={1}>
        <a
            href={item.url}
            target="_blank"
          >
          <Button type="primary" danger style={{marginTop: '8px'}}>Open</Button>
        </a>
      </Col>
      <Divider />
    </Row>
  )
}

There is a small customization if the Row is a Job-related row as job posts do not have comments or points.

Pagination

Next, I moved on to the pagination, for which the Pagination Ant component came in handy. I set the initial page size to 10 and the initial page to 1. And upon interacting with the pagination component, it would route to a different URL via the next/router.

// pagination change handler
function onPaginationChange(page, pageSize, router) {
  router.push(`/news?page=${page}&pagesize=${pageSize}`);
}

// pagination metadata
const { query, pathname } = router;
const path = pathname.split('/')[1] || 'news';
const {page = 1, pagesize = 10} = query;
const { values, totalPosts } = props;

// pagination component
<Pagination current={page} total={totalPosts} pageSize={pagesize} onChange={(page, pageSize) => onPaginationChange(page, pageSize, router)}/>

The Header

Screen Shot 2021-01-24 at 11.29.33 PM.png After that, I moved on to the Header component. I decided to keep the trademark Orange so as to keep it identifiable. Made it bigger and more spacey.

<div style={{backgroundColor: '#FB651E', display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
  <div style={{backgroundColor: '#FB651E', border: 'none', color: 'white', display: 'flex', alignItems: 'center', height: '100%', margin: '0 10% 0 10%', padding: '4px'}}>
    <img src="/logo.png" alt="Ycombinator Logo" className={styles.logo} style={{cursor: 'pointer', border: '2px solid white'}} onClick={(item) => routeToUrl(item, router)}/>
    <span style={{cursor: 'pointer', marginRight: '16px', fontSize: '1.2rem'}} onClick={(item) => routeToUrl(item, router)}>HACKERNEWS</span>
    <Divider type="vertical"/>
    <span key="news" className={path === 'news' ? styles.bold : ''} style={{cursor: 'pointer'}} onClick={(item) => routeToUrl(item, router)}>NEWS</span>
    <Divider type="vertical"/>
    <span key="show" className={path === 'show' ? styles.bold : ''} style={{cursor: 'pointer'}} onClick={(item) => routeToUrl(item, router)}>SHOW HN</span>
    <Divider type="vertical"/>
    <span key="ask" className={path === 'ask' ? styles.bold : ''} style={{cursor: 'pointer'}} onClick={(item) => routeToUrl(item, router)}>ASK HN</span>
    <Divider type="vertical"/>
    <span key="jobs" className={path === 'jobs' ? styles.bold : ''} style={{cursor: 'pointer'}} onClick={(item) => routeToUrl(item, router)}>JOBS</span>
  </div>
  <div style={{display: 'flex', color: 'white', alignItems: 'center', margin: '0 10% 0 0'}}>
    <span style={{marginRight: '8px'}}>Dark Mode 🌗</span>
    <Switch checked={darkModeEnabled} onChange={(checked) => onToggleDarkMode(checked, setDarkMode)} />
  </div>
</div>

Dark Mode

Screen Shot 2021-01-27 at 3.43.05 PM.png Next was my most favorite feature in the entire implementation. Dark mode!

I followed this awesome guide to implementing dark mode in Next.js. The gist is this, Every time the component loads:

  • Look for the cache level dark mode settings, if that is not found,
  • Look for OS-level dark mode preference and save it to the cache.
  • Based on the dark mode preference, trigger a function that replaces some css variables by doing some JS magic
  • As the CSS variables change, dark mode is applied.
  • Whenever the dark mode is toggled, save the preference to the Cache again.
// trigger the dark mode check on every load
useEffect(() => {
  setTimeout(() => {
    const isDarkMode = getInitialColorMode();
    if (isDarkMode) {
      setDarkMode(true);
      console.log('dark mode enabled: ', darkModeEnabled);
      onToggleDarkMode(true, setDarkMode);
    }
  }, 0);
}, [])

// function to toggle the dark mode using css variables
function onToggleDarkMode(checked, setDarkMode) {
  const root = document.getElementById('root');
  root.style.setProperty('--color-background', checked ? '#0E141C' : '#efefef');
  root.style.setProperty('--color-background-muted', checked ? '#152028' : '#ffffff');
  root.style.setProperty('--color-text-main', checked ? '#efefef' : '#121212');
  root.style.setProperty('--color-text-secondary', checked ? '#9a9a9a' : '#00000088');
  root.style.setProperty('--color-text-title', checked ? '#cdcdcd' : '#676767');

  if (setDarkMode) {
    console.log('writing to localstorage: ', checked);
    setDarkMode(checked);
    window.localStorage.setItem('dark-mode', checked ? 'true' : 'false');
  }
}

Post detailed view:

The detailed view for the news and show HN pages is now complete. So clicking on any post title in the News page/Show HN page/Ask HN page now takes you to the post detailed view. It uses cheerio to load the page html, then parse it in order to get the summary and an image out of the page, if any and shows that on the detailed view page of the post. Here is the cheerio code:


function getImageAndSummary(url) {
    return new Promise((resolve, reject) => {
        //get our html
        axios.get(url)
        .then(resp => {

            const html = resp.data;
            const $ = cheerio.load(html);
            let image = null;

            const imageInPage = getImage($)
            if (imageInPage) {
              image = nodeUrl.resolve(url, imageInPage);
            } 

            const summary = getParas($);
            resolve({image, summary});
        })
        .catch(err => {
           reject(err);
        });
    });
}

function getImage($) {
  let length = $("body").find("img").length;
  for (let i = 0; i < length; i++) {
    if ($("body").find("img")[i].attribs.width > 300) {
      return $("body").find("img")[i].attribs.src;
    }
  }

  return null;
}

function getParas($) {
  let summary = '';

  $("body").find("p").each((index,element) => {
    let paraText = $(element).text();
    if (paraText.length > 100 && summary.length < 300) {
      summary += paraText + ". ";
    }
  });

  return summary;
}

Here is how it looks:

Screen Shot 2021-02-07 at 5.27.59 PM.png

And of course, dark mode:

Screen Shot 2021-02-07 at 5.32.19 PM.png

Post comments section:

Was able to implement the comments section for any post detailed view by using the Ant.design Comment component. Now, this method needs to be called recursively when the view replies button is clicked in order to see the reply comments:

comments2.gif

Feel free to try it out yourself.

Comments (2)

Josias Aurel's photo

This is really nice app. The only issue is that the design is not good on mobile. Making it responsive will be great

kapeel kokane's photo

Yes correct. Kept the primary focus as desktop for now. Planning on creating a separate react native app for mobile.