Building A Basic Hacker News Clone with Rails 5
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
- What we'll be building
- Getting started
- Starting the server
- Setting up the models
- Let's start with the users - user signup/registration fuctionality
- Basic Authentication - Login / Logout functionality
- Let's get down to business - Submitting and displaying links
- Voting on links
- Ranking links on the homepage
- Showing newest links
- Wrapping up
- That's all folks
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.
Let's get down to business - Submitting and displaying links
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.
The link model
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.
Validating a link
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.
Creating a link
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.rb
to 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_action
callback 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.
Displaying links on the homepage
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.
User actions for links - editing / deleting a link
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.
Adding comments to a link
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 body
column 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
Displaying comments on link thread
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.
Voting on links
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>
Upvoting links
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.
Downvoting links
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.
Ranking links on the homepage
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.
Showing newest links
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
We’ll be creating a simple link-sharing web application similar to Hacker News or Reddit using the latest Ruby on Rails framework. Much like how Remini Mod APK enhances photo quality with advanced technology, our app will leverage Rails’ powerful features to deliver an exceptional user experience. Even if you’re using an older version of Ruby on Rails, you can still follow along seamlessly. https://reminimod.com/
To download the PicsArt Gold version for free, you can search for a modded version of the app online. This version typically offers premium features, including exclusive filters and stickers, ad-free editing, and access to a vast library of creative content, similar to the features found in PicsArt Mod APK. However, it’s important to be cautious when downloading from unofficial sources to avoid potential security risks. https://picxartsapp.net/best-photo-editing-apps/
Embark on a journey to build a basic Hacker News clone with Rails 5, utilizing the comprehensive resources available through PC Download. Dive into the world of web development and create your dynamic platform. https://manoknapulamodapk.org/manok-na-pula-for-pc/