Пишем форум с нуля на Ruby on Rails и AngularJS17.05.2016 07:18
Не так давно я рассказывал о геме Oxymoron, позволяющем очень просто и быстро строить современные Single Page Application на AngularJS и Ruby on Rails. Статья была встречена весьма позитивно, поэтому пришло время написать более-менее сложное приложение, чтобы показать все возможности гема. Учитывая ошибки прошлых статей, я зарегистрировал доменное имя и арендовал сервер для разворачивания приложений для хабра.
Репозиторий с полным исходным кодом Развернутое приложение
Задача
Написать форум обладающий следующим функционалом:
Пользователи должны иметь возможность зарегистрироваться и авторизоваться в системе
Пользователи имеют роли. На данный момент в системе предусмотрены 2 роли: администратор и модератор
Администратор может создавать группы, наполнять эти группы темами
Пользователи могут создавать топики в существующих темах и писать в эти топики свои посты
Модератор может удалять сообщения и топики пользователей
Модератор и администратор могут блокировать нерадивых пользователей
Каждый пользователь имеет свой рейтинг, определяемый другими участниками
Пользователи должны иметь возможно загрузить аватарку
На форуме должен быть предусмотрен поиск
В качестве базы данных я буду использовать PostgreSQL, так как мне необходим функционал хранения массивов и хэшей. Для поиска будет использоваться поисковой движок Sphinx. Процессинг изображений на сервере по старой доброй традиции будет идти через ImageMagick. В данной статье я не буду использовать вспомогательные кеширующие инструменты и постараюсь обойтись только возможностями Rails и Postgresql.
class Avatar < ActiveRecord::Base
belongs_to :user
end
Для моделей Topic и Theme необходимо устанавливать в поле last_post последний созданный пост. Сделать это лучше всего в каллбэке after_create модели Post:
Приступим к модели Avatar. Первым делом сгенерируем uploader, который будет использоваться для обработки загружаемых аватарок. Я использую carrierwave:
rails g uploader Avatar
Укажем нашему аплоадеру, что он должен сжимать все загружаемые картинки до версии thumb (150×150рх), и делать это он будет через MiniMagick (враппер для ImageMagick):
uploaders/avatar_uploader.rb
class AvatarUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
storage :file
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
version :thumb do
process :resize_to_fill => [150, 150]
end
def extension_white_list
%w(jpg jpeg gif png)
end
end
Теперь подключим AvatarUploader к модели Avatar и укажем, что размер загружаемого файла должен быть не более 2 МБайт:
В ответ ожидается массив. При необходимости можно указать аттрибут multiple. Если multiple не указан, то в переменную result_from_server будет положен первый элемент массива, в ином случае — весь массив. Сгенерируем UploadsController, отвечающий за загрузку файлов на сервер:
rails g controller uploads
Создадим метод avatar, который будет управлять логикой загрузки аватарки:
class UploadsController < ApplicationController
before_action :authenticate_user!
def avatar
avatar = Avatar.new(body: params[:attachments].first)
if avatar.save
avatar_url = avatar.body.thumb.url
current_user.update(avatar_id: avatar.id, avatar_url: avatar_url)
render json: Oj.dump([avatar_url])
else
render json: {msg: avatar.errors.full_messages.join(", ")}
end
end
end
Поиск по постам
Для полнотекстового поиска я использую Sphinx и гем thinking_sphinx. Первым делом необходимо создать файл конфига для thinking_sphinx, который будет транслирован в sphinx.conf. Итак, нам нужен стиминговый поиск с возможностью поиска по звёздочке (автокомплит) и минимальным запросом в 3 символа. Опишем это в thinking_sphinx.yml:
Теперь создадим индекс для постов. Индексироваться должны заголовок и контент. Результат будем сортировать в обратном порядке от даты создания, поэтому ее необходимо указать в виде фильтра:
app/indices/post_index.rb
ThinkingSphinx::Index.define :post, {delta: true} do
indexes title
indexes content
has created_at
end
Выполняем генерацию sphinx-конфига и запускаем демон searchd одной командой:
rake ts:rebuild
Если ребилд пройдет успешно, то вы увидите в консоли сообщение о том, что демон стартовал удачно.
Добавим в модель Post метод для поиска. Так как метод search занял thinking_sphinx, я использовал look_for:
def self.look_for query
return self if query.blank? or query.length < 3
search_ids = self.search_for_ids(query, {per_page: 1000, order: 'created_at DESC'})
self.where(id: search_ids)
end
Сгенерируем контроллер, отвечающий за поиск и определим метод index, который будет обрабатывать логику поиска:
rails g controller search index
Данный метод мы определим позже.
Капча reCAPTCHA
Дабы не отставать от моды, подключим в свое приложение новую reCAPTCHA. После регистрации, вам будет доступно 2 ключа: публичный и приватный. Оба этих ключа мы положим в secrets.yml. Туда же будем складировать все возможные api-key нашего приложения.
Напишем protected метод в ApplicationContoller, который верифицирует капчу
protected
def verify_captcha response
result = RestClient.post(
"https://www.google.com/recaptcha/api/siteverify",
secret: Rails.application.secrets[:recaptcha]["secret_key"],
response: response)
JSON.parse(result)["success"]
end
Теперь этот метод доступен у всех контроллеров, унаследованных от ApplicationController.
Авторизация и регистрация
У нас чистое SPA-приложение. Страницу мы не перезагружаем даже при логине/разлогине. Создадим контроллеры для управления сессией и регистрацией на основе JSON API:
controllers/auth/sessions_controller.rb
class Auth::SessionsController < Devise::SessionsController
skip_before_action :authenticate_user!
after_filter :set_csrf_headers, only: [:create, :destroy]
def create
if verify_captcha(params[:user][:recaptcha])
self.resource = warden.authenticate(auth_options)
if self.resource
sign_in(resource_name, self.resource)
render json: {msg: "Вы успешно авторизовались в системе", current_user: current_user.public_fields}
else
render json: {msg: "Email не найден, либо пароль неверен"}, status: 401
end
else
render json: {msg: "Проверка каптчи не пройдена"}, status: 422
end
end
def destroy
Devise.sign_out_all_scopes ? sign_out : sign_out(resource_name)
render json: {msg: "Вы успешно вышли"}
end
protected
def set_csrf_headers
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
end
controllers/auth/registrations_controller.rb
class Auth::RegistrationsController < Devise::RegistrationsController
skip_before_action :authenticate_user!
def create
if verify_captcha(params[:user][:recaptcha])
build_resource(sign_up_params)
resource.save
unless resource.persisted?
render json: {
msg: resource.errors.full_messages.first,
errors: resource.errors,
}, status: 403
else
sign_up(resource_name, resource)
render json: {
msg: "Вы успешно зарегистрировались!",
current_user: current_user.public_fields
}
end
else
render json: {msg: "Проверка каптчи не пройдена"}, status: 422
end
end
private
def sign_up_params
params.require(:user).permit(:name, :email, :password)
end
end
Здесь комментарии излишни. Разве что стоит обратить внимание на set_csrf_headers. Так как страница у нас не обновляется, нам необходимо получать «свежие» CSRF-токены с сервера, чтобы не быть уязвимыми к CSRF-атакам. Для ActionController это делает автоматически Oxymoron. Для всех остальных контроллеров, работающих в обход ActionController необходимо устанавливать в cookies['XSRF-TOKEN'] актуальное значение CSRF-токена.
Теперь нам нужно заблокировать все страницы, требующие авторизации. Для этого нам прийдется переопределить метод authenticate_user! . Сделаем это в ApplicationController:
before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit]
private
def authenticate_user!
unless current_user
if request.xhr?
render json: {msg: "Вы не авторизованы"}, status: 403
else
redirect_to root_path
end
end
end
Роутинг приложения
Сразу опишем файл routes.rb, чтобы больше к нему не возвращаться. Итак, у нас есть 5 ресурсов: users, groups, themes, topics и posts. Так же имеются роуты /uploads/avatar и /search. Помимо этого, нам необходимы методы на ресурсе users для определения онлайна пользователя, получения его рейтинга и прочей статистики.
Rails.application.routes.draw do
root to: 'groups#index'
devise_for :users, controllers: {
sessions: 'auth/sessions',
registrations: 'auth/registrations',
}
post "uploads/avatar" => "uploads#avatar"
get "search" => "search#index"
resources :groups
resources :themes
resources :topics
resources :posts
resources :users, only: [:index, :show] do
collection do
get "touch" # touch для current_user, чтобы обновить время онлайна
get "metrics" # разнообразная статистика
end
member do
put "rate" # Изменение рейтинга
put "ban" # Забанить
put "unban" # Разбанить
end
end
end
Сериализация
Мне нравится философия сериализиции ActiveModelSerializer, но я очень стеснен в серверных мощностях, особенно перед Хабраэффектом. Поэтому пришлось придумать механизм максимально быстрой сериализации, которая только возможна в рамках текущего проекта. Основной критерий, предъявляемый мной перед сериализацией состоит в том, что она не должна занимать больше 5–10 мс.
Все что Вы прочитаете дальше, может показаться вам чуждым, странным и неправильным
Идея заключается в том, чтобы на клиент передавать только выбранные поля по сджойненым таблицам напрямую из базы. Вместе с этим отправлять на клиент название сериалайзера, который необходимо применить к ответу. Ангуляр позволяет перехватить все запросы и ответы, и изменить их по своему желанию. Следовательно, мы можем сериализовать объект на клиенте, при этом не загромаждая запросы каллбэками.
Перехватчик запросов выглядит следующим образом:
javascripts/serializers/interceptor.js
app.factory('serializerInterceptor', ['$q', function ($q) {
return {
response: function (response) {
// Если в ответе найден сериалайзер, то
if (response.data.serializer) {
// Находим его в нашей глобальной области видимости
var serializer = window[response.data.serializer];
// Если он найден, то
if (serializer) {
// применяем его
var collection = serializer(response.data.collection);
// если результат ожидается как массив, то кладем его в поле collection, иначе в resource
if (response.data.single) {
response.data.resource = collection[0]
} else {
response.data.collection = collection;
}
} else {
console.error(response.data.serializer + " is not defined")
}
}
// Возвращаем измененный ответ с сервера
return response || $q.when(response);
}
};
}])
// Кладем serializerInterceptor в стек перехватчиков для http-запросов, выполненных посредством Angular
.config(['$httpProvider', function ($httpProvider) {
$httpProvider.interceptors.push('serializerInterceptor');
}])
На клиент мы будем передавать результат селекта по сджойненным таблицам. Например, мы хотим передать вместе с постом еще и пользователя его создавшего:
Результатом будет таблица с соответствующими колонками, или в JSON-представлении — это массив, состоящий из массивов. Напишем сериалайзер:
Пример сериалайзера
function ExampleSerializer (collection) {
var result = [];
collection.forEach(function(item) {
id: item[0],
title: item[1],
content: item[2],
user: {
id: item[3],
name: item[4]
}
})
return result
}
Данный сериалайзер будет автоматически применён к collection и в response любого $http-запроса мы увидим уже сериализованный результат. Теперь необходимо для каждой модели создать метод pluck_fields, который возвращает поля для селекта:
models/group.rb
class Group < ActiveRecord::Base
has_many :themes, ->{order(:id)}, dependent: :destroy
has_many :topics, through: :themes, dependent: :destroy
has_many :posts, through: :topics, dependent: :destroy
def self.pluck_fields
["groups.id", "groups.title", "themes.id", "themes.title",
"themes.posts_count", "themes.topics_count", "themes.last_post"]
end
end
models/post.rb
class Post < ActiveRecord::Base
belongs_to :topic, :counter_cache => true
belongs_to :theme, :counter_cache => true
belongs_to :user, :counter_cache => true
after_create :set_last_post
validates :content, presence: true, length: { in: 2..300 }
def self.pluck_fields
["posts.id", "posts.title", "posts.content", "users.id", "users.created_at", "users.name",
"users.rating", "users.posts_count", "users.avatar_url", "topics.id", "topics.title"]
end
def self.look_for query
return self if query.blank? or query.length < 3
search_ids = self.search_for_ids(query, {per_page: 1000000, order: 'created_at DESC'})
self.where(id: search_ids)
end
private
def set_last_post
last_post = self.as_json(include: [:topic, :user])
topic.update(last_post: last_post)
theme.update(last_post: last_post)
end
end
models/theme.rb
class Theme < ActiveRecord::Base
has_many :topics, dependent: :destroy
has_many :posts, dependent: :destroy
belongs_to :group
def self.pluck_fields
[:id, :title]
end
end
models/topic.rb
class Topic < ActiveRecord::Base
has_many :posts, dependent: :destroy
belongs_to :theme, :counter_cache => true
belongs_to :user
validates_presence_of :theme, :title, :content
after_create do
Post.create(title: title, content: content, user_id: user_id, theme_id: theme_id, topic_id: id)
end
def self.pluck_fields
["topics.id", "topics.title", "topics.last_post", "topics.posts_count",
"users.id", "users.name", "themes.id", "themes.title"]
end
end
models/user.rb
class User < ActiveRecord::Base
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
belongs_to :avatar
has_many :posts
validates :name, :uniqueness => {:case_sensitive => false}, presence: true, length: { in: 2..10 }
def self.pluck_fields
[:id, :created_at, :updated_at, :name, :avatar_url, :posts_count, :rating, :banned]
end
def public_fields
self.attributes.slice("id", "email", "rating", "name", "created_at", "updated_at", "posts_count", "banned")
end
end
Эти методы мы будем использовать в контроллерах, для передачи их в метод pluck.
Контроллеры
В своей предыдущей статье «Архитектура построения Single Page Application на основе AngularJS и Ruby on Rails» я приводил пример «типичного Rails-контроллера». Типичный — значит нам нет необходимости описывать каждый раз одну и ту же логику. Достаточно написать наиболее общий контроллер, унаследоваться от него и переопределить, либо доопределить необходимые методы. Писать такой контроллер я не стал и просто вынес всю общую логику в concern. Итоговый concern выглядит очень необычно:
controllers/concern/spa.rb
module Spa
extend ActiveSupport::Concern
# @model – модель (со всем чейнингом), к которой идет обращение
# @resource – текущий ресурс
# Все методы имеют свое дефолтное состояние и переопределяются при необходимости
included do
before_action :set_model
before_action :set_resource, only: [:show, :edit, :update, :destroy]
def index
respond_to do |format|
format.html
format.json {
collection = @model.where(filter_params) if params[:filter]
render json: Oj.dump({
total_count: collection.count,
serializer: serializer,
collection: collection.page(params[:page]).per(10).pluck(*pluck_fields),
page: params[:page] || 1
})
}
end
end
def show
respond_to do |format|
format.html
format.json {
@resource = @model.where(id: params[:id]).pluck(*pluck_fields)
render json: Oj.dump({
collection: @resource,
serializer: serializer,
single: true
})
}
end
end
def new
new_params = resource_params rescue {}
@resource = @model.new(new_params)
authorize @resource, :create?
respond_to do |format|
format.html
format.json {
render json: Oj.dump(@resource)
}
end
end
def edit
authorize @resource, :update?
respond_to do |format|
format.html
format.json {
render json: Oj.dump(@resource)
}
end
end
def create
@resource = @model.new resource_params
authorize @resource
if @resource.save
@collection = @model.where(id: @resource.id).pluck(*pluck_fields)
result = {
collection: @collection,
serializer: serializer,
single: true,
}.merge(redirect_options[:update] || {})
render json: Oj.dump(result)
else
render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422
end
end
def update
authorize @resource
if @resource.update(resource_params)
render json: {resource: @resource, msg: "#{@model.name} успешно обновлен"}.merge(redirect_options[:update] || {})
else
render json: {errors: @resource.errors, msg: @resource.errors.full_messages.join(', ')}, status: 422
end
end
def destroy
authorize @resource
@resource.destroy
render json: {msg: "#{@model.name} успешно удален"}
end
private
def set_resource
@resource = @model.find(params[:id])
end
def pluck_fields
@model.pluck_fields
end
def redirect_options
{}
end
def filter_params
params.require(:filter).permit(filter_fields)
end
def serializer
serializer = "#{@model.model_name}Serializer"
end
end
end
Итак, создадим на его основе PostsController:
class PostsController < ApplicationController
include Spa
private
# Устанавливаем модель для консёрна
def set_model
@model = Post.joins(:user, :topic).order(:created_at)
end
# Указываем поля, по которым можно производить фильтрацию
def filter_fields
[:theme_id, :topic_id]
end
# Определяем поля, которые допустимы при сабмите формы
def resource_params
# Топик нам нужен для того, чтобы устанавливать его заголовок, как дефолтный заголовок для постов
topic = Topic.find(params[:post][:topic_id])
title = params[:post][:title]
params.require(:post).permit(:content, :title, :topic_id)
.merge({
theme_id: topic.theme_id,
user_id: current_user.id,
title: title.present? ? title : "Re: #{topic.title}"
})
end
end
Это и есть весь код контроллера, которым он отличается от Spa. Аналогично создадим остальные контроллеры:
controllers/groups_controller.rb
class GroupsController < ApplicationController
include Spa
private
def set_model
@model = Group.joins("LEFT JOIN themes ON themes.group_id = groups.id").order("groups.id")
end
def redirect_options
{
create: {
redirect_to_url: root_path
},
update: {
redirect_to_url: root_path
}
}
end
def resource_params
params.require(:group).permit(:title)
end
end
controllers/themes_controller.rb
class ThemesController < ApplicationController
include Spa
private
def set_model
@model = Theme.order(:created_at)
end
def redirect_options
{
create: {
redirect_to_url: root_path
},
update: {
redirect_to_url: root_path
}
}
end
def resource_params
params.require(:theme).permit(:title, :group_id)
end
end
controllers/topics_controller.rb
class TopicsController < ApplicationController
include Spa
private
def set_model
@model = Topic.joins(:theme, :user).order("topics.updated_at DESC")
end
def filter_fields
[:theme_id]
end
def redirect_options
{
create: {
redirect_to_url: topic_path(@resource)
},
update: {
redirect_to_url: topic_path(@resource)
}
}
end
def resource_params
params.require(:topic).permit(:title, :content, :theme_id)
.merge({
user_id: current_user.id
})
end
end
controllers/users_controller.rb
class UsersController < ApplicationController
include Spa
def touch
current_user.touch if current_user
render json: {}
end
def rate
if current_user.votes.include?(params[:id].to_i)
return render json: {msg: "Вы уже влияли на репутацию пользователя"}, status: 422
end
current_user.votes.push(params[:id].to_i)
current_user.save
set_resource
if params[:positive]
@resource.increment!(:rating)
else
@resource.decrement!(:rating)
end
render json: {rating: @resource.rating}
end
def metrics
result = current_user.attributes.slice("posts_count", "rating") if current_user
render json: result || {}
end
def ban
authorize @resource
@resource.update(banned: true)
render json: {msg: "Пользователь был забанен"}
end
def unban
authorize @resource, :ban?
@resource.update(banned: false)
render json: {msg: "Пользователь был разбанен"}
end
private
def set_model
@model = User
end
end
Контроллер SearchController не использует concern Spa, поэтому его опишем полностью:
SearchController
class SearchController < ApplicationController
def index
respond_to do |format|
format.html
format.json {
collection = Post.look_for(params[:q]).joins(:user, :topic).order("created_at DESC")
render json: Oj.dump({
total_count: collection.count,
serializer: "PostSerializer",
collection: collection.page(params[:page]).per(10).pluck(*Post.pluck_fields),
page: params[:page] || 1
})
}
end
end
end
Разграничение прав доступа
Я сознательно не использовал Rolify для организации ролей пользователей, посколько в данном случае это не оправдано. На форуме нет комбинированных ролей. Все управление идет через поле role. Для разграничения прав доступа я использую Pundit. В описании гема есть вся информация по его использованию. Напишем все policies для нашего приложения исходя из требований:
app/policies/group_policy.rb
class GroupPolicy
def initialize(user, group)
@user = user
@group = group
end
def create?
@user.role == "admin"
end
def update?
@user.role == "admin"
end
def destroy?
@user.role == "admin"
end
end
app/policies/post_policy.rb
class PostPolicy
def initialize(user, post)
@user = user
@post = post
end
def create?
true
end
def update?
["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id
end
def destroy?
["admin", "moderator"].include?(@user.role) || @user.id == @post.user_id
end
end
app/policies/theme_policy.rb
class ThemePolicy
def initialize(user, theme)
@user = user
@theme = theme
end
def create?
@user.role == "admin"
end
def update?
@user.role == "admin"
end
def destroy?
@user.role == "admin"
end
end
app/policies/topic_policy.rb
class TopicPolicy
def initialize(user, topic)
@user = user
@topic = topic
end
def create?
true
end
def update?
@user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role)
end
def destroy?
@user.id == @topic.user_id || ["admin", "moderator"].include?(@user.role)
end
end
app/policies/user_policy.rb
class UserPolicy
def initialize(user, resource)
@user = user
@resource = resource
end
def ban?
["admin", "moderator"].include? @user.role
end
end
По умолчанию Pundit кидает исключение Pundit: NotAuthorizedError, если проверка не пройдена, поэтому нам необходимо настроить его на работу посредством JSON API. Для этого в ApplicationController обработаем это исключение:
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized
if request.xhr?
render json: {msg: "Нет прав на данное действие"}, status: 403
else
redirect_to root_path
end
end
Перед тем, как перейти к клиентской части, давайте закончим с ApplicationController, передав текущего пользователя в Gon, чтобы сразу после загрузки страницы у нас сразу была вся необходимая информация о нём:
controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Pundit
protect_from_forgery with: :exception
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :authenticate_user!, only: [:create, :update, :destroy, :new, :edit]
# Выключаем лейаут для всех ajax-запросов
layout proc {
if request.xhr?
false
else
set_gon
"application"
end
}
protected
def verify_captcha response
result = RestClient.post("https://www.google.com/recaptcha/api/siteverify",
secret: Rails.application.secrets[:recaptcha]["secret_key"],
response: response)
JSON.parse(result)["success"]
end
private
def set_gon
gon.current_user = current_user.public_fields if current_user
end
def authenticate_user!
unless current_user
if request.xhr?
render json: {msg: "Вы не авторизованы"}, status: 403
else
redirect_to root_path
end
end
end
def user_not_authorized
if request.xhr?
render json: {msg: "Нет прав на данное действие"}, status: 403
else
redirect_to root_path
end
end
end
Клиентская часть
Я не буду описывать шаги по подключению Oxymoron, так как это всё уже было в этой статье.
AngularJS-контроллеры так же являются однотипными и рассматривались в той самой статье. Опишем их:
javascripts/controllers/groups_ctrl.js
app.controller('GroupsCtrl', ['$scope', 'Group', 'action', 'Theme', function ($scope, Group, action, Theme) {
var ctrl = this;
action('index', function () {
ctrl.groups = Group.get();
ctrl.destroy_theme = function (theme) {
if (confirm("Вы уверены?"))
Theme.destroy({id: theme.id})
}
ctrl.destroy_group = function (group) {
if (confirm("Вы уверены?"))
Group.destroy({id: group.id})
}
})
action('new', function () {
ctrl.group = Group.new();
ctrl.save = Group.create;
})
action('edit', function (params) {
ctrl.group = Group.edit(params);
ctrl.save = Group.update;
})
}])
javascripts/controllers/themes_ctrl.js
app.controller('ThemesCtrl', ['$scope', 'Theme', 'Topic', 'action', '$location', function ($scope, Theme, Topic, action, $location) {
var ctrl = this;
action('show', function (params) {
var filter = {
theme_id: params.id
}
ctrl.theme = Theme.get(params);
ctrl.query = function (page) {
Topic.get({
filter: filter,
page: page
}, function (res) {
ctrl.topics = res;
});
}
ctrl.query($location.search().page || 1)
ctrl.destroy = function (topic) {
if (confirm("Вы уверены?"))
Topic.destroy({id: topic.id})
}
})
action('new', function () {
Theme.new(function (res) {
ctrl.theme = res;
ctrl.theme.group_id = $location.search().group_id;
});
ctrl.save = Theme.create;
})
action('edit', function (params) {
ctrl.theme = Theme.edit(params);
ctrl.save = Theme.update;
})
}])
javascripts/controllers/topics_ctrl.js
app.controller('TopicsCtrl', ['$scope', '$location', 'Topic', 'action', 'Post', 'Theme', function ($scope, $location, Topic, action, Post, Theme) {
var ctrl = this;
action('show', function (params) {
var filter = {
topic_id: params.id
}
ctrl.post = {
topic_id: params.id
}
ctrl.topic = Topic.get(params);
ctrl.query = function (page, callback) {
Post.get({filter: filter, page: page}, function (res) {
ctrl.posts = res;
if (callback) callback();
});
}
ctrl.query(1)
ctrl.send = function () {
Post.create({post: ctrl.post}, function (res) {
ctrl.post = {
topic_id: params.id
}
ctrl.query(Math.ceil(ctrl.posts.total_count/10))
})
}
})
action('new', function () {
var theme_id = $location.search().theme_id;
ctrl.theme = Theme.get({id: theme_id});
ctrl.topic = Topic.new({topic: {theme_id: theme_id}});
ctrl.save = Topic.create;
})
action('edit', function (params) {
ctrl.topic = Topic.edit(params, function (res) {
ctrl.theme = Theme.get({id: res.theme_id});
});
ctrl.save = Topic.update;
})
}])
javascripts/controllers/users_ctrl.js
app.controller('UsersCtrl', ['$scope', 'User', 'action', function ($scope, User, action) {
var ctrl = this;
action('index', function () {
ctrl.query = function (page) {
User.get({page: page}, function (res) {
ctrl.users = res;
});
}
ctrl.query(1)
})
action('show', function (params) {
ctrl.user = User.get(params);
})
ctrl.ban = function (user) {
User.ban({id: user.id})
user.banned = true;
}
ctrl.unban = function (user) {
User.unban({id: user.id})
user.banned = false;
}
}])
javascripts/controllers/search_ctrl.js
app.controller('SearchCtrl', ['$scope', '$location', '$http', function ($scope, $location, $http) {
var ctrl = this;
ctrl.query = function (page) {
var params = {
page: page || 1
}
if (ctrl.q) {
params.q = ctrl.q
}
$http.get(Routes.search_path(params)).then(function (res) {
ctrl.posts = res.data;
})
}
$scope.$watch(function () {
return $location.search().q
}, function (q) {
ctrl.q = q;
ctrl.query()
})
}])
Данный шаблон рендерит внутри себя все паршалы, лежащие в директории components и оборачивает их в тег шаблонов для AngularJS. Его необходимо отредерить в основном лейауте. Это автоматизирует работу с директивами.
= render template: "components/render"
application/layout.html.slim
html ng-app="app"
head
title Форум
base href="/"
= stylesheet_link_tag 'application'
body ng-controller="MainCtrl as main" ng-class="gon.current_user.role"
.layout.body
.search
input.form-control placeholder="Поиск" type="text" ng-model="main.search" ng-model-options="{debounce: 300}"
.bredcrumbs ng-yield="bredcrumbs"
.wrapper
.content
ui-view
.sidebar
= render "layouts/sidebar"
= render template: "components/render"
= Gon::Base.render_data
script src="https://www.google.com/recaptcha/api.js?onload=vcRecaptchaApiLoaded&render=explicit" async="" defer=""
= javascript_include_tag 'application'
Мы вынесли пост и рейтинг в отдельные директивы по той причине, что они содержат изолированную логику работы и используются в разных местах внутри приложения. Это позволяет лучше следовать паттерну DRY.
Для трекинга онлайна создадим online.js, где по таймеру будем раз в 5 минут посылать запрос на обновление онлайна пользователя:
На серверной стороне мы внедрили reCAPTCHA. Теперь пришло время сделать это и на клиентской. Я использовал скрипт Angular Recaptcha, который содержит внутри себя директиву для удобной работы с рекапчей. В общем виде это выглядит следующим образом:
div ng-model="ctrl.user.recaptcha" vc-recaptcha="" key="'#{Rails.application.secrets[:recaptcha]["public_key"]}'"
Нам осталось написать клиентские сериалайзеры и проект готов. На основе примера ExampleSerializer я написал сериайлайзеры для всех моделей:
javascripts/serializers/post_serializer.js
function PostSerializer (collection) {
var result = [];
_.each(collection, function (item) {
result.push({
id: item[0],
title: item[1],
content: item[2],
user: {
id: item[3],
created_at: item[4],
name: item[5],
rating: item[6],
posts_count: item[7],
avatar_url: item[8] || "/default_avatar.png"
},
topic: {
id: item[9],
title: item[10]
}
})
})
return result
}
javascripts/serializers/theme_serializer.js
function ThemeSerializer (collection) {
var result = [];
_.each(collection, function (item) {
result.push({
id: item[0],
title: item[1]
})
})
return result
}
javascripts/serializers/topic_serializer.js
function TopicSerializer (collection) {
var result = [];
_.each(collection, function (item) {
result.push({
id: item[0],
title: item[1],
last_post: item[2],
posts_count: item[3],
user: {
id: item[4],
name: item[5]
},
theme: {
id: item[6],
title: item[7]
}
})
})
return result
}
javascripts/serializers/user_serializer.js
function UserSerializer (collection) {
var result = [];
_.each(collection, function (item) {
result.push({
id: item[0],
created_at: item[1],
updated_at: item[2],
name: item[3],
avatar_url: item[4],
posts_count: item[5],
rating: item[6],
banned: item[7]
})
})
return result
}
javascripts/serializers/group_serializer.js
function GroupSerializer (collection) {
var result = [],
groups = _.groupBy(collection, function (el) {
return el[0]
});
_.each(groups, function (group) {
result.push({
id: group[0][0],
title: group[0][1],
themes: _.map(group, function (item) {
return {
id: item[2],
title: item[3],
posts_count: item[4],
topics_count: item[5],
last_post: item[6]
}
})
})
})
return result
}
Кеширование
Как вы наверняка заметили, вьюхи нашего приложения не имеют серверных элементов шаблонизации. За исключением Gon. Следовательно мы можем закешировать весь лейаут для production-окружения:
Заголовок спойлера
= cache_if Rails.env.production?, $cache_key
html ng-app="app"
head
title Ф