Давайте создадим клон Dribbble с помощью Ruby on Rails

Первоначально опубликовано на веб-сайте web-crunch.com 18 декабря 2017 г.

Добро пожаловать в мини-сериал из пяти частей, в котором я научу вас создавать клон Dribbble в Ruby on Rails. Эта серия — наша самая тщательная сборка!

Dribbble — это активное дизайнерское сообщество, где дизайнеры всех мастей публикуют свои снимки того, над чем они работают. То, что изначально задумывалось как сайт типа показать свой прогресс, стало скорее портфолио для начинающих дизайнеров, а также опытных профессионалов.

Наш клон Dribbble представит некоторые новые концепции, а также другие, о которых я рассказывал в предыдущих сборках. Если вы попали сюда и совершенно не знакомы с Ruby on Rails, я приглашаю вас ознакомиться с другими моими сериями ниже, чтобы узнать о некоторых основополагающих принципах, лежащих в основе фреймворка.

Что мы будем строить в деталях

Наше приложение будет иметь следующие функции:

  • Возможность создавать, редактировать и уничтожать «кадры», а также ставить лайки и отличать отдельные кадры.
  • Роли пользователей и аутентификация
  • Функция перетаскивания
  • Функция комментирования
  • Просмотр счетчиков/аналитики
  • Пользовательский адаптивный пользовательский интерфейс сетки снимков с использованием CSS Grid.

Под капотом этой сборки много всего, поэтому я пошел дальше и создал общедоступный репозиторий на Github, чтобы вы могли следить за ним/справкой. Ради экономии времени вы заметите, что в некоторых из следующих видео я скопировал и вставил некоторые общие стили и разметку.

Скачать исходный код

Чего не будет в сборке

  • Независимые профили пользователей
  • Поиск
  • Маркировка
  • Генерация пользовательской цветовой палитры, как в настоящее время делает Dribbble.

Мы могли бы многое сделать для расширения клона, но, поскольку это приложение типа «пример», я решил пока воздержаться от его радикального расширения. В будущем я могу дополнить эту серию и расширить ее, но в основном я предлагаю вам сделать то же самое самостоятельно, чтобы увидеть, как вы можете еще больше имитировать настоящее приложение.

Смотреть часть 1

Смотреть часть 2

Смотреть часть 3

Смотреть часть 4

Смотреть часть 5

Предполагая, что на вашем компьютере установлены rails и ruby ​​(узнайте, как это сделать), вы готовы начать. Из вашего любимого рабочего каталога с помощью вашего любимого инструмента командной строки выполните следующее:

$ rails new dribbble_clone

Это должно создать новый проект rails и создать папку с именем dribbble_clone.

Для начала я добавил все драгоценные камни из этого проекта в свой файл Gemfile. Вы можете сослаться на репо, если вы еще не взяли те же версии, которые я использовал здесь.

Список драгоценных камней

Наш список драгоценных камней расширился по сравнению с предыдущей сборкой. Вы обнаружите, что чем больше становится ваше приложение, тем больше драгоценных камней вам понадобится.

  • CarrierWave + Mini Magick — Для загрузки изображений и оптимизации
  • Devise — Аутентификация пользователей и роли
  • Guard — работа с файлами по мере их изменения — своего рода средство запуска задач
  • Guard Livereload — перезагружает браузер при изменении файлов в сочетании с расширением Live Reload в вашем любимом браузере.
  • Лучшие ошибки — отображает лучшие ошибки во время разработки.
  • Simple Form — Простые формы в Rails
  • Bulma Rails — мой любимый CSS-фреймворк в последнее время, основанный на Flexbox.
  • Импрессионист — мы используем это для подсчета просмотров кадров.
  • Тег Gravatar Image — простой способ получить изображение пользователя для Gravatar на основе электронной почты его учетной записи.
  • Действует как право голоса — похожие и непохожие снимки

Я полагаюсь на видео в качестве руководства по настройке нашего проекта с каждым отдельным драгоценным камнем. Этому процессу посвящена основная часть первой части. Мы немного настраиваем Devise, чтобы добавить поле «имя» для новых пользователей, которые регистрируются. Снова обратитесь к репо, чтобы увидеть, что это влечет за собой.

Подмости для выстрела

Мы могли бы пройти и создать контроллер, модель и многие другие файлы для нашего Shot, но было бы намного проще и, вероятно, эффективнее scaffold наш Shot и впоследствии удалить все файлы, которые нам не нужны. Rails протягивает нам руку, создавая все необходимые файлы и файлы миграции, которые нам нужны при запуске команды scaffold.

Запустите следующее, чтобы сгенерировать наш Shot с атрибутами title и description:

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

Свяжите пользователей с снимками

Перейдите в направлении models в каталоге app. Нам нужно связать пользователей с выстрелами. Измените свои файлы, чтобы они выглядели следующим образом. Обратите внимание, что некоторые данные здесь уже добавлены, когда мы установили Devise. Вы можете увидеть, что некоторые из этих ассоциаций уже существуют. Если нет, то на данный момент они должны выглядеть следующим образом:

# 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 — это жемчужина для загрузки изображений с кучей наворотов. Убедитесь, что вы установили гем CarrierWave, а затем запустите следующее, чтобы создать новый файл загрузчика с именем user_shot_uploader.rb. Убедитесь, что у вас также установлен MiniMagick gem, на который я ссылался.

$ rails generate uploader user_shot

это должно дать вам файл в:

app/uploaders/user_shot_uploader.rb

Внутри этого файла мы можем настроить некоторые параметры того, как CarrierWave обрабатывает наши изображения. Мой файл выглядит так, когда все сказано и сделано. Примечание: я удалил некоторые ненужные комментарии.

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

Связывание нашего пользовательского снимка с снимком

Нам нужно, чтобы наше новое изображение, снятое пользователем, было полем в нашей таблице базы данных. Для этого нам нужно добавить это как миграцию, которая дает нам столбец user_shot типа string.

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

Внутри файла shot.rb внутри app/models/shot.rb добавьте следующий код, чтобы все заработало.

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

Чтобы действительно позволить пользователю загружать снимок, нам нужно настроить наш контроллер, чтобы разрешить все эти новые параметры.

Создание нового снимка

На этом этапе нашему контроллеру выстрела требуется некоторая TLC для правильной работы. Помимо контроллера, я хотел ввести некоторую функциональность типа перетаскивания в представлениях new и edit. Dribbble выполняет автоматическую загрузку изображений как часть их первоначального создания Shot. Чтобы сэкономить время, я сымитировал это, но это определенно не соответствует тому, как все это работает нормально.

shots_controller.rb, найденный в app/controllers/, в итоге выглядит так:

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

Действия like и unlike относятся к нашему драгоценному камню Acts as Voteable, о котором я расскажу позже. Обратите внимание, что в частном методе shot_params мы разрешаем поля внутри app/views/_form.html.erb.

Добавление представлений и функций перетаскивания

Частичная форма будет повторно использоваться как для редактирования, так и для создания новых кадров. Я хотел использовать перетаскивание. Чтобы сделать это, я представил немного JavaScript, который творит немного магии, чтобы процесс казался плавным. Мы добавляем классы в определенную зону перетаскивания в зависимости от того, где элемент находится в окне браузера. Когда изображение перетаскивается, оно автоматически отображается с помощью API FileReader, встроенного в большинство современных браузеров. Он также прикрепляет свой путь к скрытому входному файлу, который при создании нового сообщения передается нашему действию создания на странице shots_controller.rb.

Когда все сказано и сделано, наш новый снимок создается и связывается с текущим зарегистрированным пользователем или current_user в данном случае.

В нашей частичной форме внутри app/views/_form.html.erb у меня есть следующий код:

<%= 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 %>

Здесь нет ничего слишком сумасшедшего. Я добавил специальную разметку для Bulma, чтобы она хорошо работала с SimpleForm, а также немного HTML для взаимодействия с нашим JavaScript:

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

В нашей зоне высадки происходит волшебство. В сочетании с некоторыми CSS мы можем вносить изменения, когда пользователь взаимодействует с зоной. Проверьте репозиторий на наличие необходимого здесь CSS.

JavaScript для выполнения всей этой работы находится в 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();

});

Я делаю полное объяснение этого кода в видео, поэтому обязательно посмотрите, чтобы понять, что здесь происходит.

Настройка комментариев

Комментарии являются важной частью сообщества Dribbble. Я хотел добавить их в смесь как упражнения, так и объяснение того, как позволить пользователям взаимодействовать, а также создавать, редактировать и уничтожать свои собственные комментарии в сочетании с Devise.

Для начала нам нужно сгенерировать контроллер. Здесь я указываю создавать только действия create и destroy. Это также генерирует эти представления. Вы можете удалить их внутри app/views/comments/.

$ rails g controller comments create destroy

Для взаимодействия с нашей базой данных нам нужна модель комментариев. Запустите следующее:

$ rails g model comment name:string response:text

Все комментарии будут иметь поля Имя и Ответ. Я не вникаю в то, как работает валидация в этой серии, но для улучшения приложения я бы определенно подумал о том, чтобы добавить их, чтобы пользователь должен был ввести свое имя и достойный ответ. Другими словами, никаких пустых полей.

После создания модели обязательно запустите:

$ rails db:migrate

Нам нужно связать комментарий с кадром, а для этого необходимо добавить параметр shot_id к комментариям. Rails достаточно умен, чтобы знать, что shot_id — это то, как shot будет ссылаться на комментарий. Это верно для любых типов моделей, которые вы связываете вместе.

$ rails g migration add_shot_id_to_comments

Внутри этого поколения (db/migrate/XXXXXXXXX_add_shot_id_to_comments) добавьте следующее:

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

Затем запустите:

$ rails db:migrate

Далее нам нужно связать модели напрямую. Внутри модели комментария добавьте следующее:

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

А также обновить модель выстрела:

# 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

Обновите маршруты, чтобы в кадрах были вложенные комментарии:

# config/routes.rb

resources :shots do
  resources :comments
end

Далее идет логика создания комментария внутри нашего файла comments_controller.rb.

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

Дальше идут наши взгляды. Внутри app/views/comments мы добавим два новых файла, которые являются частичными. В конечном итоге они отображаются на нашей странице показа снимков в 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 %>

Это наша форма комментариев, которая отображается под самим кадром.

<!-- 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>

Это фактическая разметка комментария. Здесь я решаю получить имя вошедшего в систему пользователя, а не имя, которое они вводят в форме комментария. Если вы когда-нибудь хотели расширить это, чтобы пользователи, находящиеся в открытом доступе, могли комментировать, это, безусловно, возможно. На данный момент наш comments_controller.rb требует от пользователя входа в систему или регистрации.

Наконец, отрисовка нашего комментария в файле 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>

Импрессионистская установка

Получение уникальных просмотров на кадре — приятная функция. Чтобы облегчить себе задачу, я нашел жемчужину под названием Импрессионист. Это производит впечатление довольно легко.

$ rails g impressionist 
$ rails db:migrate

Если есть ошибка при указании используемой версии rails, вам нужно добавить [5.1] к миграции. Используйте любую версию рельсов, на которой вы находитесь.

Добавьте в файл shots_controller.rb следующее:

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

Добавьте в файл shot.rb в 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

Отобразить его в представлениях:

<!-- 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 %>

Добавление лайков с актами голосования

Еще одна приятная функция — возможность любить и не любить снимок. У Dribbble есть эта функция, хотя они немного используют AJAX за кулисами, чтобы все работало без обновления браузера.

Установка

Добавьте следующее в свой Gemfile, чтобы установить последнюю версию.

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

И за этим следует bundle install.

Миграция базы данных

Acts As Votable использует таблицу голосов для хранения всей информации о голосовании. Для создания и запуска миграции используйте.

$ rails generate acts_as_votable:migration 
$ rake db:migrate

Нам нужно добавить идентификатор acts_as_votable к модели, за которую мы хотим получить один голос. В нашем случае это 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

Я также хочу определить пользователя как избирателя, поэтому мы добавляем acts_as_voter в модель пользователя.

# 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

Создайте интерактивный лайк или отличие, нам нужно ввести некоторые маршруты в наше приложение. Ниже я добавил блок member do, который, по сути, добавляет неуспокаивающие маршруты, вложенные в наши shots умиротворяющие маршруты. Запустив здесь rake routes, вы увидите каждый маршрут в приложении.

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

Контроллер нуждается в некоторой доработке, поэтому я добавил в смесь действия :like и :unlike. Они также передаются через наш before_actions, который занимается поиском упоминаемого снимка, а также проверяет, что пользователь вошел в систему.

# 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

В нашем файле index.html.erb внутри app/views/show/мы добавляем в блок «нравится» следующее:

<!-- 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>

А затем, чтобы повторить функциональность в файле show.html.erb, мы добавляем следующее:

<!-- 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>

Последние штрихи

На главной странице мы можем добавить немного маркетинга и отобразить общее место для людей, которые не вошли в систему. Если они вошли в систему, я не хочу, чтобы это отображалось. Конечный результат выглядит так с нашим индексным представлением:

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

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

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

В приведенном выше частичном у меня есть следующее. Мы проверяем, не вошел ли пользователь в систему. Если это не так, мы показываем баннер героя. Очень просто.

<% 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 %>

Эффекты наведения

На индексной странице я хотел имитировать эффект наведения Dribbble на миниатюры снимков. Для этого требуется немного jQuery. Я добавил следующий метод в наш файл shots.js, о котором я говорил ранее. Наш окончательный файл shots.scss также приведен ниже для справки.

// 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;
}

Заключительные мысли и спасибо

Эта сборка была большим усилием, хотя снаружи она, вероятно, выглядит немного слабоватой. В Dribbble много движущихся частей, а наш клон лишь поверхностно поцарапал поверхность. Я приглашаю вас продолжать добавлять функции и интеграции там, где вы считаете нужным. Просто погружаться, ломать и исправлять вещи — это то, что в конечном итоге позволяет вам учиться и расти как веб-разработчик.

Спасибо, что заглянули в этот сериал. Если вы следили за новостями или у вас есть отзывы, дайте мне знать в комментариях или в Твиттере. Если вы еще этого не сделали, обязательно ознакомьтесь с остальной частью блога, чтобы узнать больше статей о дизайне и разработке.

Посмотрите/прочитайте полную серию Ruby on Rails Let’s Build:

Первоначально опубликовано на веб-сайте web-crunch.com 18 декабря 2017 г.

☝ Хотите изучить Ruby on Rails с нуля? Ознакомьтесь с моим предстоящим курсом под названием Hello Rails.