Building A Basic Hacker News Clone with Rails 5

Published Jan 27, 2017Last updated Feb 16, 2017

In this tutorial, we'll be creating a simple link-sharing web application much like hacker news or reddit using the latest and greatest version of the Ruby on Rails framework. Although we'll be making use of Rails version 5, you should still be able to follow along even if you use an older version of Ruby on Rails.

Table of contents

Audience

This tutorial assumes you are fairly familiar with the basics of the Ruby on Rails framework and are looking to get started building more practical applications.

What we'll be building

We'll be building a simple platform that allows users to share links, add comments to a link thread and vote on links. We'll also throw in a very basic authentication system that we'll be building from scratch so strap in your seatbelts as we are about to go on a ride.

The github repo for this project can be found here and a live demo can be found here

Getting started

You should already have the following installed before proceeding with this tutorial:

To get started, open a terminal window and run rails new jump_news. This simple command will take care of creating and setting up the folder structure as well as installing all the necessary gems needed for our new rails application to function optimally.

I've named the application jump_news, feel free to come up with a more original name if you will.

Starting the server

From the terminal window, navigate to the project folder that contains the application and run rails s or rails server. This will start the Rails server on port 3000. Open up a browser window and visit http://localhost:3000 and you should see a 'Yay! You're on Rails' page.

If you get an error message saying the port is already in use, you can pass an additional argument to the rails server command to use a different port like this: rails s -p 3001; the -p flag allows you to pass in a port number that the rails server can bind to instead.

Setting up the models

The models are a pretty important part of an application and we'll start out with creating the various models we'll be needing for our application to function properly. Based on the way the application is meant to work, we'll be needing four models in total, they are:

  • User - Handles creation of users in the application. This model will also come into play when we build our simple authentication flow later.
  • Link - Handles the creation/editing/deletion of links.
  • Comment - Handles creation/editing/deletion of comments.
  • Vote - Handles upvotes/downvotes on links.

Although we have just four models, that will enough super power we'll be needing to get the application running effectively.

Let's start with the users - user signup/registration fuctionality

We'll be starting with the user model because we need users to exist before links can be submitted as links will belong to a user.

To keep things simple, the user model will only have two columns:

  • username
  • password_digest

The password digest column is named as such because we won't be storing a user's password in plain text (which is clearly unsafe) but we'll be storing an encrypted hash of the user's password instead. How this works is simple, we'll have a virtual column called 'password' that we'll use when creating a new user and we'll pass in a plain text value but this will be encrypted when being saved to the database in the password_digest column. We'll need a gem to handle this functionality for us and luckily enough, it's already a part of the gemfile only commented out. We'll add in this functionality by uncommenting the line that adds in the gem and then running bundle install.

Open the gemfile and look for the line where the bcrypt gem is commented out.

# Use ActiveModel has_secure_password
# gem 'bcrypt', '~> 3.1.7'

Add it back in by uncommenting the line;

# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'

After this has been done, go back to the terminal window and run bundle install to install the gem and make it available for our application. After that, restart the rails server if you still have it running.

bundle install

Adding this gem also gives us the chance to add a very basic authentication flow into our application which we'll be doing later.

To enable users sign up for the application, we'll need to go ahead with the creation of the user model and then hooking up views and controllers to handle the display and logic of creating a new user.

Open the terminal window and create the user model by running:

rails g model User username password_digest

After this has been done, run rails db:migrate to save the changes to the database.

Validating a new user

Before creating a new user, we'll have to ensure that some basic validations are carried out first. We'll be checking that the username column is filled with a value with at least a length of three (3) as well as being unique (as this will be the only way we'll be identifying users on the platform), we'll also be ensuring that a password with a minimum length of eight is set when a new user is created.

Open the user model file which can be found in: app/models/user.rb

Add the following to the file:

class User < ApplicationRecord
  has_secure_password
  
  validates :username,
            presence: true,
            length: { minimum: 3 },
            uniqueness: { case_sensitive: false }
   
  validates :password, length: { minimum: 8 }
end

This piece of code adds in the functionality that was discussed above. If you look clearly, you'll notice we aren't adding presence: true to the password validation, this is because the first line of the class invokes the has_secure_password method which takes care of that for us. The has_secure_password method is added in by the bcrypt gem we added earlier on.

That line also ensures we are able to authenticate a user as we'll see when we implement the login functionality.

Now let's go on and wire this up to the view and controller.

We'll first start by generating a user controller and then adding a form to the view to enable us fully implement the signup feature.

Open the terminal and run the following command:

rails g controller users new create

Open the route file which can be found in: config/routes.rb

It should look something like this:

Rails.application.routes.draw do
  get 'users/new'

  get 'users/create'
end

We'll remove the methods added in when the controller was generated and replace it with a single call to the resources method. The file should now look like this:

Rails.application.routes.draw do
  resources :users, only: [:new, :create]
end

This will take care of creating our routes for us as well as helper methods that can be used to generate links to the generated path.

Next up is to open up the users controller. The file can be found in: apps/controllers/users_controller.rb

The file should look something like this:

class UsersController < ApplicationController
  def new
  end

  def create
  end
end

This is a barebones setup that just ensures that when these paths are requested, there is an available template to be sent as a response. Before we proceed to the next step, we'll need to delete the view template that was generated for the create action when the users controller was created as we won't be needing it.

Look for the file in: app/views/users/create.html.erb and delete it.

Now, open up: app/views/users/new.html.erb, it should look something like this:

<h1>Users#new</h1>
<p>Find me in app/views/users/new.html.erb</p>

We'll be adding a form to this page that will enable us create a new user and this will complete our registration feature. Go back to the users controller found in: app/controllers/users_controller.rb and add an instance variable which will be available in the view, this allows us rely on rails sensible default behaviour of generating the right path for the form by passing in just the instance variable. Edit the users controller to look like this:

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
  end
end

Now go back to the new user page found in: app/views/users/new.html.erb and edit it to look like this:

<section class="register-user">
  <header>
    <h1>
      Create a new Account
    </h1>
    
    <div class="row">
      <div class="col-sm-6">
        <%= form_for @user do |f| %>
        <%= render "shared/errors", object: @user %>
        <div class="form-group">
          <%= f.text_field :username, class: "form-control", required: true, minlength: 3, placeholder: "username" %>
        </div>

        <div class="form-group">
          <%= f.password_field :password, class: "form-control", required: true, minlength: 8, placeholder: "password" %>
        </div>

        <div class="form-group">
          <%= f.button "Register", class: "btn btn-success" %>
        </div>

        <% end %>
      </div>
    </div>
  </header>
</section>

There are a few things going on here which will be discussed, first we have added class names like row, col-sm-6, form-group etc, these class names currently do nothing for our application but if you are familiar with the bootstrap framework, you'll realise that these are all class names used by bootstrap for styling purposes. Once we add in the bootstrap gem, we'll be able to rely on bootstrap for some of our styling (no one wants to use a visually unappealing platform).

Another thing is the partial for rendering errors when there are any from the form; this hasn't been created yet but we'll be doing that in a short while.

To prevent unecessary round trips to the server, we have also put in html 5 validations on the input elements that enable much of the same validations that we have on the user model.

Let's create the partial for rendering form errors. Create a folder in: app/views and name it shared. Now, proceed to create the errors partial in: app/views/shared, it should be created as: _errors.html.erb, the underscore before the name ensures rails know that the template will be used as a partial.

Now, within app/views/shared/_errors.html.erb, add in the following piece of code:

<% if object.errors.any? %>
  <ul class="errors alert alert-danger">
    <% object.errors.full_messages.each do |msg| %>
      <li> <%= msg %> </li>
    <% end %>
  </ul>
<% end %>

With the way this has been written, we'll be able to re-use this partial for rendering errors for many different forms. There are some class names on the ul element, alert and alert-danger that mean something to bootstrap when styling elements and we won't be able to see some of these styling until we add in the bootstrap gem. Let's proceed to do that.

Open the gemfile found in the root of your application and add in the bootstrap gem:

gem 'bootstrap-sass', '~> 3.3.6'

After this has been done, open the terminal and run bundle install. Now rename the css file found in: app/assets/stylesheets/application.css to 'app/assets/stylesheets/application.scss', we are just changing the extension from .css to .scss as we'll be importing the bootstrap stylesheet as a sass file.

Edit application.scss to look like this:

@import "bootstrap-sprockets";
@import "bootstrap";

After importing the bootstrap files, you'll need to restart the rails server for the bootstrap gem to take effect.

You should be able to view the page at: http://localhost:3000/users/new (replace 3000 with whatever port your server is running on). It doesn't look like much now but we'll be improving the design bit by bit as we go along.

Let's open the users controller found in: app/controllers/users_controller.rb and edit it to look like this:

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_back fallback_location: new_user_path
    else
      render :new
    end
  end

  private

  def user_params
    params.require(:user).permit(:username, :password)
  end
end

At this point, we have all the functionality to enable a user register on the application, we'll be coming back to the users controller once we add in the functionality for login.

Basic Authentication - Login / Logout functionality

We'll be baking in a very basic authentication workflow into our application that will enable users login and access protected routes and perform actions only logged in users can. This is by no means an endorsement to go about creating an authentication system from the ground up when building out an application but for our very basic needs, it will give the most flexibility. There are libraries and gems whose sole purpose is to enable the implementation of authentication within an application and when your application grows beyond basic needs and requires more features, look into the various libraries out there.

With that said, we'll be implementing our authentication workflow using sessions which is something already provided for in rails, sessions, as well as the authenticate method provided by the bcrypt gem we added earlier on will be the tools we'll be using. Rails sessions will enable us save information pertaining a user in the user's browser and we'll be able to retrieve this information from the browser when requests are made and be able to authenticate a user.

Open the terminal and generate the sessions controller by running: rails g controller sessions new

Now, open the routes file in: config/routes, it should look something like this:

Rails.application.routes.draw do
  get 'sessions/new'

  resources :users, only: [:new, :create]
end

We'll be replacing the custom get call with a call to resources which will take care of generating all the necessary routes we'll be needing. Edit the file to look like this instead:

Rails.application.routes.draw do
  resources :sessions, only: [:new, :create]
  resources :users, only: [:new, :create]
end

With all this setup, we have all we need to implement the login/logout functionality. Logging in will simply mean creating a new session and logging out will be destroying an existing session.

Let's proceed to wire in this functionality. Since there is no session model, creating a new session and destroying a session will mean something else entirely. Rails comes with a handy session method that basically allows the server store information on the user's browser that can be used to identify a user later on. Note that this method has nothing to do with the name of our controller as it is not necessary we call the controller 'sessions', it is just the most meaningful name to denote the functionality that the controller carries out.

We'll need just a few methods to enable our basic authentication workflow. We'll need a method to handle logging in a user, one to return the currently active user, one to check if a user is logged in and a method to destroy a session and log the user out. Let's proceed to implement this functionality. The methods we'll be creating will need to be available in all controllers and some will need to be available in the views.

Open up the application controller which can be found in: app/controllers/application_controller.rb

Add in the following piece of code:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  def login(user)
    session[:user_id] = user.id
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def logout
    session.delete(:user_id)
    @current_user = nil
  end

  def logged_in?
    current_user.nil? ? false : true
  end
  
  helper_method :current_user, :logged_in?
end

The login method simply stores the user's id in a session which is used by the current_user method to fetch the currently active user from the database. In order to ensure that a call to the current_user method doesn't result in extra queries to the database, the result of the first call is memoized so that a cached result is returned after the first call. The logged_in? method simply returns true of false based on whether a user's session is active or not and the logout method deletes the active session and sets current_user to nil.

The line at the bottom of the class that makes a call to helper_method simply makes the passed in arguments available as helper methods in the views. This means we can call both current_user and logged_in? from the views. We'll find out the usefulness of this later on.

We have all we need to carry out basic authentication in our application. Let's add a form to the login page that allows a user sign in to the application.

Open the new sessions page which can be found in: app/views/sessions/new.html.erb, delete the previous content and add in the following:

<section class="login">
  <header>
    <h1>
      Login
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= form_for :session, url: sessions_path do |f| %>
        <div class="form-group">
          <%= f.text_field :username, class: "form-control", placeholder: "username", required: true %>
        </div>

        <div class="form-group">
          <%= f.password_field :password, class: "form-control", placeholder: "password", required: true %>
        </div>

        <div class="form-group">
          <%= f.button "login", class: "btn btn-success" %>
        </div>

      <% end %>
    </div>
  </div>
</section>

With the form created, we'll move on to the sessions controller and finally give users the ability to login to our application.

Open the sessions controller which can be found in: app/controllers/sessions_controller.rb

It should look something like this:

class SessionsController < ApplicationController
  def new
  end
end

We'll be adding in the create action which is where the form we created earlier on sends its request to. Edit the controller to look like this:

class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(username: login_params[:username])

    if user && user.authenticate(login_params[:password])
      login(user)
      redirect_back fallback_location: new_session_path, notice: 'Logged in'
    else
      flash.now[:notice] = 'Invalid username / password combination'
      render :new
    end
  end

  private

  def login_params
    params.require(:session).permit(:username, :password)
  end
end

The way this works is simple, the action firsts checks if there is a user by the submitted username in the database, then it proceeds to check if the submitted password is valid for the user and if it is, it logs in the user otherwise it bounces the user back with a helpful message.

Regarding the messages which we have stored in the rails helper method simply known as flash (no, this has nothing to do with the super hero), we won't be seeing them in the views until we explicitly add them in. Let's proceed to do that.

Seeing as we'd like to have these messages show up on multiple pages, we'll be adding it to the application layout which enables us apply the message site-wide.

Open the default application layout file in: app/views/layouts/application.html.erb

Edit the file to look like this:

<!DOCTYPE html>
<html>
  <head>
    <title>JumpNews</title>
    <%= csrf_meta_tags %>

    <%= stylesheet_link_tag    "application", media: "all", "data-turbolinks-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbolinks-track": "reload" %>
  </head>

  <body class="container">
    <main>
      <% if flash[:notice] %>
        <p class="alert alert-info">
          <%= flash[:notice] %>
        </p>
      <% end %>

      <%= yield %>
    </main>
  </body>
</html>

We have added a class name called container to the body element, this comes from bootstrap and it sets a responsive width on the body element and keeps it's content nice and centered. We have also added a conditional to show the flash message if there are any.

Currently, we have no concrete way of telling when a user is logged in or logged out as well as a way to prevent users from accessing particular pages based on their authentication status. We also have no way of navigating between the various pages just yet. Let's proceed to alleviate these issues.

We'll start by adding in a navbar as not only will this help us move across the various pages easily, we'll also be able to use it as a way to know which user is logged in or not based on the items presented in the navbar.

Create a navbar partial in the app/views/shared folder and name it _navbar.html.erb. Add the following piece of code to the partial:

<header>
  <nav>
    <%= link_to "jump news", "#", class: "logo" %>

    <div class="navlinks">
      <%= link_to "new", "#" %>
      <%= link_to "comments", "#" %>

      <% unless logged_in? %>
        <%= link_to "signup", new_user_path %>
        <%= link_to "login", new_session_path %>
      <% end %>

      <% if logged_in? %>
        <%= link_to "submit a link", "#" %>
        <%= link_to "logout", "#" %>
      <% end %>
    </div>
  </nav>
</header>

Don't worry about the dead links for now, we'll come back to them when the routes are available. The conditionals added in give us a way of knowing if a user is logged in or not as the items shown in the navbar change based on a user's authentication status (earlier on we made the logged_in? method available in the views as a helper method). This means only logged in users will be able to submit links as well as perform a logout operation and only logged out users will be able to access the pages for registering a new account as well as logging in to an existing account.

Currently this only prevents the user from seeing the link as an available option as the routes can still be accessed from the location bar, later on, we'll add logic to the controllers to prevent user's from accessing routes they are not authorized to.

Let's style our navbar component, create a styles.scss file in the folder: app/assets/stylesheets and delete any stylsheet file generated when the controllers were created. At this point, we should have just two stylesheet files application.scss and styles.scss.

Make sure to import the styles.scss file in application.scss:

@import "styles";

Add the following to the styles.scss file:

nav {
  display: flex;
  justify-content: space-between;
  padding: 12px 0;

  .navlinks {
    a {
      padding: 0 12px;
    }
  }
}

ul {
  list-style: none;
}

The style helps us neatly lay out the navbar component horizontally as well add some spacing between the navbar items. The styling applied to the ul element removes the bullet points that get added in when lists are created, this is done because our error messages are displayed as a list and we don't want the bullet points showing up. We'll be updating this stylesheet file as we move along.

With these done, let's proceed to implement the logout functionality. All we need do is add a new method to the sessions controller to handle logout requests.

Let's start by making the route available.

Open the route file found in: config/routes.rb and edit the sessions resource to look like this:

resources :sessions, only: [:new, :create] do
  delete :destroy, on: :collection
end

This positions the sessions controller to recieve delete requests made to the /sessions path to be handled by the destroy action.

Open the sessions controller found in: app/controllers/sessions_controller.rb

Add in the following piece of code:

def destroy
  logout
  redirect_back fallback_location: new_session_path, notice: 'Logged out'
end

This simply makes a call to the logout method (declared in the application controller) and redirects back to the login page. We'll change the redirect path later. Let's wire this up to the view so that logout requests can be made from the browser.

Open the navbar partial found in: app/views/shared/_navbar.html.erb

Edit the logout link:

<%= link_to "logout", sessions_path, method: :delete %>

Voila! we have our basic login / logout feature implemented. Take a breather and bask in the glory. Of course we are not all done as we'll come back to add some basic authorization later on but we have enough to proceed to the next phase of the application.

Let's go ahead and try everything out in the browser, we should be able to sign up and create a new user on the application, we should also be able to login as well as log out of the application.

On hacker news, a user is able to submit an optional url, an optional description and a mandatory title which is then displayed on the homepage and other users can leave comments on the thread.

Threads displayed on the hompage are ranked based on how relevant or hot they are which is usually just a factor of how many points a thread has and the time it was created.

We'll be adding this same functionality to our application and this will bring us one step further to the completion of our application.

For now, all we want is to be able to create a link and then display it on the homepage. Let's proceed to make this available. Go to the terminal and enter:

rails g model Link title url description:text user:belongs_to

Open the newly generated migration file found in db/migrate and edit the create_links migration file by adding an index to the foreign key column user_id as this will make queries involving this column faster:

t.belongs_to :user, foreign_key: true, index: true

Follow this up by running the migration: rails db:migrate.

Seeing as a link belongs to a user, we'll have to add the appropriate has_many association to the user model as well.

Open the user model found in: app/models/user.rb and add the following line:

has_many :links, dependent: :destroy

This means that a user can have many links while a single link should belong to a user.

A link has three parts main parts when being created, the title which is mandatory, the url which is optional but when provided should match a valid url and a description which is completely optional. Let's wire in this functionality, open the link model which can be found in: app/models/link.rb

Enter the following:

validates :title,
            presence: true,
            uniqueness: { case_sensitive: false }
            
validates :url,
          format: { with: %r{\Ahttps?://} },
          allow_blank: true

This adds in validation to ensure that unique titles are submitted when links are created and that a valid url is passed in if a url is present. For the url field, we are just simply checking that it begins with either http:// or https://.

With this setup, we can proceed to wire up this functionality to the controller and view.

Let's start by creating a links controller and we'll proceed from there. Open the terminal and enter:

rails g controller links index new show edit

Now, edit the routes file found in: config/routes.rbto look like this:

Rails.application.routes.draw do
  root 'links#index'

  resources :links, except: :index

  resources :sessions, only: [:new, :create] do
    delete :destroy, on: :collection
  end

  resources :users, only: [:new, :create]
end

Now would be a good time to update some parts of the application that we left off earlier on. We need to update some of the links in the navbar as we now have routes available for them, this is also a good chance to add basic authorization to the pages that need it.

Starting with the navbar partial found in: app/views/shared/_navbar.html.erb, update the following links:

Link with the title jump news will serve as the homepage and should be updated as such:

<%= link_to "jump news", root_path, class: "logo" %>

Link with the title submit a link will be where logged in users create a new link. Let's update it as well:

<%= link_to "submit a link", new_link_path %>

Great! we only have two dead links left and we'll take care of them soon enough. As it is now, any user whether logged in or not can access all pages, we'll need to be a little more restrictive about this.

The pages that all users can access will include:

  • homepage - root_path
  • link show page - link_path
  • newest links - we haven't created a route for this yet
  • all comments - we haven't created a route for this yet

The pages that only logged out users can access will be:

  • signup / register - new_user_path
  • login - new_session_path

Every other page will be accessible by logged in users only.

We have mitigated this issue a little bit by only showing links in the navbar based on a user's authentication status but this doesn't prevent the user from accessing the page from the browser's location bar.

Open the application controller found in: app/controllers/application_controller.rb and add the following methods:

def prevent_unauthorized_user_access
  redirect_to root_path, notice: 'sorry, you cannot access that page', status: :found unless logged_in?
end

def prevent_logged_in_user_access
  redirect_to root_path, notice: 'sorry, you cannot access that page', status: :found if logged_in?
end

These simple looking methods are all we need to wire up the authorization functionality for our controllers. They simply redirect the user to the root path or homepage when a user tries to access a page they are not allowed to. We'll be using a before_actioncallback in the controllers that gets triggered before an action is called.

Open up the users controller found in: app/controllers/users_controller.rb and add this:

before_action :prevent_logged_in_user_access

While we are at it, let's update the user controller's create action to immediately log a user in when a new account is created and then redirect to the homepage:

def create
  @user = User.new(user_params)

  if @user.save
    login(@user)
    redirect_to root_path, notice: 'Logged in'
  else
    render :new
  end
end

Now, let's proceed to the sessions controller found in: app/controllers/sessions_controller.rb. Add the following lines of code:

before_action :prevent_logged_in_user_access, except: :destroy
before_action :prevent_unauthorized_user_access, only: :destroy

This ensures that only logged in users will be able to access the logout route and only logged out users will be able to sign up / register as well as login to the application.

While we are at it, let's update the controller's create and destroy actions. Change the redirect_back calls to this:

def create
  ...
  redirect_to root_path, notice: 'Logged in'
  ...
end

def destroy
  ...
  redirect_to root_path, notice: 'Logged out'
end

Finally, let's open the link controller found in: app/controllers/links_controller.rb and add this line of code:

before_action :prevent_unauthorized_user_access, only: [:new, :edit]

This ensures that only logged in users can access the page to create a new link and to edit an existing link.

Whew! that was a lot. Let's proceed with giving user's the ability to add a new link. Open up the new link page found in app/views/links/new.html.erb. It should look something like this:

<h1>Links#new</h1>
<p>Find me in app/views/links/new.html.erb</p>

Now, let's edit it to look like this:

<section class="new-link">
  <header>
    <h1>
      submit a link
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= form_for @link do |f| %>
        <%= render "shared/errors", object: @link %>

        <div class="form-group">
          <%= f.text_field :title, required: true, class: "form-control", placeholder: "title" %>
        </div>

        <div class="form-group">
          <%= f.url_field :url, class: "form-control", placeholder: "url" %>
        </div>

        <div class="form-group">
          <%= f.text_area :description, class: "form-control", placeholder: "description", rows: 15 %>
        </div>

        <div class="form-group">
          <%= f.button :submit, class: "btn btn-success" %>
        </div>
      <% end %>
    </div>
  </div>
</section>

Let's proceed to the link controller found in: app/controllers/links_controller.rb. We'll be updating the new action as well as creating a new create action and a private method link_params where we'll whitelist attributes that we want to accept from the browser request.

Add the following piece of code:

def new
  @link = Link.new
end

def create
  @link = current_user.links.new(link_params)

  if @link.save
    redirect_to root_path, notice: 'Link successfully created'
  else
    render :new
  end
end

private

def link_params
  params.require(:link).permit(:title, :url, :description)
end

At this point, a logged in user should be able to create a new link. Try out everything in the browser and it should work as such.

Open up the index page for links found in: app/views/links/index.html.erb. The file should look something like this:

<h1>Links#index</h1>
<p>Find me in app/views/links/index.html.erb</p>

Let's edit it to look like this:

<section class="all-links">
  <% @links.each do |link|  %>
    <div class="link">
      <div class="title">
        <%= link_to link.title, (link.url? ? link.url : link) %>

        <% if link.url? %>
          <span>
            ( <%= link.url %> )
          </span>
        <% end %>
      </div>

      <div class="metadata">
        <span class="time-created">
          <%= time_ago_in_words(link.created_at) %> ago
        </span>
      </div>

    </div>
  <% end %>
</section>

This simply loops over all available links and displays their title as well as metadata about when the link was created. If a url is provided, then clicking on the link will redirect to the provided url, otherwise, it opens the link show page. We'll update this page as we go along but this serves our purpose of simply displaying links on the homepage for now.

If you try this out in the browser, it will throw an error because the @links variable hasn't been created yet. Let's proceed to make it available, open the link controller found in: app/controllers/links_controller.rb and add this to the index action:

@links = Link.all

Let's add a little bit of seperation between the links by editing the styles.scss file found in: app/assets/stylesheets/styles.scss. Add the following:

.link {
  margin: 12px auto;
}

Go ahead and try this out in the browser, create multiple links and view them on the homepage.

We have been able to successfully display links on the homepage, let's proceed to add functionality that allows users that own links to be able to delete them or edit them. This means we'll have to ensure that the editing / deleting actions only show up for logged in users that own a link, another logged in user sould not be able to edit / delete the link of another user.

To do this, we'll add a method to the user model that essentially tells us if a user owns a particular link or not. Open the user model found in: app/models/user.rb and add the following:

def owns_link?(link)
  self == link.user
end

This method simply compares the user id on a link record and checks if it is equal to the user's id. With this method setup, we'll be able to wire up this functionality in the view.

Open the link index page found in: app/views/links/index.html.erb, look for the the span with a classname of time-created and add this after it:

<% if logged_in? && current_user.owns_link?(link) %>
  <span class="edit-link">
    <%= link_to "edit", edit_link_path(link) %>
  </span>

  <span class="delete-link">
    <%= link_to "delete", link, method: :delete, data: { confirm: "Are you sure?" } %>
  </span>
<% end %>

With this setup, only logged in users who own a particular link will be able to access the actions for editing and deleting a link. While this has been setup in the views, a logged in user who doesn't own a story would still be able to access these actions by other means (the edit page can be accessed from the browser's location bar). We'll need to also prevent access from the controllers as well.

Open the link controller found in: app/controllers/links_controller.rb and update the edit action:

def edit
  link = Link.find_by(id: params[:id])

  if current_user.owns_link?(link)
    @link = link
  else
    redirect_to root_path, notice: 'Not authorized to edit this link'
  end
end

This simply sets an instance variable equal to the local link variable if the current user owns the link (the instance variable is what we'll use in the form for editing a link) else it redirects the user back to the homepage if the link doesn't belong to the current user.

Let's do the same thing for the destroy action (we'll need to add the destroy action manually as it wasn't created when we generated the controller):

def destroy
   link = Link.find_by(id: params[:id])

  if current_user.owns_link?(link)
    link.destroy
    redirect_to root_path, notice: 'Link successfully deleted'
  else
    redirect_to root_path, notice: 'Not authorized to delete this link'
  end
end

You'll notice some similarities between the edit action and the delete action and this is a chance to add some refactoring but I'll leave that as an exercise for the adventurous amongst us.

We are all done with the functionality for deleting a link but we have one more step remaining for a user to be able to edit / update an existing link. Let's proceed to implement this.

Open link edit page which can be found in: app/views/links/edit.html.erb. It should look something like this:

<h1>Links#edit</h1>
<p>Find me in app/views/links/edit.html.erb</p>

Delete the existing content and add the following:

<section class="edit-link">
  <header>
    <h1>
      Edit link
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= form_for @link do |f| %>
        <%= render "shared/errors", object: @link %>

        <div class="form-group">
          <%= f.text_field :title, required: true, class: "form-control", placeholder: "title" %>
        </div>

        <div class="form-group">
          <%= f.url_field :url, class: "form-control", placeholder: "url" %>
        </div>

        <div class="form-group">
          <%= f.text_area :description, class: "form-control", placeholder: "description", rows: 15 %>
        </div>

        <div class="form-group">
          <%= f.button :submit, class: "btn btn-success" %>
        </div>
      <% end %>
    </div>
  </div>
</section>

The edit form and the form for a new link share a lot of the same functionality, let's proceed to extract this into a partial.

Create partial called _form.html.erb in app/views/links. Add the following to the file:

<%= form_for @link do |f| %>
  <%= render "shared/errors", object: @link %>

  <div class="form-group">
    <%= f.text_field :title, required: true, class: "form-control", placeholder: "title" %>
  </div>

  <div class="form-group">
    <%= f.url_field :url, class: "form-control", placeholder: "url" %>
  </div>

  <div class="form-group">
    <%= f.text_area :description, class: "form-control", placeholder: "description", rows: 15 %>
  </div>

  <div class="form-group">
    <%= f.button :submit, class: "btn btn-success" %>
  </div>
<% end %>

Now, proceed to the edit link page found in: app/views/links/edit.html.erb and update it to look like this:

<section class="edit-link">
  <header>
    <h1>
      Edit link
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= render 'form' %>
    </div>
  </div>
</section>

Update the new link page as well found in : app/views/links/new.html.erb

<section class="new-link">
  <header>
    <h1>
      submit a link
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= render 'form' %>
    </div>
  </div>
</section>

Everything should still work as before. Now, let's complete the edit functionality by adding an action to the link controller to handle update requests.

Open the link controller found in: app/controllers/links_controller.rb and add the following:

def update
  @link = current_user.links.find_by(id: params[:id])

  if @link.update(link_params)
    redirect_to root_path, notice: 'Link successfully updated'
  else
    render :edit
  end
end

This completes the functionality for the edit operation. The update action redirects the user to the homepage with a flash message telling the user that the update operation was successful if the link was successfully updated, if that is not the case, it renders the edit template and shows the form errors if any.

So far, we are able to create links as well as edit or delete them if authorized to do so but now, we'll be moving on to comments. Comments can be attached to a link and will be displayed on the link show page. A comment will belong to a user as well as to a link and a user will have many comments as well as a link.

Let's proceed to implement this functionality. We'll start first by creating the comments model, all it needs is a body column which will hold the content of a comment as well as columns that add the associations with the user and link model.

Open the terminal and enter:

rails g model comment body:text user:belongs_to link:belongs_to

Open the just generated migration file: create_comments.rb, ensure you have something like this:

class CreateComments < ActiveRecord::Migration[5.0]
  def change
    create_table :comments do |t|
      t.text :body
      t.belongs_to :user, foreign_key: true
      t.belongs_to :link, foreign_key: true

      t.timestamps
    end
  end
end

Now run: rails db:migrate to update the database with the new changes.

Open the comment model found in: app/models/comment.rb and emsure it looks like this:

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :link
end

Now, proceed to the user model found in: app/models/user.rb and add the following:

has_many :comments

Do the same with the link model found in: app/models/link.rb:

Validating a comment

We'll be adding a simple validation to ensure that the body column has content when a comment is created. The calls to belongs_to would also ensure that the associated models ids be present when a comment is being created (user and link).

Open the comment model found in app/models/comment.rb and add the following:

validates :body, presence: true

Adding comments

Let's proceed to enabling user's add comments to a link thread from the view. With the model setup, we have enough leeway to implement this functionality.

Open the link show page found in: app/views/links/show.html.erb. It should look something like this:

<h1>Links#show</h1>
<p>Find me in app/views/links/show.html.erb</p>

Edit the file so that it looks like this:

<section class="link-thread">
  <header>
    <h4>
      <%= @link.title %>
    </h4>

    <% if @link.description? %>
      <p>
        <%= @link.description %>
      </p>
    <% end %>
  </header>
</section>

Open the link controller found in: app/controlers/links_controller.rb and add the following to the show action:

@link = Link.find_by(id: params[:id])

Let's proceed to create a comments controller. Open the terminal and enter:

rails g controller comments index edit

The actions generated along with the controller are the only ones that need a view associated with them.

Let's proceed to the route file found in: config/routes.rb. Remove the lines:

get 'comments/index'

get 'comments/edit'

Edit the route file to look like this:

Rails.application.routes.draw do
  root 'links#index'

  resources :links, except: :index do
    resources :comments, only: [:create, :edit, :update, :destroy]
  end

  get '/comments' => 'comments#index'

  resources :sessions, only: [:new, :create] do
    delete :destroy, on: :collection
  end

  resources :users, only: [:new, :create]
end

The comments have been nested within links with routes generated for the create, edit, update and destroy actions. A custom route /comments which maps to the comments controller index action will be used to display all comments on the application.

We don't need a separate page for creating new comments as comments will be created from the link show page.

Let's proceed to make this functionality available. Open the link show page found in: app/views/links/show.html.erb and add the following after the header element:

  <% if logged_in? %>
    <div class="add-comment row">
      <div class="col-sm-6">
        <%= form_for :comment, url: link_comments_path(@link) do |f| %>
          <div class="form-group">
            <%= f.text_area :body, class: "form-control", placeholder: "The quick brown fox...", rows: 10, required: true %>
          </div>

          <div class="form-group">
            <%= f.button "add comment", class: "btn btn-success" %>
          </div>
        <% end %>
      </div>
    </div>
  <% end %>

This will add a form to the page that allows logged in users to add comments to a link thread. We'll need to add a create action to the comments controller to handle requests for creating a new comment. Open the comments controller found in: app/controllers/comments_controller.rb and add the following:

def create
  @link = Link.find_by(id: params[:link_id])
  @comment = @link.comments.new(user: current_user, body: comment_params[:body])

  if @comment.save
    redirect_to @link, notice: 'Comment created'
  else
    redirect_to @link, notice: 'Comment was not saved. Ensure you have entered a comment'
  end
end

private

def comment_params
  params.require(:comment).permit(:body)
end

How this works is fairly easy, first we get the current link thread and then create a new comment by setting the bodycolumn equal to the submitted body parameter, we then set the user column equal to the current user. If all goes well, we redirect back to the link thread with the message Comment created otherwise, the message becomes Comment was not saved. Ensure you have entered a comment

Since we now have the ability to add comments to a link thread, let's proceed to implement the functionality that allows us to display comments on a link thread.

Open the link controller found in: app/controllers/links_controller.rb and add the following to the show action just after the declaration of the @link instance variable:

@comments = @link.comments

This loads all the comments belonging to a link and stores them in the instance variable @comments. We can access this instance variable from the views and the iteratively display all comments belonging to a particular link thread.

Open the link show page found in: app/views/links/show.html.erb and add the following just after the conditional that adds the ccomment form:

<div class="all-comments row">
    <div class="col-sm-12">
      <% if @comments.present? %>
        <h3>
            Comments
        </h3>
      <% end %>
      
      <% @comments.each do |comment| %>
        <div class="comment">
          <p class="comment-owner">
            <strong>
              <%= comment.user.username %>
            </strong>

            <span class="comment-created small">
              <%= time_ago_in_words(comment.created_at) %> ago
            </span>
          </p>

          <p>
            <%= comment.body %>
          </p>
        </div>
      <% end %>
    </div>
  </div>

This simply loops through the comments and displays the comments content as well as the user the comment belongs to.

Editing / Deleting comments

Let's proceed to add in functionality that allows a user edit comments as well as delete comments if they are authorized to do so.

We'll start by adding a method that helps identify if a comment belongs to a user. Open the user model found in: app/models/user.rb and add the following:

def owns_comment?(comment)
  self == comment.user
end

With this done, let's proceed to the comments controller found in: app/controllers/comments_controller.rb We'll start by preventing access for users who aren't logged in as they wouldn't be able to edit or delete a comment. Add the following:

before_action :prevent_unauthorized_user_access, except: :index

This ensures that user's who aren't logged in will be unable to access any action apart from the index action (which will be used to list all comments).

Add the following method as a private method:

def set_variables
  @link = Link.find_by(id: params[:link_id])
  @comment = @link.comments.find_by(id: params[:id])
end

The instance variables declared here will need to be available before the edit, update and destroy action get called and in order to make this work as such, we'll add it to a before_action callback. Add the following:

before_action :set_variables, only: [:edit, :update, :destroy]

This ensures that both variables are available for the actions when called. We'll implement the edit, update and destroy actions in one fell swoop:

def edit
  unless current_user.owns_comment?(@comment)
    redirect_to root_path, notice: 'Not authorized to edit this comment'
  end
end

def update
  if @comment.update(comment_params)
    redirect_to @link, notice: 'Comment updated'
  else
    render :edit
  end
end

def destroy
  if current_user.owns_comment?(@comment)
    @comment.destroy
    redirect_to @link, notice: 'Comment deleted'
  else
    redirect_to @link, notice: 'Not authorized to delete this comment'
  end
end

The edit action makes the link and comment instance variables available for the view, it also prevents unauthorized users from editing a comment that doesn't belong to them. The update action simply changes the comment content if valid otherwise it renders the edit page and shows errors and finally, the destroy action deletes a comment if the user is authorized to do so.

With these all done, all we have left is to wire this up to the view. Open the link show page found in: app/views/links/show.html.erb and the following after the span element with a class name of comment-created:

<% if logged_in? && current_user.owns_comment?(comment) %>
  <span class="edit-comment">
    <%= link_to 'edit',  edit_link_comment_path(@link, comment) %>
  </span>

  <span class="delete-comment">
    <%= link_to 'delete', link_comment_path(@link, comment), method: :delete, data: { confirm: 'Are you sure?' } %>
  </span>
<% end %>

This simply displays the edit and delete links if the user owns the comment. The delete functionality is all done but we still have one more step to go for users to be able to edit their comment.

Open the comment edit page found in: app/views/comments/edit.html.erb, it should look something like this:

<h1>Comments#edit</h1>
<p>Find me in app/views/comments/edit.html.erb</p>

Remove those elements and replace with:

<section class="edit-comment">
  <header>
    <h1>
      Edit comment
    </h1>
  </header>

  <div class="row">
    <div class="col-sm-6">
      <%= form_for :comment, url: link_comment_path(@link, @comment), method: :patch do |f| %>
        <%= render "shared/errors", object: @comment %>
        <div class="form-group">
          <%= f.text_area :body, class: "form-control", rows: 10, required: true %>
        </div>

        <div class="form-group">
          <%= f.button "edit comment", class: "btn btn-success" %>
        </div>

      <% end %>
    </div>
  </div>
</section>

This simply provides a form that can be used to edit / update an existing comment.

Displaying all comments

This is a trivial functionality to implement. What we need is just a page to display all comments.

Let's update the navbar partial found in: app/views/shared/_navbar.html.erb and remove the dead link for the comments link and point the link instead to the page that lists all comments:

<%= link_to "comments", comments_path %>

Open the comments controller found in: app/controllers/comments_controller.rb and add the following to the index action:

@comments = Comment.all

Now, proceed to the comment index page found in: app/views/comments/index.html.erb. It should look something like this:

<h1>Comments#index</h1>
<p>Find me in app/views/comments/index.html.erb</p>

Edit to look like this:

<section class="sitewide-comments">
  <% @comments.each do |comment| %>
  <div class="comment">
    <p class="comment-owner">
      <strong>
        <%= comment.user.username %>
      </strong>

      <span class="comment-created small">
        <%= time_ago_in_words(comment.created_at) %> ago
      </span>

      <% if logged_in? && current_user.owns_comment?(comment) %>
      <span class="edit-comment">
        <%= link_to 'edit',  edit_link_comment_path(comment.link, comment) %>
      </span>

      <span class="delete-comment">
        <%= link_to 'delete', link_comment_path(comment.link, comment), method: :delete, data: { confirm: 'Are you sure?' } %>
      </span>
      <% end %>
    </p>

    <p>
      <%= comment.body %>
    </p>
  </div>
  <% end %>
</section>

This simply loops through all the comments and displays them on the view.

Now that we have comments all set up, we'll be able to display the amount of comments a link has on the homepage (this will also act as a url to the link show page).

Open the link model found in: app/models/link.rb and add the following:

def comment_count
  comments.length
end

Open the link index page found in: app/views/links/index.html.erb and right after the span element with a class name of time-created, add the following:

<span class="comment-count">
  <%= link_to pluralize(link.comment_count, 'comment'), link %>
</span>

With this, a user will be able to see the amount of comments a link thread has and clicking on it will take the user to the link thread itself.

Let's create the vote model which will be used to implement the functionality for voting on links. The vote model will belong to both a user and a link, it will also have columns for upvote and downvote. The difference between upvotes and downvotes on a link will be the amount of points that a link has.

Open the terminal and enter:

rails g model vote user:belongs_to link:belongs_to upvote:integer downvote:integer

Open the newly generated migration found in db/migrate, the file should end with create_votes.rb. Edit to look like this:

class CreateVotes < ActiveRecord::Migration[5.0]
  def change
    create_table :votes do |t|
      t.belongs_to :user, foreign_key: true
      t.belongs_to :link, foreign_key: true
      t.integer :upvote, default: 0
      t.integer :downvote, default: 0

      t.timestamps
    end
  end
end

We have simply added a default value of zero (0) for the upvote and downvote columns. Proceed to run the migration:

rails db:migrate

Open the vote model found in: app/models/vote.rb and ensure it has the belongs_to calls that map it to the user model as well as the link model:

class Vote < ApplicationRecord
  belongs_to :user
  belongs_to :link
end

Also, add the following to the user and link model:

has_many :votes

Before proceeding to provide a way to vote on links from the view, let's also add two new columns to the link model. We'll be adding the points and hot_score columns. The points column will hold the amount of points a link has garnered based on the difference between it's upvotes and downvotes and the hot_score column will be used when we rank top links on the homepage, we'll discuss further about the hot_score column when we get around to implementing the functionality.

Open the terminal and enter:

rails g migration addPointsAndHotscoreToLink points:integer hot_score:float

Open the migration folder found in: db/migrate, look for the file ending with: add_points_and_hotscore_to_link.rb and add the following:

class AddPointsAndHotscoreToLink < ActiveRecord::Migration[5.0]
  def change
    add_column :links, :points, :integer, default: 1
    add_column :links, :hot_score, :float, default: 0
  end
end

We just added default values of one (1) and zero (0) to the points and hot_score columns respectively.

Run the migration:

rails db:migrate

While we are at it, let's display the amount of points a link thread has on the homepage as well as the owner. Open the link index page found in: app/views/links/index.html.erb and right before the span element with a class name of time-created, add the following:

<span class="points">
  <%= pluralize(link.points, 'point') %> by <%= link.user.username %>
</span>

Let's start by providing a route for upvoting links. Open the routes file found in: config/routes.rb and edit the link resource:

resources :links, except: :index do
  resources :comments, only: [:create, :edit, :update, :destroy]
  post :upvote, on: :member
end

This will provide a route like link/5/upvote that we'll be able to use to add an upvote to a link.

Lets add a method that allows us add an upvote to a link. Open the user model found in: app/models/user.rb and add the following:

def upvote(link)
  votes.create(upvote: 1, link: link)
end

Now, open the link controller found in: app/controllers/links_controller.rb and add the following:

def upvote
  link = Link.find_by(id: params[:id])
  current_user.upvote(link)

  redirect_to root_path
end

Let's proceed to wire this functionality to the view. Open the link index page found in: app/views/links/index.html.erb and right after the conditional that adds the edit and delete link elements, add the following:

<% if logged_in? %>
  <span class="upvote-link">
    <%= link_to "upvote (#{link.upvotes})", upvote_link_path(link), method: :post %>
  </span>
<% end %>

If you try this out in the browser right now, it'll error out because we are calling a method on the link index page that we haven't created yet, link.upvotes, let's proceed to do that.

Open the link model found in: app/models/link.rb, and add the following:

def upvotes
  votes.sum(:upvote)
end

This simply gets the sum of all upvotes for a link.

At this point, we should be able to add upvotes but with one caveat, a user can add as many upvotes to a link as possible and this shouldn't be the case. What we want is to cancel or remove a vote if a user tries to vote on the same link again as a user can at most vote on a link only once. Let's proceed to implement this.

Open the vote model found in: app/models/vote.rb and add the following:

validates :user_id, uniqueness: { scope: :link_id }

This validation ensures that a user can only add one vote to a link, any additional votes will not be saved. Let's proceed to add a method to the user model that checks if a user has already upvoted a link and a method to remove a vote on a link. Open the user model found in: app/models/user.rb and add the following:

def upvoted?(link)
  votes.exists?(upvote: 1, link: link)
end

def remove_vote(link)
  votes.find_by(link: link).destroy
end

This simply checks if a vote has already been added to the specified link and returns true or false appropriately.

Let's go ahead and update the link controller's upvote action:

def upvote
  link = Link.find_by(id: params[:id])

  if current_user.upvoted?(link)
    current_user.remove_vote(link)
  else
    current_user.upvote(link)
  end

  redirect_to root_path
end

This adds in the functionality that was discussed earlier on, if a user has already upvoted a link, the upvote is removed, otherwise the upvoted is added.

Much of the same functionality that has been implemented for link upvote will carry over to downvoting links. Here are a few things to note when the downvote action is performed (we'll implement the same for upvote as well):

  • A downvote will replace an upvote if there is any and vice versa
  • A downvote will be removed if there is already one (user already downvoted)

Edit the link resource in the route file found in config/routes:

resources :links, except: :index do
  resources :comments, only: [:create, :edit, :update, :destroy]
  post :upvote, on: :member
  post :downvote, on: :member
end

We have added a route for downvotes to a link.

On to the user model found in: app/models/user.rb. Add the following:

def downvote(link)
  votes.create(downvote: 1, link: link)
end

def downvoted?(link)
  votes.exists?(downvote: 1, link: link)
end

Let's also add the following to the link model found in: app/models/link.rb

def downvotes
  votes.sum(:downvote)
end

Let's proceed to implement this functionality in the controller.

Open the link controller found in: app/controllers/links_controller.rb and add the following:

def downvote
  link = Link.find_by(id: params[:id])

  if current_user.downvoted?(link)
    current_user.remove_vote(link)
  elsif current_user.upvoted?(link)
    current_user.remove_vote(link)
    current_user.downvote(link)
  else
    current_user.downvote(link)
  end

  redirect_to root_path
end

While we are at it, let's also edit the upvote action:

def upvote
  link = Link.find_by(id: params[:id])

  if current_user.upvoted?(link)
    current_user.remove_vote(link)
  elsif current_user.downvoted?(link)
    current_user.remove_vote(link)
    current_user.upvote(link)
  else
    current_user.upvote(link)
  end

  redirect_to root_path
end

Both actions ensure that the notes pointed out earlier on are adhered to. With the actions created, let's move onto the views. Open the link index page found in: app/views/links/index.html.erb and add the following after the span element with a class name of upvote-link:

<span class="downvote-link">
  <%= link_to "downvote (#{link.downvotes})", downvote_link_path(link), method: :post %>
</span>

Let's also ensure to secure the upvote and downvote actions from unauthorized users. Open the link controller found in: app/controllers/links_controller.rb and update the before_action method call:

before_action :prevent_unauthorized_user_access, except: [:show, :index]

This means that users who aren't logged in will only be able to access the show and index actions of the link controller.

Earlier on, we added a hot_score column to the link model, this column will be what we use when displaying links on the homepage.

The hot score of a link will be based on a time decay algorithm and how this works is fairly simple, a time decay algorithm will reduce the value of a number based on the time (in hours) and gravity (an arbitrary number). What this means is that as a link gets older, it's hot score will start to drop and it's ranking affected.

This ensures that old links with a lot of points don't continually stay at the top of the ranking but will at some point drop and this will ensure that newer threads with not as much points get a fair ranking as well. A more in-depth explanation of this algorithm can be found here: Explaining hacker news hot score algorithm

Let's proceed to wire this functionality into our application. Open the link model found in: app/models/link.rb and add the following:

def calc_hot_score
  points = upvotes - downvotes
  time_ago_in_hours = ((Time.now - created_at) / 3600).round
  score = hot_score(points, time_ago_in_hours)

  update_attributes(points: points, hot_score: score)
end

private

def hot_score(points, time_ago_in_hours, gravity = 1.8)
  # one is subtracted from available points because every link by default has one point 
  # There is no reason for picking 1.8 as gravity, just an arbitrary value
  (points - 1) / (time_ago_in_hours + 2) ** gravity
end

With the calc_hot_score and hot_score method created, we are one step away from fully implementing the ranking of links on the application. Still within the link model, add the following:

scope :hottest, -> { order(hot_score: :desc) }

Great! This means that we can call Link.hottest to return links sorted by how relevant / hot they are.

Open the link controller found in: app/controllers/links_controller.rb

Change the call to all in the index action:

@link = Link.hottest

Add the following to both the upvote and downvote action just before the call to redirect:

link.calc_hot_score

We are all done with this functionality. Try it out in the browser and at this point, we should be able to upvote / downvote a link and links on the homepage should be ranked based on their hot score.

This is a very basic feature to implement, we will be displaying all links based on the time they were created rather than by how hot they are.

Open the link model found in: app/models/link.rb and add the following:

scope :newest, -> { order(created_at: :desc) }

Let's add a new route for displaying newest links to the route file. Open config/routes and enter:

get '/newest' => 'links#newest'

Open the link controller found in: app/controllers/links_controller.rb and add the following:

def newest
  @newest = Link.newest
end

Create the file newest.html.erb in the folder app/views/links. The link newest page will have the same content as the link index page. Seeing as both pages will have the same content, let's move the content of the link index page into a partial.

Create the partial _link.html.erb in the folder: app/views/links and add the following to it:

<div class="link">
  <div class="title">
    <%= link_to link.title, (link.url? ? link.url : link) %>

    <% if link.url? %>
      <span>
        ( <%= link.url %> )
      </span>
    <% end %>
  </div>

  <div class="metadata">
    <span class="points">
      <%= pluralize(link.points, 'point') %> by <%= link.user.username %>
    </span>

    <span class="time-created">
      <%= time_ago_in_words(link.created_at) %> ago
    </span>

    <span class="comment-count">
      <%= link_to pluralize(link.comment_count, 'comment'), link %>
    </span>

    <% if logged_in? && current_user.owns_link?(link) %>
      <span class="edit-link">
        <%= link_to "edit", edit_link_path(link) %>
      </span>

      <span class="delete-link">
        <%= link_to "delete", link, method: :delete, data: { confirm: "Are you sure?" } %>
      </span>
    <% end %>

    <% if logged_in? %>
      <span class="upvote-link">
        <%= link_to "upvote (#{link.upvotes})", upvote_link_path(link), method: :post %>
      </span>

      <span class="downvote-link">
        <%= link_to "downvote (#{link.downvotes})", downvote_link_path(link), method: :post %>
      </span>
    <% end %>
  </div>

</div>

Now, let's proceed to edit the link index page:

<section class="all-links">
  <%= render @links %>
</section>

Let's also add this to the link newest page:

<section class="newest-links">
  <%= render @links %>
</section>

Following the style of hacker news, let's also add this to the link show page to replace the simple title we had before. Open the file: app/views/links/show.html.erb and replace this piece of code:

<h4>
  <%= @link.title %>
</h4>

with:

<%= render @link %>

We are using a rails feature here, when you call render on an instance variable holding an active record relation object, it will iteratively loop through the object and then render the appropriate partial.

With that done, let's remove the last dead link on the navbar. Open the navbar partial found in: app/views/shared/_navbar.html.erb and edit the link with the value new as title:

<%= link_to "new", newest_links_path %>

Wrapping up

Let's add a few basic styling to the page so that it's a bit more visually appealing. Open the styles.scss file found in: app/assets/stylesheets/styles.scss and add the following:

.metadata {
  span {
    margin: 0 4px;
    border-right: 2px solid #777;
    
    &:first-child {
      margin: 0;
    }
    
    &:last-child {
      border: 0;
    }
  }
}

.comment {
  margin: 12px auto;
  border-bottom: 2px solid #f5f5f5;
}

Although the application at this point lacks a bunch of features like pagination of links and comments, upvotes / downvotes on comments, user karma system etc, we have built enough to give us a baseline if we decide to further extend it later.

That's all folks

The github repo for this project can be found here and a live demo can be found here

Discover and read more posts from Daniel Chinedu
get started
Enjoy this post?

Leave a like and comment for Daniel

28
1