Reddit Clone in Rails 5 using Twitter Bootstrap 4

Libraries Used

  • Devise
  • Acts as Votable
  • Twitter Bootstrap 4

Reddit Clone

Create a new Rails 5 project.

rails new rabbit --skip-spring

Add devise gem to Gemfile.

gem 'devise', :github => 'plataformatec/devise', :branch => 'master'

Run:

bundle

Run devise generator:

rails generate devise:install

Add the mailer url in config/environments/development.rb:

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

Add the root url in routes.rb:

root to: "links#index"

Create user model that will be used for authentication by devise.

rails g devise user

Migrate the database.

rails db:migrate

Copy the views provided devise to our project so that we can customize it.

rails g devise:views

Create the link model with string fields title and url. It also has a user_id foreign key.

rails g model link title url user:references

Add the relationship declaration to user model.

has_many :links

Add the relationship declaration to link model.

belongs_to :user

Add the devise routes to routes.rb:

devise_for :users 

This provides the following routes:

                  Prefix Verb   URI Pattern                    Controller#Action
        new_user_session GET    /users/sign_in(.:format)       devise/sessions#new
            user_session POST   /users/sign_in(.:format)       devise/sessions#create
    destroy_user_session DELETE /users/sign_out(.:format)      devise/sessions#destroy
           user_password POST   /users/password(.:format)      devise/passwords#create
       new_user_password GET    /users/password/new(.:format)  devise/passwords#new
      edit_user_password GET    /users/password/edit(.:format) devise/passwords#edit
                         PATCH  /users/password(.:format)      devise/passwords#update
                         PUT    /users/password(.:format)      devise/passwords#update
cancel_user_registration GET    /users/cancel(.:format)        devise/registrations#cancel
       user_registration POST   /users(.:format)               devise/registrations#create
   new_user_registration GET    /users/sign_up(.:format)       devise/registrations#new
  edit_user_registration GET    /users/edit(.:format)          devise/registrations#edit
                         PATCH  /users(.:format)               devise/registrations#update
                         PUT    /users(.:format)               devise/registrations#update
                         DELETE /users(.:format)               devise/registrations#destroy

This is the output of rails routes. Add the link resource to routes.

resources :links

Create a links controller.

rails g controller links

Links controller looks like this:

class LinksController < ApplicationController
  before_action :set_link, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!, except: [:index, :show]
  before_action :authorized_user, only: [:edit, :update, :destroy]

  # GET /links
  # GET /links.json
  def index
    @links = Link.all
  end

  # GET /links/1
  # GET /links/1.json
  def show
  end

  # GET /links/new
  def new
    @link = current_user.links.build
  end

  # GET /links/1/edit
  def edit
  end

  # POST /links
  # POST /links.json
  def create
    @link = current_user.links.build(link_params)

    respond_to do |format|
      if @link.save
        format.html { redirect_to @link, notice: 'Link was successfully created.' }
        format.json { render :show, status: :created, location: @link }
      else
        format.html { render :new }
        format.json { render json: @link.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /links/1
  # PATCH/PUT /links/1.json
  def update
    respond_to do |format|
      if @link.update(link_params)
        format.html { redirect_to @link, notice: 'Link was successfully updated.' }
        format.json { render :show, status: :ok, location: @link }
      else
        format.html { render :edit }
        format.json { render json: @link.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /links/1
  # DELETE /links/1.json
  def destroy
    @link.destroy
    respond_to do |format|
      format.html { redirect_to links_url, notice: 'Link was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_link
      @link = Link.find(params[:id])
    end

    def authorized_user
      @link = current_user.links.find_by(id: params[:id])
      redirect_to links_path, notice: "Not authorized to edit this link" if @link.nil?
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def link_params
      params.require(:link).permit(:title, :url)
    end
end

Create the link form partial:

<%= form_for(@link) do |f| %>
  <% if @link.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@link.errors.count, "error") %> prohibited this link from being saved:</h2>
      <ul>
      <% @link.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <%= f.label :title %><br>
    <%= f.text_field :title, class: "form-control" %>
  </div>
  <div class="form-group">
    <%= f.label :url %><br>
    <%= f.text_field :url, class: "form-control" %>
  </div>
  <br>
  <div class="form-group">
    <%= f.submit "Submit", class: "btn btn-lg btn-primary" %>
  </div>
<% end %>

Create links/edit.html.erb:

<h1>Editing link</h1>
<%= render 'form' %>

<%= link_to 'Show', @link %> |
<%= link_to 'Back', links_path %>

Create links/index.html.erb:

<% @links.each do |link| %>
  <div class="link row clearfix">
    <h2>
      <%= link_to link.title, link %><br>
      <small class="author">Submitted <%= time_ago_in_words(link.created_at) %> by <%= link.user.name %></small>
    </h2>
  </div>
<% end %>

Create the links/new.html.erb:

<h1>Submit New link</h1>
<%= render 'form' %>
<%= link_to 'Back', links_path %>

Create the links/show.html.erb:

<div class="page-header">
  <h1><a href="<%= @link.url %>"><%= @link.title %></a><br> <small>Submitted by <%= @link.user.name %></small></h1>
</div>

<div class="btn-group">
    <%= link_to 'Visit URL', @link.url, class: "btn btn-primary" %>
</div>

<% if @link.user == current_user -%>
    <div class="btn-group">
        <%= link_to 'Edit', edit_link_path(@link), class: "btn btn-default" %>
        <%= link_to 'Destroy', @link, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
    </div>
<% end %>

Add name field to the users table.

rails g migration add_name_to_users name

Migrate the database.

rails db:migrate

Start the rails server.

rails s

You can now submit links. To add the name, change devise/registrations/new.html.erb:

<div class="field">
  <%= f.label :name %><br />
  <%= f.text_field :name, autofocus: true %>
</div>

Still the name is not populated when the link is displayed. To fix it, in application controller, add:

before_filter :configure_permitted_parameters, if: :devise_controller?

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.for(:sign_up) << :name
  devise_parameter_sanitizer.for(:account_update) << :name
end

This gives the:

undefined method `for' for #<Devise::ParameterSanitizer

Change the implementation as follows:

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_up, keys: :name)
  devise_parameter_sanitizer.permit(:account_update, keys: :name)
end

This results in:

no implicit conversion of Symbol into Array

Change the implementation as follows:

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
  devise_parameter_sanitizer.permit(:account_update, keys: [:name])
end

You can now display the user name of the submitted link. Add bootstrap gem to Gemfile.

gem 'bootstrap', '~> 4.0.0.alpha3'

Run:

bundle

Within the head tag of the application layout, add:

<!-- Required meta tags always come first -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta http-equiv="x-ua-compatible" content="ie=edge">

Add navigation bar within body tag in application layout:

<nav class="navbar navbar-light bg-faded">
  <%= link_to 'Rabbit', root_path, class: 'navbar-brand' %>
  <ul class="nav navbar-nav">
  <% if user_signed_in? %>  
    <li class="nav-item">
      <%= link_to 'Submit Link', new_link_path, class: 'nav-link' %> 
    </li>
    <li class="nav-item">
      <%= link_to 'Account', edit_user_registration_path, class: 'nav-link' %>
    </li>
    <li class="nav-item">
       <%= link_to 'Sign Out', destroy_user_session_path, class: 'nav-link', method: :delete %>
        </li>
      <% else %>
        <li class="nav-item">
          <%= link_to 'Sign Up', new_user_registration_path, class: 'nav-link' %>
        </li>
        <li class="nav-item">
           <%= link_to 'Sign In', new_user_session_path, class: 'nav-link' %>
        </li>
      <% end %>
    </ul>
    <form class="form-inline pull-xs-right">
      <input class="form-control" type="text" placeholder="Search">
      <button class="btn btn-success-outline" type="submit">Search</button>
    </form>
</nav>

Change field class to form-group in devise/registrations/new.html.erb:

<div class="form-group">
  <%= f.label :name %><br />
  <%= f.text_field :name, autofocus: true %>
</div>

<div class="form-group">
  <%= f.label :email %><br />
  <%= f.email_field :email %>
</div>

<div class="form-group">
  <%= f.label :password %>
  <% if @minimum_password_length %>
  <em>(<%= @minimum_password_length %> characters minimum)</em>
  <% end %><br />
  <%= f.password_field :password, autocomplete: "off" %>
</div>

<div class="form-group">
  <%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation, autocomplete: "off" %>
</div>

<div class="actions">
  <%= f.submit "Sign up", class: 'btn btn-primary' %>
</div>

To make the text field size reasonable, change the class in application layout:

<div id="content" class="col-md-4 center-block">
  <%= yield %>
</div>

Change the style of edit and destroy buttons by adding the classes link/show.html.erb.

<%= link_to 'Edit', edit_link_path(@link), class: "btn btn-secondary" %>
<%= link_to 'Destroy', @link, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-danger" %>

Make the submitted by small case and group buttons.

<div>
  <h3><a href="<%= @link.url %>"><%= @link.title %></a><br> <small>Submitted by <%= @link.user.name %></small></h3>
</div>

<div class="btn-group">
    <%= link_to 'Visit URL', @link.url, class: "btn btn-primary" %>
</div>

<% if @link.user == current_user -%>
    <div class="btn-group">
        <%= link_to 'Edit', edit_link_path(@link), class: "btn btn-secondary" %>
        <%= link_to 'Destroy', @link, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-danger" %>
    </div>
<% end %>

The outer div should be main_content and the yield should be within the content like this:

<div id="main_content" class="container">
  <% flash.each do |name, msg| %>
    <%= content_tag(:div, msg, class: "alert alert-info") %>
  <% end %>

  <div id="content" class="col-md-6 center-block">
      <%= yield %>
  </div>
</div>

Reload the page, you will see the links centered, styled and separated by lines. Remove the back link the new link submission form and add Back button to the form partial:

<div class="form-group">
  <%= f.submit "Submit", class: "btn btn-lg btn-primary" %>
  <%= link_to 'Back', links_path, class: 'btn btn-lg btn-secondary' %>
</div>

Let's style the edit account page (http://localhost:3000/users/edit). Add form-group and form-control to registrations/edit.html.erb. Use card and card-block classes.

<h2>Edit <%= resource_name.to_s.humanize %></h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
  <%= devise_error_messages! %>

  <div class='card'>
    <div class='card-block'>

      <div class="form-group">
        <%= f.label :email %><br />
        <%= f.email_field :email, autofocus: true, class: 'form-control' %>
      </div>

      <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
        <div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
      <% end %>

      <div class="form-group">
        <%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
        <%= f.password_field :password, autocomplete: "off", class: 'form-control' %>
        <% if @minimum_password_length %>
          <br />
          <em>(<%= @minimum_password_length %> characters minimum)</em>
        <% end %>
      </div>

      <div class="form-group">
        <%= f.label :password_confirmation %><br />
        <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control' %>
      </div>

      <div class="form-group">
        <%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
        <%= f.password_field :current_password, autocomplete: "off", class: 'form-control' %>
      </div>

      <div class="actions">
        <%= f.submit "Update", class: 'btn btn-lg btn-primary' %>
      </div>
     </div>
  </div>
<% end %>

<div class='card-footer'>
<h3>Cancel my account</h3>

<p>Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete, class: 'btn btn-lg btn-secondary' %>
</p>

</div>
<hr/>
<%= link_to "Back", :back, class: 'btn btn-lg btn-secondary' %>

Add acts_as_votable gem to Gemfile.

gem 'acts_as_votable'

Run:

bundle

Create the votes table.

rails g acts_as_votable:migration

Migrate the database.

rails db:migrate

Add

acts_as_votable

to Link model. Define route to like and dislike in routes.rb.

resources :links do
  member do
    put 'like', to: 'links#upvote'
    put 'dislike', to: 'links#downvote'
  end
end

Implement upvote and downvote actions in links controller.

def upvote
  @link = Link.find(params[:id])  
  @link.upvote_by current_user

  redirect_to :back
end

def downvote
  @link = Link.find(params[:id])
  @link.downvote_by current_user

  redirect_to :back
end

Checkout How to build a Pinterest Clone in Rails 5 to see how to get glyphicons to work with bootstrap 4. To display upvote and downvote glyphicons using font awesome, change the links/index.html.erb:

<% @links.each do |link| %>
  <div class="link row clearfix">
    <h3>
      <%= link_to link.title, link %><br>
      <small>Submitted <%= time_ago_in_words(link.created_at) %> by <%= link.user.name %></small>
    </h3>
    <div class='btn-group'>
      <%= link_to 'Visit Link', link.url, class: 'btn btn-secondary' %> 
      <%= link_to like_link_path(link), method: :put, class: 'btn btn-secondary' do %>
        <span class='fa fa-arrow-up'></span>
        Upvote
        <%= link.get_upvotes.size %>
      <% end %>
      <%= link_to dislike_link_path(link), method: :put, class: 'btn btn-secondary' do %>
        <span class='fa fa-arrow-down'></span>
        Downvote
        <%= link.get_downvotes.size %>
      <% end %>
    </div>
  </div>
<% end %>

Add voting links to the bottom of the link show page.

<div class='btn-group pull-right'>
  <%= link_to like_link_path(@link), method: :put, class: 'btn btn-secondary btn-sm' do %>
    <span class='fa fa-arrow-up'></span>    
    Upvote
    <%= @link.get_upvotes.size %>
  <% end %>
  <%= link_to dislike_link_path(@link), method: :put, class: 'btn btn-secondary btn-sm' do %>
  <span class='fa fa-arrow-down'></span>
  Downvote
  <%= @link.get_downvotes.size %>
  <% end %>
</div>

Change the width to accommodate all buttons by changing the class in application layout file:

<div id="content" class="col-md-9 center-block">
  <%= yield %>
</div>

Generate the comment model.

rails g scaffold comment link_id:integer:index body:text user:references --skip-stylesheets

Migrate the database.

rails db:migrate

Define the relationship between link and comments in the link model.

has_many :comments

Define the relationship between comment and link in the comment model.

belongs_to :link

In routes, add:

resources :comments 

inside the link resource, like this:

resources :links do
  member do
    put 'like', to: 'links#upvote'
    put 'dislike', to: 'links#downvote'
  end
  resources :comments
end

Add comment section to link show page.

<h3 class='comments_title'>
  <%= pluralize(@link.comments.count, 'Comment') %>
</h3>

<div id='comments'>
    <%= render @link.comments %>
</div>

<%= form_for([@link, Comment.new]) do |f| %>
  <div class='form-group'>
    <%= f.label :body %>  
    <%= f.text_area :body, class: 'form-control' %>
  </div>
  <br>
  <div class='form-group'>
    <%= f.submit 'Add Comment', class: 'btn btn-primary' %>
  </div>
<% end %>

Delete unnecessary actions in comments controller and customize the create and destroy actions.

class CommentsController < ApplicationController
  before_action :set_comment, only: [:show, :edit, :update, :destroy]
  before_action :authenticate_user!

  def create
    @link = Link.find(params[:link_id])
    @comment = @link.comments.build(comment_params)
    @comment.user = current_user

    respond_to do |format|
      if @comment.save
        format.html { redirect_to @link, notice: 'Comment was successfully created.' }
        format.json { render :show, status: :created, location: @comment }
      else
        format.html { render :new }
        format.json { render json: @comment.errors, status: :unprocessable_entity }
      end
    end
  end

  def destroy
    @comment.destroy
    respond_to do |format|
      format.html { redirect_to :back, notice: 'Comment was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_comment
      @comment = Comment.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def comment_params
      params.require(:comment).permit(:link_id, :body, :user_id)
    end
end

Create comments/_comment.html.erb:

<div id="comment_#{comment.id}" class='comment'>
  <div class="comments_wrapper clearfix">
    <div class="pull-left">
      <p class="lead"><%= comment.body %></p>
      <p><small>Submitted <strong><%= time_ago_in_words(comment.created_at) %> ago</strong> by <%= comment.user.email %></small></p>
    </div>

    <div class="btn-group pull-right">
      <% if comment.user == current_user -%>
        <%= link_to 'Destroy', comment, method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-sm btn-default" %>
      <% end %>
    </div>
  </div>
</div>

Display name in the edit account page.

<div class="form-group">
  <%= f.label :name %><br />
  <%= f.text_field :name, autofocus: true, class: 'form-control' %>
</div>

We can update the name because of the method configure_permitted_parameters . You can download the source code for this article from https://github.com/bparanj/rabbit.

Summary

In this article, you learned how to create a reddit clone using Rails 5, actsasvotable, font awesome, devise and Twitter Bootstrap 4.

References

FontAwesome Cheatsheet
Migrating to v4


Related Articles


Create your own user feedback survey