Let’s Build A Dribbble Clone With Ruby on Rails

Welcome to a five part mini-series where I teach you how to build a Dribbble clone in Ruby on Rails. This series is our most thorough build yet!

Dribbble is an active design community where designers of all varieties post their “shots” of whatever it is they are working on. What was originally intended to become more of “show your progress” type of site has become more of a portfolio for up and coming designers as well as seasoned pros.

Our Dribbble clone will introduce some new concepts as well as others I covered in previous builds. If you landed here and are brand new to Ruby on Rails, I invite you to check out my other series below for some foundational principles behind the framework.

What we will be building in depth

Our app will have the following features:

  • The ability to create, edit, and destroy “shots” as well as like and unlike individual shots.
  • User roles and authentication
  • Drag and drop functionality
  • Commenting functionality
  • View counts/analytics
  • A custom responsive shot grid UI using CSS Grid

There’s a lot under the hood of this build so I went ahead and created a public repo on Github for you to follow along/reference. For time sake you’ll notice in some of the following videos that I copied and pasted some general styles and mark up

Download the Source Code

What won’t be featured in the build

  • Independent user profiles
  • Search
  • Tagging
  • Custom color palette generation as Dribbble currently does.

There’s a ton we could do to extend the clone but since it’s an “example” type of application I decided to forgo extending it dramatically for now. In the future, I may add to this series and extend it further but I mostly invite you to do that same on your own to see how you could mimic the real app even more.

Watch Part 2

Watch Part 3

Watch Part 4

Watch Part 5

Getting started

Assuming you have rails and ruby installed on your machine (learn how to here) you are ready to begin. From your favorite working directory using your favorite command line tool run the following:

$ rails new dribbble_clone

This should scaffold a new rails project and create the folder called dribbble_clone.

To kick things off I’ve added all the gems from this project to my Gemfile. You can reference the repo if you haven’t already to grab the same versions I’ve used here.

The Gem List

Our gem list has grown since the previous build. You’ll find the larger your app becomes the more gems you’ll need.

  • CarrierWave + Mini Magick – For image uploads and optimization
  • Devise – User authentication and roles
  • Guard – Doing work to files as they change – A task runner of sorts
  • Guard Livereload – Reloads the browser when files change combined with the Live Reload extension on your favorite browser.
  • Better Errors – Displays better errors during development.
  • Simple Form – Simpler forms in Rails
  • Bulma Rails – My favorite CSS framework as of late based on Flexbox.
  • Impressionist – We use this to get view counts on shots
  • Gravatar Image Tag – Easy way to grab a user’s gravatar image based on their account email
  • Acts As Votable – Like and unlike shots

I’ll defer to the videos for a guide in getting our project all set up with each individual gem. The bulk of Part 1 is devoted to this process. We customize Devise a bit to add a “name” field for new users who register. Again reference the repo to see what that entails.

Scaffolding the Shot

We could go through and create a controller, model, and many other files for our Shot but it would much easier and probably more effective to scaffold our Shot and remove any files we don’t need afterward. Rails lends us a hand by generating all the necessary files and migration files we need when running a scaffold command.

Run the following to generate our Shot with title and description attributes:


rails g scaffold Shot title:string description:text user_id:integer
rails db:migrate

Associate Users to Shots

Navigate to your models direction within the app directory. We need to associate users with shots. Amend your files to look like the following. Notice we have some data here already added when we installed Devise. You may see some of these associations already in place. If not they should look like the following at this point:


# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
  has_many :shots
end

# app/models/shot.rb
class Shot < ApplicationRecord
    belongs_to :user
end

Carrierwave Setup

A foundational piece of a shot is, of course, its image. Carrierwave is a gem for image uploads with a bunch of bells and whistles. Make sure you have installed the CarrierWave gem and then run the following to generate a new uploader file called user_shot_uploader.rb. Be sure you also have the MiniMagick gem I referenced installed at this point.

$ rails generate uploader user_shot

this should give you a file in:

app/uploaders/user_shot_uploader.rb

Inside that file we can customize some bits about how CarrierWave treats our images. My file looks like this when it’s all said and done. Note: I’ve removed some comments that weren’t necessary.


class UserShotUploader < CarrierWave::Uploader::Base

  # Include RMagick or MiniMagick support:
  # include CarrierWave::RMagick
  include CarrierWave::MiniMagick

  # Choose what kind of storage to use for this uploader:
  storage :file

  # Override the directory where uploaded files will be stored.
  # This is a sensible default for uploaders that are meant to be mounted:
  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

  # Create different versions of your uploaded files:
  version :full do
    process resize_to_fit: [800, 600]
  end
  version :thumb do
    process resize_to_fit: [400, 300]
  end

  def extension_whitelist
    %w(jpg jpeg gif png)
  end
end

Associating our User Shot with a Shot

We need our new user shot image to be a field on our database table. To do this we need to add that as a migration which gives us a user_shot column of the type string.


$ rails g migration add_shot_to_users shot:string
$ rails db:migrate

Inside the shot.rb file within app/models/shot.rb add the following code to make it all work.


class Shot < ActiveRecord::Base
  belongs_to :user
  mount_uploader :user_shot, UserShotUploader # add this line
end

To really allow a user to upload a shot we need to configure our controller to allow all of these new parameters.

Creating a new shot

Our shot controller needs some TLC to function properly at this point. Aside from the controller I wanted to introduce some drag and drop type of functionality within the new and edit views. Dribbble does an automatic image upload as part of their initial Shot creation. To save time I mimiced this but it definitely falls short of how it all works normally.

The shots_controller.rb found in app/controllers/ looks like this in the end:


class ShotsController < ApplicationController
  before_action :set_shot, only: [:show, :edit, :update, :destroy, :like, :unlike]
  before_action :authenticate_user!, only: [:edit, :update, :destroy, :like, :unlike]
  impressionist actions: [:show], unique: [:impressionable_type, :impressionable_id, :session_hash]

  # GET /shots
  # GET /shots.json
  def index
    @shots = Shot.all.order('created_at DESC')
  end

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

  # GET /shots/new
  def new
    @shot = current_user.shots.build
  end

  # GET /shots/1/edit
  def edit
  end

  # POST /shots
  # POST /shots.json
  def create
    @shot = current_user.shots.build(shot_params)

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

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

  # DELETE /shots/1
  # DELETE /shots/1.json
  def destroy
    @shot.destroy
    respond_to do |format|
      format.html { redirect_to shots_url, notice: 'Shot was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  def like
    @shot.liked_by current_user
    respond_to do |format|
      format.html { redirect_back fallback_location: root_path }
      format.json { render layout:false }
    end
  end

  def unlike
    @shot.unliked_by current_user
    respond_to do |format|
      format.html { redirect_back fallback_location: root_path }
      format.json { render layout:false }
    end
  end

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

    # Never trust parameters from the scary internet, only allow the whitelist through.
    def shot_params
      params.require(:shot).permit(:title, :description, :user_shot)
    end
end

The like and unlike actions are specific to our Acts as Voteable gem which I’ll discuss soon. Notice on the shot_params private method we are permitting the fields inside app/views/_form.html.erb.

Adding views and drag and drop functionality

The form partial will be reused for both editing and creating new shots. I wanted to utilize some dragging and dropping. To do this I introduced some JavaScript of which does quite a bit of magic to make the process seem fluid. We add classes to a defined drop zone based on where the element is on the browser window. When the image is dropped it gets displayed automatically using a FileReader API built in to most modern browsers. It also attaches it’s path to a hidden file input of which when a new post is created gets passed through to our create action on the shots_controller.rb.

When it’s all said and done our new shot is created and associated with the currently logged in user or current_user in this case.

In our form partial inside app/views/_form.html.erb I have the following code:


<%= simple_form_for @shot, html: { multipart: true }  do |f| %>
  <%= f.error_notification %>
  <div class="columns is-centered">
    <div class="column is-half">
    
    <% unless @shot.user_shot.blank? %>
    <%= image_tag (@shot.user_shot_url), id: "previewImage" %>
    <% end %>

    <output id="list"></output>
    <div id="drop_zone">Drag your shot here</div>
    <br />

    <%= f.input :user_shot, label: false, input_html: { class: "file-input", type: "file" }, wrapper: false, label_html: { class: "file-label" } %>

    <div class="field">
      <div class="control">
        <%= f.input :title, label: "Title", input_html: { class: "input"}, wrapper: false, label_html: { class: "label" } %>
      </div>
    </div>

    <div class="field">
        <div class="control">
        <%= f.input :description, input_html: { class: "textarea"}, wrapper: false, label_html: { class: "label" } %>
      </div>
    </div>

    <div class="field">
        <div class="control">
        <%= f.button :submit, class:"button is-primary" %>
      </div>
    </div>
  </div>
</div>
<% end %>

Nothing too crazy here. I’ve added specific markup for Bulma to play nicely with SimpleForm as well as a bit of HTML for our JavaScript to interact with:


<output id="list"></output>
<div id="drop_zone">Drag your shot here</div>

Our drop zone is where the magic happens. Combined with some CSS we can make changes as a user interacts with the zone. Check the repo for the CSS necessary here.

The JavaScript to make all of this work is found in app/assets/javascripts/shot.js


document.addEventListener("turbolinks:load", function() {

    var Shots = {
        previewShot() {
            if (window.File && window.FileList && window.FileReader) {

                function handleFileSelect(evt) {
                    evt.stopPropagation();
                    evt.preventDefault();

                    let files = evt.target.files || evt.dataTransfer.files; 
                    // files is a FileList of File objects. List some properties.
                    for (var i = 0, f; f = files[i]; i++) {

                        // Only process image files.
                        if (!f.type.match('image.*')) {
                            continue;
                        }
                        const reader = new FileReader();

                        // Closure to capture the file information.
                        reader.onload = (function(theFile) {
                            return function(e) {
                                // Render thumbnail.
                                let span = document.createElement('span');
                                span.innerHTML = ['<img class="thumb" src="', e.target.result,
                                    '" title="', escape(theFile.name), '"/>'
                                ].join('');
                                document.getElementById('list').insertBefore(span, null);
                            };
                        })(f);

                        // Read in the image file as a data URL.
                        reader.readAsDataURL(f);
                    }
                }

                function handleDragOver(evt) {
                    evt.stopPropagation();
                    evt.preventDefault();
                    evt.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy.
                }

                // Setup the dnd listeners.
                // https://stackoverflow.com/questions/47515232/how-to-set-file-input-value-when-dropping-file-on-page
                const dropZone = document.getElementById('drop_zone');
                const target = document.documentElement;
                const fileInput = document.getElementById('shot_user_shot');
                const previewImage = document.getElementById('previewImage');
                const newShotForm = document.getElementById('new_shot');


                if (dropZone) {
                    dropZone.addEventListener('dragover', handleDragOver, false);
                    dropZone.addEventListener('drop', handleFileSelect, false);

                    // Drop zone classes itself
                    dropZone.addEventListener('dragover', (e) => {
                        dropZone.classList.add('fire');
                    }, false);

                    dropZone.addEventListener('dragleave', (e) => {
                        dropZone.classList.remove('fire');
                    }, false);

                    dropZone.addEventListener('drop', (e) => {
                        e.preventDefault();
                        dropZone.classList.remove('fire');
                        fileInput.files = e.dataTransfer.files;
                        // if on shot/id/edit hide preview image on drop
                        if (previewImage) {
                            previewImage.style.display = 'none';
                        }
                        // If on shots/new hide dropzone on drop
                        if(newShotForm) {
                            dropZone.style.display = 'none';
                        }
                    }, false);

                    // Body specific 
                    target.addEventListener('dragover', (e) => {
                        e.preventDefault();
                        dropZone.classList.add('dragging');
                    }, false);

                    // removes dragging class to body WHEN NOT dragging
                    target.addEventListener('dragleave', (e) => {
                        dropZone.classList.remove('dragging');
                        dropZone.classList.remove('fire');
                    }, false);
                }
            }
        },
        // Displays shot title, description, and created_at time stamp on home page on hover.
        // Be sure to include jquery in application.js
        shotHover() {
            $('.shot').hover(function() {
                $(this).children('.shot-data').toggleClass('visible');
            });
        }

    };
    Shots.previewShot();
    Shots.shotHover();

});

I do a full explanation of this code in the videos so be sure to watch to understand what’s happening here.

Comments Setup

Comments are a big part of the community aspect of Dribbble. I wanted to add these to the mix as both and exercise as well as an explanation of how to allow users to interact as well as create, edit, and destroy their own comments combined with Devise.

To get started we need to generate a controller. Here I specify to only create the create and destroy actions. This also generates those views. You can delete those inside app/views/comments/.

$ rails g controller comments create destroy 

To interact with our database we need a Comment model. Run the following:

$ rails g model comment name:string response:text

All comments will have a Name and Response field. I don’t get into how validation works in this series but to enhance the app I would definitely consider adding these so that a use must enter their name and a worthy response. No empty fields in other words.

After creating the model be sure to run:


$ rails db:migrate

We need to associate a comment to a shot and to do that requires adding a shot_id paramter to comments. Rails is smart enough to know that shot_id is how a shot will reference a comment. This is true for any types of models you associate together.


$ rails g migration add_shot_id_to_comments 

Inside that generation (db/migrate/XXXXXXXXX_add_shot_id_to_comments) add the following:


class AddShotIdToComments < ActiveRecord::Migration[5.1]
  def change
    add_column :comments, :shot_id, :integer
  end
end

Then run :


$ rails db:migrate      

Next we need to associate the models directly. Inside the comment model add the following:


# app/models/comment.rb
class Comment < ApplicationRecord
    belongs_to :shot
    belongs_to :user
end

And also update the shot model:


# app/model/shot.rb
class Shot < ApplicationRecord
    belongs_to :user
    has_many :comments, dependent: :destroy # if a shot is deleted so are all of its comments
    mount_uploader :user_shot, UserShotUploader
end

Update routes to have nested comments within shots:


# config/routes.rb

resources :shots do 
  resources :comments
end

The logic of creating a comment comes next inside our comments_controller.rb file.


class CommentsController < ApplicationController
  # a user must be logged in to comment
    before_action :authenticate_user!, only: [:create, :destroy]

  def create
    # finds the shot with the associated shot_id
    @shot = Shot.find(params[:shot_id]) 
     # creates the comment on the shot passing in params 
    @comment = @shot.comments.create(comment_params)
     # assigns logged in user's ID to
    @comment.user_id = current_user.id if current_user comment
    # saves it
    @comment.save!
    # redirect back to Shot
    redirect_to shot_path(@shot)

  end

  def destroy
    @shot = Shot.find(params[:shot_id])
    @comment = @shot.comments.find(params[:id])
    @comment.destroy
    redirect_to shot_path(@shot)
  end

  private

  def comment_params 
    params.require(:comment).permit(:name, :response)
  end
end

Our views come next. Inside app/views/comments we will add two new files that are partials. These ultimately get rendered on our shot show page found in app/views/shots/shot.html.erb.


<!-- app/views/comments/_form.html.erb -->
<%= simple_form_for([@shot, @shot.comments.build]) do |f| %>

    <div class="field">
        <div class="control">
            <%= f.input :name, input_html: { class: 'input'}, wrapper: false, label_html: { class: 'label' } %>
        </div>
    </div>

    <div class="field">
        <div class="control">
            <%= f.input :response, input_html: { class: 'textarea' }, wrapper: false, label_html: { class: 'label' } %>
        </div>
    </div>
    
    <%= f.button :submit, 'Leave a reply', class: 'button is-primary' %>
<% end %>

This is our comment form of which displays below the shot itself.


<!-- app/views/comments/_comment.html.erb -->
<div class="box">
  <article class="media">
    <figure class="media-left">
      <p class="image is-48x48 user-thumb">
        <%= gravatar_image_tag(comment.user.email.gsub('spam', 'mdeering'), alt: comment.user.name, gravatar: { size: 48 }) %>
      </p>
    </figure>
    <div class="media-content">
      <div class="content">
        <p>
          <strong><%= comment.user.name %></strong>
          <div><%= comment.response %></div>
        </p>
      </div>
    </div>
    <% if (user_signed_in? && (current_user.id == comment.user_id)) %>
       <%= link_to 'Delete', [comment.shot, comment],
                  method: :delete, class: "button is-danger", data: { confirm: 'Are you sure?' } %>
    <% end %>
  </article>
</div>

This is the actual comment markup itself. Here I decide to grab the logged in users name rather than the name they input on the comment form. If you ever wanted to extend this to allow for publically facing users to comment it’s certainly possible. At this point our comments_controller.rb requires a user to login or sign up first.

Finally rendering our comment in our show.html.erb.


<!-- app/views/shots/show.html.erb - Note: This is the final version which includes some other methods from other gems/helpers. Check the repo or videos for the step by step here! -->
<div class="section">
    <div class="container">
        <h1 class="title is-3"><%= @shot.title %></h1>
        <div class="columns">
            <div class="column is-8">
                <span class="by has-text-grey-light">by</span>
                <div class="user-thumb">
                    <%= gravatar_image_tag(@shot.user.email.gsub('spam', 'mdeering'), alt: @shot.user.name, gravatar: { size: 20 }); %>
                </div>
                <div class="user-name has-text-weight-bold"><%= @shot.user.name %></div>
                <div class="shot-time"><span class="has-text-grey-light">posted</span><span class="has-text-weight-semibold">
                    <%= verbose_date(@shot.created_at) %>
                </span></div>
            </div>    
        </div>

        <div class="columns">
            <div class="column is-8">
                <div class="shot-container">
                    <div class="shot-full">
                         <%= image_tag @shot.user_shot_url unless @shot.user_shot.blank? %>
                    </div>

                    <% if user_signed_in? && (current_user.id == @shot.user_id) %>        
                        <div class="buttons has-addons">
                            <%= link_to 'Edit', edit_shot_path(@shot), class: "button" %>
                            <%= link_to 'Delete', shot_path, class: "button", method: :delete, data: { confirm: 'Are you sure you want to delete this shot?'} %>
                        </div>
                    <% end %>

                    <div class="content">
                        <%= @shot.description %>
                    </div>

                    <section class="comments">
                        <h2 class="subtitle is-5"><%= pluralize(@shot.comments.count, 'Comment') %></h2>
                            <%= render @shot.comments %>
                        <hr />
                        <% if user_signed_in? %>
                            <div class="comment-form">
                                <h3 class="subtitle is-3">Leave a reply</h3>
                                <%= render 'comments/form' %>
                            </div>
                        <% else %>
                            <div class="content"><%= link_to 'Sign in', new_user_session_path %> to leave a comment.</div>
                        <% end %>
                    </section>

                </div>
            </div>
            <div class="column is-3 is-offset-1">
                <div class="nav panel show-shot-analytics">
                    <div class="panel-block views data">
                        <span class="icon"><i class="fa fa-eye"></i></span>
                        <%= pluralize(@shot.impressionist_count, 'View') %>
                    </div>
                    <div class="panel-block comments data">
                        <span class="icon"><i class="fa fa-comment"></i></span>
                        <%= pluralize(@shot.comments.count, 'Comment') %>
                    </div>
                    <div class="panel-block likes data">
                        <% if user_signed_in? %>
              <% if current_user.liked? @shot %>
                <%= link_to unlike_shot_path(@shot), method: :put, class: "unlike_shot" do %>
                  <span class="icon"><i class="fa fa-heart has-text-primary"></i></span>
                  <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
                <% end %>
              <% else %>
                <%= link_to like_shot_path(@shot), method: :put, class: "like_shot" do %>
                  <span class="icon"><i class="fa fa-heart"></i></span>
                  <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
                <% end %>
              <% end %>
            <% else %>
                <%= link_to like_shot_path(@shot), method: :put, class: "like_shot" do %>
                  <span class="icon"><i class="fa fa-heart"></i></span>
                  <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
                <% end %>
            <% end %>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Impressionist Setup

Getting unique views on a shot is a nice feature. To make it easier on ourselves I found a gem called Impressionist. It makes impressions quite easy.


$ rails g impressionist
$ rails db:migrate

If there’s an error in specifying which rails version to use you need to append [5.1] to the migration. Use whichever version of rails you are on.

Add the following to the shots_controller.rb file:


class ShotsController < ApplicationController
    # only recording when a user views the shot page and is unique
    impressionist actions: [:show], unique: [:impressionable_type, :impressionable_id, :session_hash]
    
    ...  
end

Add the following to the shot.rb file in app/models/:


class Shot < ApplicationRecord
    belongs_to :user
    has_many :comments, dependent: :destroy
    
    mount_uploader :user_shot, UserShotUploader
    is_impressionable # adds support for impressionist on our shot model
end

Display it in views:


<!-- on our shot index page app/views/shots/index.html.erb -->
<%= shot.impressionist_count %>
<!-- on our shot show page app/views/shots/show.html.erb -->
<%= @shot.impressionist_count %>

Adding Likes with Acts As Votable

Another nice feature is being able to like and unlike a shot. Dribbble has this functionality in place though theirs does a bit of AJAX behind the scenes to make everything work without a browser refresh.

Installation

Add the following to your Gemfile to install the latest release.


gem 'acts_as_votable', '~> 0.11.1' # check if this version is supported in your project

And follow that up with a bundle install.

Database Migrations

Acts As Votable uses a votes table to store all voting information. To generate and run the migration use.


$ rails generate acts_as_votable:migration
$ rake db:migrate

We need to add the acts_as_votable identifier to the model we want votes one. In our case it’s Shot.


# app/models/shot.rb
class Shot < ApplicationRecord
    belongs_to :user
    has_many :comments, dependent: :destroy

    mount_uploader :user_shot, UserShotUploader
    is_impressionable
    acts_as_votable
end

I also want to define the User as the voter so we add acts_as_voter to the user model.


# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable
         
  has_many :shots
  has_many :comments, dependent: :destroy
  acts_as_voter

end

Do create a clickable like or unlike we need to introduce some routes into our app. Below I’ve added a member do block which essentially adds non-restful routes nested within our shots restful routes. Running rake routes here will show each route within the app.


Rails.application.routes.draw do
  resources :shots do 
    resources :comments
     member do
        put 'like', to: "shots#like"
        put 'unlike', to: "shots#unlike"
    end
  end
  devise_for :users, controllers: { registrations: 'registrations' }
  root "shots#index"
end

The controller needs some work here so I’ve added both the :like and :unlike actions to the mix. These are also passed through our before_actions which handle finding the shot in mention as well as making sure the user is signed in.


# app/controllers/shots_controller.rb

before_action :set_shot, only: [:show, :edit, :update, :destroy, :like, :unlike] 
# sets up shot for like and unlike now keeping things dry.
before_action :authenticate_user!, only: [:edit, :update, :destroy, :like, :unlike] 
# add routes as authenticated. 
....

def like
  @shot.liked_by current_user
  respond_to do |format|
    format.html { redirect_back fallback_location: root_path }
    format.js { render layout: false }
  end
end

def unlike
  @shot.unliked_by current_user
  respond_to do |format|
    format.html { redirect_back fallback_location: root_path }
    format.js { render layout: false }
  end
end

In our index.html.erb file within app/views/show/we add the following to the “likes” block:


<!-- app/views/shots/index.html.erb -->
<div class="level-item likes data">
   <div class="votes">
   <% if user_signed_in? %>
      <% if current_user.liked? shot %>
        <%= link_to unlike_shot_path(shot), method: :put, class: 'unlike_shot' do %>
            <span class="icon"><i class="fa fa-heart has-text-primary"></i></span>
            <span class="vote_count"><%= shot.get_likes.size %></span> 
        <% end %>
      <% else %>
        <%= link_to like_shot_path(shot), method: :put, class: 'like_shot' do %>
          <span class="icon"><i class="fa fa-heart"></i></span>
          <span class="vote_count"><%= shot.get_likes.size %></span> 
        <% end %>
      <% end %>
    <% else %>
        <%= link_to like_shot_path(shot), method: :put, class: 'like_shot' do %>
          <span class="icon"><i class="fa fa-heart"></i></span>
          <span class="vote_count"><%= shot.get_likes.size %></span> 
        <% end %>
    <% end %>
  </div>
</div>

And then to repeat the functionality in the show.html.erb file we add the following:


<!-- app/views/shots/show.html.erb -->
<div class="panel-block likes data">
    <% if user_signed_in? %>
    <% if current_user.liked? @shot %>
      <%= link_to unlike_shot_path(@shot), method: :put, class: "unlike_shot" do %>
        <span class="icon"><i class="fa fa-heart has-text-primary"></i></span>
        <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
      <% end %>
    <% else %>
      <%= link_to like_shot_path(@shot), method: :put, class: "like_shot" do %>
        <span class="icon"><i class="fa fa-heart"></i></span>
        <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
      <% end %>
    <% end %>
  <% else %>
      <%= link_to like_shot_path(@shot), method: :put, class: "like_shot" do %>
        <span class="icon"><i class="fa fa-heart"></i></span>
        <span class="vote_count"><%= pluralize(@shot.get_likes.size, 'Like') %></span>
      <% end %>
  <% end %>
</div>

Final Touches

On the home page we can add a bit of marketing to the mix and display a general hero spot for people that are not logged in. If they are logged in I don’t want this to display. The end result looks like this withn our index view:


<!-- app/views/shots/index.html.erb -->

<%= render 'hero' %> <!-- render the hero partial-->

<section class="section">
  <div class="shots">
    <% @shots.each do |shot| %>
    ...

Within the partial above I have the following. We are checking if a user is not signed in. If they aren’t then we display the hero banner. Easy peasy.


<% if !user_signed_in? %>
    <section class="hero is-dark">
        <div class="hero-body">
            <div class="container has-text-centered">
                <h1 class="title is-size-5">
                    What are you working on? <span class="has-text-grey-light">Dribbble is where designers get inspired and hired.</span>
                </h1>
                <div class="content">
                    <%= link_to "Login", new_user_session_path, class: "button is-primary" %>
                </div>
            </div>
        </div>
    </section>
<% end %>

Hover effects

On the index page, I wanted to mimic the hover effect Dribbble has on their shot thumbnails. To do this required a bit of jQuery. I added the following method to our shots.js file I spoke of before. Our final shots.scss file is also below for reference.


// app/assets/javascripts/shot.js
...
},
shotHover() {
  $('.shot').hover(function() {
      $(this).children('.shot-data').toggleClass('visible');
    });
  }
};
Shots.previewShot();
Shots.shotHover();
  
});


/* app/assets/stylesheets/shots.scss */
.hero.is-dark {
    background-color: #282828 !important;
}

.shot-wrapper {
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
    border-radius: 2px;
    padding: 10px;
    background: white;
}

.shots {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    grid-gap: 1rem;
    @media only screen and (min-width: 1600px) {
        grid-template-columns: repeat(6, 1fr);
    }
    @media only screen and (max-width: 1300px) {
        grid-template-columns: repeat(4, 1fr);
    }
    @media only screen and (max-width: 1100px) { 
        grid-template-columns: repeat(3, 1fr);
    }
    @media only screen and (max-width: 800px) {
        grid-template-columns: 1fr 1fr;
    }
    @media only screen and (max-width: 400px) {
        grid-template-columns: 1fr;
    }

}

.shot {
    position: relative;
    display: block;
    color: #333 !important;

    .shot-data {
        display: none;
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        padding: 10px;
        background: rgba(white, .9);
        cursor: pointer;
        .shot-title {
            font-weight: bold;
        }
        .shot-description {
            font-size: .9rem;
        }
        .shot-time {
            font-size: .8rem;
            padding-top: .5rem;
        }
    }
}

.user-data {
    padding: 1rem 0 0 0;
}

.user-name {
    display: inline-block;
    position: relative;
    top: -4px;
    padding-left: 5px;
}

.user-thumb {
    display: inline-block;
    img {
        border-radius: 50%;
    }
}

.by,
.shot-time {
    display: inline-block;
    position: relative;
    top: -4px;
}

.shot-analytics {
    text-align: right;
    @media only screen and (max-width: 800px) {
        text-align: right;
        .level-item {
            display: inline-block;
            padding: 0 4px;
        }
        .level-left+.level-right {
            margin: 0;
            padding: 0;
        }
        .level-item:not(:last-child) {
            margin: 0;
        }
    }
}

.shot-analytics,
.panel.show-shot-analytics {
    font-size: .9rem;
    a,
    .icon {
        color: #aaa;
    }
    .icon:hover,
    a:hover {
        color: darken(#aaa, 25%);
    }
}

.panel.show-shot-analytics {
    a { color: #333; }
    .icon {
        padding-right: .5rem;
    }
    .likes .vote_count {
        margin-left: -4px;
    }
}


.shot-container {
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
    border-radius: 2px;
    padding: 40px;
    background: white;
    @media only screen and (max-width: 800px) {
        padding: 10px;
    }
    .content,
    .comments {
        margin-top: 1rem;
        padding: 20px;
        @media only screen and (max-width: 800px) {
            padding: 0;
        }
    }
}

.shot-full {
    text-align: center;
}

Final Thoughts and Thanks

This build was a big endeavor even though from the outside in it probably looks a little light on features. Dribbble has a lot of moving parts and our clone only scratched the surface. I invite you to carry on adding features and integrations where you see fit. Just diving in and breaking and fixing things is what ultimately allows you to learn and grow as a web developer.

Thanks for tuning in to this series. If you followed along or have feedback please let me know in the comments or on Twitter. If you haven’t already, be sure to check out the rest of the blog for more articles on anything design and development.

Watch/read the full Ruby on Rails Let’s Build Series: