Pull to refresh

Начни изучать Elixir прямо сейчас! Перевод всей серии статей готов

Reading time15 min
Views16K
Original author: Brandon Richey


От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.»


В этой части речь больше пойдёт о вёрстке, чем непосредственно об Эликсире, однако статья будет полезна тем, что расскажет о взаимодействии с ассет-пайплайном Phoenix.


Вы можете прямо сейчас открыть первую часть и за несколько вечеров создать работающее приложение на Фениксе и посмотреть на Эликсир в действии. А также познакомиться с вебсокетами, плагами, ченджсетами и другими страшными словами функциональной веб-разработки :)


На чём мы остановились


В прошлой части мы закончили с написанием тестов для всего, связанного с каналами. На этот раз будет значительно меньше кода на Elixir, вместо которого мы научимся использовать сторонние библиотеки внутри Phoenix-приложений. Как правило, Phoenix встречает вас с очень-очень базовой версией Bootstrap, оптимизированной под него. Давайте немного отойдём от этого подхода и заменим его на другой CSS-фреймворк (а также будет использовать Sass).


Предупреждение. Автор статьи точно не является дизайнером. Результат может вас напугать, так что попробуйте менять всё на свой вкус.


Почему Foundation?


Не хотелось бы начинать спор на тему Bootstrap против Foundation. Вместо этого давайте просто возьмём Foundation, потому что внешний вид сайта на нём выглядит интереснее. Также для него нужно делать чуть меньше шаблонных вещей, по сравнению с Bootstrap. Кроме того, не так много людей пробовали использовать Foundation, так что будет тем интереснее!


Устанавливаем Zurb Foundation 6


$ npm install --save-dev foundation-sites motion-ui sass-brunch jquery@2.2.4
$ cp node_modules/foundation-sites/dist/foundation.min.js web/static/vendor/

Примечание. Обратите внимание, что для избежания проблем совместимости jQuery и Foundation, здесь устанавливается конкретная версия jQuery (v2.2.4). Для дополнительной информации посмотрите эту статью.


Также нужно поправить brunch-config.js, чтобы учесть изменения. А именно подключение Foundation к Brunch, включая загрузку Sass-директорий, конфигурацию модуля Sass, и установка jQuery в качестве глобальной зависимости для Npm.


exports.config = {
  // See http://brunch.io/#documentation for docs.
  files: {
    javascripts: {
      joinTo: "js/app.js"
    },
    stylesheets: {
      joinTo: "css/app.css",
      order: {
        after: ["web/static/css/app.css"] // concat app.css last
      }
    },
    templates: {
      joinTo: "js/app.js"
    }
  },

  conventions: {
    // This option sets where we should place non-css and non-js assets in.
    // By default, we set this to "/web/static/assets". Files in this directory
    // will be copied to `paths.public`, which is "priv/static" by default.
    assets: /^(web\/static\/assets)/
  },

  // Phoenix paths configuration
  paths: {
    // Dependencies and current project directories to watch
    watched: [
      "web/static",
      "test/static"
    ],

    // Where to compile files to
    public: "priv/static"
  },

  // Configure your plugins
  plugins: {
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/web\/static\/vendor/]
    },
    sass: {
      options: {
        includePaths: [
          'node_modules/foundation-sites/scss',
          'node_modules/motion-ui/src',
        ]
      }
    }
  },

  modules: {
    autoRequire: {
      "js/app.js": ["web/static/js/app"]
    }
  },

  npm: {
    enabled: true,
    globals: {
      $: 'jquery',
      jQuery: 'jquery',
    }
  }
};

Затем, нам нужно добавить директорию для Sass-файлов. Создайте директорию web/static/scss с 3 файлами внутри:


  1. application.scss;
  2. _settings.scss;
  3. _custom.scss.

В файл web/static/scss/application.scss добавьте:


// Only put imports and things here
@import "settings";
@import "foundation";
@include foundation-everything;
@import "motion-ui";
@include motion-ui-transitions;

@import "custom";

В файл web/static/scss/_settings.scss добавьте содержимое файла по ссылке.


В файл web/static/scss/_custom.scss добавьте:


// Put custom styles here
$darker-gray: #2a2a2a;

html, body {
  height: 100%;
  background-image: url("/images/computer-bg.png");
  background-size: cover;
  background-repeat: no-repeat;
  background-color: #000;
  background-attachment: fixed;
}

.top-bar {
  background-color: rgba(0, 0, 0, 0.6);
  ul {
    background-color: inherit;
    padding-left: rem-calc(40);
    padding-right: rem-calc(40);
  }
  a {
    color: $white;
  }
  a:hover {
    color: $alert-color;
  }
  a.button:hover {
    color: $white;
  }
  .logo {
    padding-right: rem-calc(50);
    text-transform: uppercase;
    color: $white;
    font-weight: bolder;
  }
}

.image-overlay {
  margin-top: 10%;
  margin-bottom: 10%;
}

section.about {
  background: rgba(0, 0, 0, 0.6);
  color: $white;
  padding: rem-calc(50);
  .icon {
    text-align: center;
    font-size: rem-calc(64);
    margin-bottom: rem-calc(20);
  }
  h2 {
    text-align: center;
  }
}

section.more-info {
  background: rgba(0, 0, 0, 0.9);
  color: $white;
  min-height: 200px;
  padding: rem-calc(50);
  .social-media {
    font-size: rem-calc(48);
    a {
      color: white;
    }
    a:hover {
      color: $secondary-color;
    }
  }
}

section.main-content {
  margin-top: rem-calc(100);
}

.content {
  background: rgba(255, 255, 255, 0.8);
  padding: rem-calc(30);
}

footer {
  background-color: #000;
  color: $white;
  text-align: center;
  padding-bottom: rem-calc(20);
  font-size: rem-calc(14);
}

.post {
  background: rgba(255, 255, 255, 0.8);
  padding: rem-calc(30);
  margin-bottom: rem-calc(25);
  .title {
    text-align: center;
    border-bottom: 2px solid rgba(0, 0, 0, 0.6);
    margin-bottom: rem-calc(10);
  }
}

Вам также потребуется загрузить картинку и кинуть его в директорию web/static/assets/images/computer-bg.png, чтобы файл стилей мог использовать его в качестве фонового изображения.


Устанавливаем иконки и шрифты для Foundation


Если вы захотите подключить шрифт с иконками из набора Foundation, выполните следующие шаги:


  1. Загрузите архив.
  2. Распакуйте его содержимое.
  3. Скопируйте следующие файлы в директорию web/static/assets/fonts: foundation-icons.eot, foundation-icons.svg, foundation-icons.ttf, foundation-icons.woff.
  4. Скопируйте следующий файл в директорию web/static/css: foundation-icons.css.
  5. Наконец, измените файл foundation-icons.css, чтобы пути для каждого шрифта соответствовали путям ассетов Phoenix:

/* Rest of the file */
@font-face {
  font-family: "foundation-icons";
  src: url("/fonts/foundation-icons.eot");
  src: url("/fonts/foundation-icons.eot?#iefix") format("embedded-opentype"),
       url("/fonts/foundation-icons.woff") format("woff"),
       url("/fonts/foundation-icons.ttf") format("truetype"),
       url("/fonts/foundation-icons.svg#fontcustom") format("svg");
  font-weight: normal;
  font-style: normal;
}
/* Rest of the file */

Теперь иконки должны быть полностью установлены.


Удаляем стандартные стили


Откройте файл web/static/css/phoenix.css и удалите всё, что в нём есть! Нам ничего из этого не понадобится, но могло бы пригодиться, если бы мы решили использовать стандартный CSS.


Изменяем работу с текущим пользователем


Перед тем как продолжить, хотелось бы сделать ещё одну вещь – улучшить написанный ранее код. Например, способ работы с текущим пользователем немного ненадёжен и требует писать один и тот же код снова и снова. Давайте добавим коду, связанному с текущим пользователем, возможность повторного использования. Сначала создадим новый плаг, который расположим в файле web/controllers/current_user_plug.ex:


defmodule Pxblog.CurrentUserPlug do
  import Plug.Conn

  def init(default), do: default
  def call(conn, _opts) do
    if current_user = get_session(conn, :current_user) do
      assign(conn, :current_user, current_user)
    else
      conn
    end
  end
end

Что здесь происходит? Сначала импортируется Plug.Conn внутрь нашего модуля (так что можно легко получить доступ к функциям assign и get_session). Далее описывается функция init, принимающая и тут же возвращающая переменную default. Самая суть плага находится в функции call. Любой плаг должен реализовывать две функции – init и call. Функция call должна принимать conn и необязательные опции, а возвращать conn (изменённый или нет).


Если current_user есть в текущей сессии, то достаём его и закидываем внутрь conn, чтобы можно было отовсюду получать доступ к текущему пользователю. Если достать его нельзя, то просто возвращаем неизменённый conn. Затем, чтобы использовать плаг current_user глобально, откройте файл web/router.ex и вставьте его в стандартный пайплайн браузера:


pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug Pxblog.CurrentUserPlug
end

Вот и всё! Теперь, когда потребуется обратиться к текущему пользователю внутри разметки, мы можем просто вызвать @conn.assigns[:current_user]. Замечательно!


Улучшаем основной макет


Приступаем к рефакторингу основного макета приложения, чтобы было проще вносить изменения и легче повторно использовать общие элементы. Начнём с раздела head (всё что между тегами <head>). Создайте файл web/templates/layout/head.html.eex  и перенесите в него тег head вместе с содержимым:


<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="A Tech Blog Written in Elixir and Phoenix">
  <meta name="author" content="Brandon Richey">
  <%= if current_user = @conn.assigns[:current_user] do %>
    <%= tag :meta, name: "channel_token", content: Phoenix.Token.sign(@conn, "user", current_user.id) %>
  <% end %>

  <title>Phoenix Tech Blog</title>
  <link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
</head>

По большей части мы просто скопировали контент тега head, но при этом немного упростили работу с текущим пользователем и добавили немного информации в теги description, author и title. Вместо предыдущего подхода, когда использовался хелпер из LayoutView, теперь проверяется есть ли current_user внутри assigns, и если он есть, то текущий пользователь берётся в качестве параметра для метатега канала.


Теперь переходим к рефакторингу разметки верхней навигационной панели. Создайте файл web/templates/layout/navigation.html.eex:


<div class="top-bar">
  <div class="top-bar-left">
    <ul class="menu">
      <li class="logo">
        Phoenix Tech Blog
      </li>
      <li>
        <%= link "Home", to: page_path(@conn, :index) %>
      </li>
      <li>
        <%= link "Posts", to: post_path(@conn, :index) %>
      </li>
      <%= if current_user = @conn.assigns[:current_user] do %>
        <li>
          <%= link "My Posts", to: user_post_path(@conn, :index, current_user.id) %>
        </li>
        <li>
          <%= link "New Post", to: user_post_path(@conn, :new, current_user.id) %>
        </li>
      <% end %>
    </ul>
  </div>
  <div class="top-bar-right">
    <ul class="menu">
        <%= if current_user = @conn.assigns[:current_user] do %>
          <li>
            <%= link current_user.username, to: user_path(@conn, :edit, current_user.id) %>
          </li>
          <li>
            <%= link "Logout", to: session_path(@conn, :delete, current_user.id), method: :delete, class: "button alert" %>
          </li>
        <% else %>
          <li>
            <%= link "Login", to: session_path(@conn, :new), class: "button" %>
          </li>
        <% end %>
      </li>
    </ul>
  </div>
</div>

Это первый большой участок кода, в котором по полной используется Foundation. Здесь есть класс top-bar, а также различные вариации меню и сдвигов вправо/влево. Мы полностью вырезаем стандартные логотип Phoenix и навигационную панель и вместо этого используем простой текст в качестве логотипа. Ради простоты будем называть наш проект Phoenix Tech Blog. Также построим немного простого HTML с парой встроенных в Foundation стилей и компонентов. Ещё раз, в любом месте, где используется старый способ получения текущего пользователя, нужно заменить его на новый подход с использованием плага.


Краткое описание представленных классов


В последнем примере используются описанные ниже классы:


  1. top-bar – верхняя навигационная панель, как и ожидалось. (Документация)
  2. top-bar-left – сдвинутый влево подраздел верхней панели. top-bar-right – подраздел, сдвинутый вправо.
  3. menu – меню из ссылок. (Документация)
  4. button – стиль для стандартной кнопки. (Документация)
  5. alert – цвет кнопки. (Документация)

Продолжаем рефакторинг


Теперь возьмёмся за блок оповещения, который также являются достаточно часто используемым кодом. Давайте создадим файл web/templates/layout/alerts.html.eex:


<div class="row alerts">
  <%= if flash = get_flash(@conn, :info) do %>
    <div class="success callout alert-info" role="alert">
      <%= flash %>
    </div>
  <% end %>

  <%= if flash = get_flash(@conn, :error) do %>
    <div class="alert callout alert-error" role="alert">
      <%= flash %>
    </div>
  <% end %>
</div>

Здесь нам нужно сделать чуть больше правок, так как у Phoenix по умолчанию есть конкретные ожидания относительно используемых классов CSS. Эти изменения сделают код блока предупреждений и стили чуть более платформонезависимыми.


Если мы можем достать сообщение из conn, то соответственно отображаем его внутри div. Если нет, то не отображаем ничего. В исходном HTML-коде всё равно появится пустой div, но он никак не повлияет на отображение.


Краткое описание представленных классов


  1. row – контейнер для системы сетки. (Документация)
  2. callout – стандартный контейнер с флеш-сообщением. (Документация)
  3. success – приятный светло-зеленый стиль для успешного сообщения.  (Документация)
  4. alert – приятный светло-красный стиль для сообщений об ошибках. (Документация)

Заканчиваем с рефакторингом макета


У нас осталось 2 паршла, которые нужно доработать. Первый – основной контент. Обернём внутренности дива в классы row и content:


<main role="main row content">
  <%= render @view_module, @view_template, assigns %>
</main>

Второй – шаблон для включение стандартного кода Javascript внизу. Создайте файл web/templates/layout/script.html.eex:


<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
<script type="text/javascript">
  $(function() {
    $(document).foundation();
  });
</script>

Основная доработка здесь – это замыкание которое включается для вызова функции foundation() на объекте документа. Это требуется для встроенных в Foundation JS-хелперов.


Добавляем паршлы в макет


Наконец, давайте взглянем на макет приложения после нашего рефакторинга и на общий контент. Давайте изменим файл web/templates/layout/app.html.eex:


<!DOCTYPE html>
<html lang="en">
  <%= render "head.html", conn: @conn %>

  <body>
    <%= render "navigation.html", conn: @conn %>

    <section class="main-content row expanded">
      <%= render "alerts.html", conn: @conn %>

      <div class="row content">
        <%= render @view_module, @view_template, assigns %>
      </div>
    </section>

    <%= render "script.html", conn: @conn %>
  </body>
</html>

Как вы можете заметить, макет приложения теперь выглядит куда более аккуратнее. Будет КРАЙНЕ удобно, если мы добавим новый макет для действия index из PageController, который назовём "splash page", что значит приветственная страница.


Создаём новый макет


Создайте файл нового макета `web/templates/layout/splash.html.eex:


<!DOCTYPE html>
<html lang="en">
  <%= render "head.html", conn: @conn %>

  <body>
    <%= render "navigation.html", conn: @conn %>

    <section class="main-content row expanded">
      <%= render "alerts.html", conn: @conn %>

      <section class="text-center image-overlay row content">
        <%= render @view_module, @view_template, assigns %>
      </section>

      <section class="about expanded row">
        <div class="columns medium-12 large-4">
          <div class="icon">
            <i class="fi-like"></i>
          </div>
          <h2>What Powers This Blog</h2>
          <p>
            This blog is powered by <a href="http://elixir-lang.org">Elixir</a> and <a href="http://phoenixframework.org">Phoenix Framework</a> to
            give us a super fast, super clean, and super fun to modify blog engine!
          </p>
        </div>
        <div class="columns medium-12 large-4">
          <div class="icon">
            <i class="fi-comments"></i>
          </div>
          <h2>We Support Live Comments</h2>
          <p>
            By using the latest and greatest in client and server technology, we're able to offer a live, realtime commenting system with nearly no impact
            to performance!
          </p>
        </div>
        <div class="columns medium-12 large-4">
          <div class="icon">
            <i class="fi-pencil"></i>
          </div>
          <h2>We Support Markdown</h2>
          <p>
            We allow you to use <a href="https://daringfireball.net/projects/markdown/">Markdown</a> in all of your posts, which will make it simple to write
            up each post in the program of your choice and import it into this blog!
          </p>
        </div>
      </section>

      <section class="more-info expanded row">
        This is an example design for the Phoenix Blog Engine project detailed in my series of
        <a href="https://medium.com/@diamondgfx/introduction-fe138ac6079d">Medium</a> posts.
        This design is merely a starting template; you may want to go out and find your own
        as well to make it look truly original! You're welcome to use the source code for this
        project as well as the design as you wish! If you're interested in more, please find me
        on the internet via:
        <br />
        <div class="social-media text-center">
          <a href="http://twitter.com/diamondgfx"><i class="fi-social-twitter"></i></a>
          <a href="https://medium.com/@diamondgfx/"><i class="fi-social-medium"></i></a>
        </div>
      </section>

      <footer class="expanded row">
        I'm a standard footer for your site! Maybe you can throw a copyright &copy; down here or something like that, or licensing! You know, whatever!
      </footer>
    </section>

    <%= render "script.html", conn: @conn %>
  </body>
</html>

Здесь делается много всего, но по сути это просто контент с парой стилей здесь и там. Давайте взглянем ещё на несколько классов из Foundation:


  1. expanded – разворачивает row на всю ширину страницы. (Документация)
  2. text-center – говорит сам за себя: выравнивает текст по центру. Схожим образом text-left и text-right выравнивает текст влево и вправо, соответственно. (Документация)
  3. fi-comments, fi-pencil, fi-like – иконки из набора Foundation. (Документация)
  4. columns – сообщает о том, что мы хотим использовать блок в качестве отдельной колонки. (Документация)
  5. medium-12, large-4 – примеры использования адаптивной модели сетки. На экранах размера medium разметка займёт 12 колонок, а на экранах размера large только 4 колонки. (Вы также увидите пример использования small-N, где N некоторое число между 1 и 12). (Документация)

Наконец, сообщим Phoenix, что будем использовать новый макет для приветственного экрана в PageController. Откройте файл web/controllers/page_controller.ex и добавьте следующую строчку:


plug :put_layout, "splash.html"

Создаём экран постов для гостей


Нам также нужно дать гостям возможность просмотреть последние N постов, так как было бы глупо делать прикольный блог, который никто не сможет читать. Для начала откройте файл web/router.ex и добавьте ресурс для постов с одним действием index.


resources "/posts", PostController, only: [:index]

И затем откройте файл web/controllers/post_controller.ex, измените функцию index и добавьте ещё одну функцию index с использованием сопоставления с образцом. Но для начала измените плаг assign_user вверху файла:


plug :assign_user when not action in [:index]

А теперь измените функцию index:


def index(conn, %{"user_id" => _user_id}) do
  conn = assign_user(conn, nil)
  if conn.assigns[:user] do
    posts = Repo.all(assoc(conn.assigns[:user], :posts)) |> Repo.preload(:user)
    render(conn, "index.html", posts: posts)
  else
    conn
  end
end

def index(conn, _params) do
  posts = Repo.all(from p in Post,
                     limit: 5,
                     order_by: [desc: :inserted_at],
                     preload: [:user])
  render(conn, "index.html", posts: posts)
end

Первый вызов проверяет наличие user_id в параметрах. Таким образом мы знаем, что получаем определённый пост для определённого пользователя. Если мы попали в эту функцию, то необходимо повторно использовать плаг assign_user для присвоения пользователя в conn. Чтобы не загружать пользователей для пустого массива, мы заранее проверяем наличие пользователя в assigns. Иначе просто возвращаем conn, который будет содержать переадресацию для несуществующего пользователя, флеш-сообщение и остановку запроса.


Вторая функция index немного отличается. Нам нужно достать 5 самых свежих постов, так что ограничим их количество и предзагрузим пользователей в каждый пост. Не стоит волноваться по поводу комментариев, ведь их не нужно отображать на странице со списком постов. Здесь также нужно немного оптимизировать использование current_user. Для этого откройте файл web/templates/post/index.html.eex:


<div class="row">
  <div class="small-10 columns">
    <h1>Posts</h1>
  </div>
  <div class="small-2 columns text-right">
    <%= if current_user = @conn.assigns[:current_user] do %>
      <%= link "New post", to: user_post_path(@conn, :new, current_user.id), class: "button expanded large" %>
    <% end %>
  </div>
</div>

<%= for post <- @posts do %>
  <div class="row post">
    <div class="small-12 columns title">
      <h3><%= post.title %></h3>
    </div>
    <div class="small-12 columns">
      <small>
        Posted on <%= post.inserted_at %> by <%= link post.user.username, to: user_post_path(@conn, :index, post.user) %>
      </small>
    </div>
    <div class="small-12 columns">
      <%= markdown(post.body) %>
    </div>
    <%= if current_user = @conn.assigns[:current_user] do %>
      <div class="small-12 medium-4 columns">
        <%= link "Show", to: user_post_path(@conn, :show, post.user, post), class: "button expanded" %>
      </div>
      <div class="small-12 medium-4 columns">
        <%= link "Edit", to: user_post_path(@conn, :edit, post.user, post), class: "button warning expanded" %>
      </div>
      <div class="small-12 medium-4 columns">
        <%= link "Delete", to: user_post_path(@conn, :delete, post.user, post), method: :delete, data: [confirm: "Are you sure?"], class: "button alert expanded" %>
      </div>
    <% end %>
  </div>
<% end %>

Всё, что мы делали до – это там и сям прибирали стили. На всякий случай, код этих уроков находится здесь, так что можете посмотреть на все проделанные изменения, если хотите, чтобы у вас было всё один-в-один.


Исправляем тесты


Мы изменили часть разметки и функциональности, так что тесты упали. Чтобы их исправить, откройте файл test/controllers/page_controller_test.exs и измените проверку на “Phoenix Tech Blog”.


Заключение


Когда всё будет готово, вы должны увидеть подобную приветственную страницу:


А вот что увидит гость, решив взглянуть на список постов:


Теперь блог выглядит гораздо более профессионально. Плюс ко всему мы воспользовались Zurb Foundation 6 через Sass, а также jQuery и кучей других мелких бонусов.


Другие статьи серии


  1. Вступление
  2. Авторизация
  3. Добавляем роли
  4. Обрабатываем роли в контроллерах
  5. Подключаем ExMachina
  6. Поддержка Markdown
  7. Добавляем комментарии
  8. Заканчиваем с комментариями
  9. Каналы
  10. Тестирование каналов
  11. Заключение

Заключение от переводчика


Ура! Проделана огромная работа, которая наконец-то подошла к концу. Теперь у всех интересующихся Эликсиром и Фениксом есть довольно неплохой вводный курс, который поможет познакомиться с технологиями.


Ещё несколько десятков статей по Эликсиру на русском языке вы можете найти на сайте нашего проекта. На вопросы отвечаем в Телеграм-чате, а о новых статьях и интересных событиях рассказываем в Телеграм-канале, а также в еженедельной ламповой рассылке. Подписывайтесь!


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


И до новых встреч!

Tags:
Hubs:
Total votes 23: ↑21 and ↓2+19
Comments23

Articles