Welcome to a five-part Let's Build: With Ruby on Rails 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. (I happen to work here now š)
Our 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.
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
A demo of how to build a Dribbble clone using Ruby on Rails
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.
Custom color palette generation as Dribbble currently does.
A ton of other cool features
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 1
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.
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 into 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:
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.
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.
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:
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.
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:
If you liked this post, I have many more builds on YouTube and my blog. I plan to start authoring more here as well. Want more content like this in your inbox? Subscribe to my newsletter and get it automatically.
ā Want to learn Ruby on Rails from the ground up? Check out my upcoming course called Hello Rails.