Чтобы следовать этому руководству, необходимо базовое понимание Laravel и Vue.js.
Такие компании, как Invision, создали приложения, которые дизайнеры используют для получения отзывов от других людей. Дизайнер может просто загрузить приложение, загрузить свои дизайны и отправить ссылку людям, которые оставят отзывы. Затем эти люди могут оставлять свои отзывы о различных частях дизайна. Это хорошо для дизайнера, потому что он может видеть эту обратную связь и сразу же действовать в соответствии с ней.
В этой статье мы собираемся создать аналогичное приложение для обратной связи по дизайну. Это позволит вам загружать изображения, отправлять ссылку кому-то еще и получать от них отзывы о вашем дизайне в режиме реального времени.
Вот запись экрана того, что может делать наше приложение:
Требования, которые нам понадобятся для создания нашего приложения
Прежде чем мы начнем, нам нужно подготовить несколько вещей. Требования следующие:
- Знание PHP и фреймворка Laravel.
- Знание JavaScript (ES6).
- Знание Vue.js.
- PHP 7.0+ установлен локально на вашем компьютере.
- Laravel CLI устанавливается локально.
- Композитор устанавливается локально.
- NPM и Node.js устанавливаются локально.
- Приложение Pusher. Создайте его на pusher.com.
Как только вы убедитесь, что у вас есть указанные выше требования, мы можем приступить к созданию нашего приложения.
Настройка нашего прототипа приложения обратной связи
Приступим к настройке нашего приложения. Создайте новое приложение Laravel, используя следующую команду:
$ laravel new your_application_name
По завершении установки cd
в каталог приложения. Откройте файл .env
, чтобы мы могли внести в него пару изменений.
Настройка нашей базы данных и миграции
Первое, что нужно сделать, это настроить нашу базу данных и создать ее миграции. Начнем с настройки базы данных. Замените элементы конфигурации ниже:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=homestead DB_USERNAME=homestead DB_PASSWORD=secret
с участием:
DB_CONNECTION=sqlite
Теперь это заставит приложение использовать SQLite в качестве выбора базы данных. В вашем терминале выполните команду ниже, чтобы создать новую базу данных SQLite:
$ touch database/database.sqlite
Теперь мы создадим несколько миграций, которые создадут необходимые таблицы в базе данных. В вашем терминале выполните следующую команду, чтобы создать необходимые нам миграции:
$ php artisan make:model Photo --migration --controller $ php artisan make:model PhotoComment --migration
Приведенная выше команда создаст модель, а затем флаги --migration
и --controller
укажут ей создать миграцию и контроллер вместе с моделью.
На данный момент нас интересуют Модель и миграция. Откройте два файла миграции, созданные в каталоге ./database/migrations
. Сначала отредактируйте класс CreatePhotosTable
. Замените содержимое метода up
следующим:
public function up() { Schema::create('photos', function (Blueprint $table) { $table->increments('id'); $table->string('url')->unique(); $table->string('image')->unique(); $table->timestamps(); }); }
Это создаст таблицу photos
при запуске миграции с помощью команды artisan. Он также создаст новые столбцы внутри таблицы, как указано выше.
Откройте второй класс миграции, CreatePhotoCommentsTable
, и замените метод up
приведенным ниже содержимым:
public function up()
{
Schema::create('photo_comments', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('photo_id');
$table->text('comment');
$table->integer('top')->default(0);
$table->integer('left')->default(0);
$table->timestamps();
$table->foreign('photo_id')->references('id')->on('photos');
});
}
Это создаст таблицу photo_comments
при запуске миграции, а также создаст внешний ключ для photos
таблицы.
Теперь перейдите в свой терминал и выполните команду ниже, чтобы запустить миграцию:
$ php artisan migrate
Теперь должны быть созданы таблицы базы данных.
Настройка моделей
Теперь, когда мы выполнили наши миграции, нам нужно внести некоторые изменения в наш файл модели, чтобы он мог лучше работать с таблицей.
Откройте модель Photo
и замените содержимое следующим:
<?php namespace App; use Illuminate\Database\Eloquent\Model; class Photo extends Model { protected $with = ['comments']; protected $fillable = ['url', 'image']; public function comments() { return $this->hasMany(PhotoComment::class); } }
Выше мы добавили свойство fillable
. Это мешает нам создавать исключения массового назначения при попытке обновить эти столбцы с помощью Photo::create
. Мы также устанавливаем свойство with
, которое просто загружает отношение comments
.
Мы определили красноречивое отношение, comments
, которое просто говорит, что Photo
имеет много PhotoComments
.
Откройте модель PhotoComment
и замените содержимое следующим:
<?php namespace App; use Illuminate\Database\Eloquent\Model; class PhotoComment extends Model { protected $fillable = ['photo_id', 'comment', 'top', 'left']; protected $appends = ['position']; public function getPositionAttribute() { return [ 'top' => $this->attributes['top'], 'left' => $this->attributes['left'] ]; } }
Как и в модели Photo
, мы определили свойство fillable
. Мы также используем Аксессоры Eloquent для настройки нового свойства с именем position
. Затем он добавляется, потому что мы указали это в свойстве appends
.
Настройка внешнего интерфейса для нашего приложения
Следующее, что мы хотим сделать, это настроить интерфейс нашего приложения. Давайте начнем с установки нескольких пакетов NPM, которые нам понадобятся в приложении. В приложении терминала выполните команду ниже, чтобы установить необходимые пакеты:
$ npm install --save laravel-echo pusher-js vue2-dropzone@^2.0.0
$ npm install
Это установит Laravel Echo, Pusher JS SDK и vue-dropzone. Эти пакеты понадобятся нам позже для обработки событий в реальном времени.
После успешной установки пакетов мы можем начать добавлять HTML и JavaScript.
Откройте файл ./routes/web.php
, и давайте добавим несколько маршрутов. Замените содержимое файла следующим содержимым:
<?php Route::post('/feedback/{image_url}/comment', 'PhotoController@comment'); Route::get('/feedback/{image_url}', 'PhotoController@show'); Route::post('/upload', 'PhotoController@upload'); Route::view('/', 'welcome');
В приведенном выше коде мы определили несколько маршрутов. Первый будет обрабатывать POST
ed обратную связь. Второй маршрут будет отображать изображение, которое должно получить обратную связь. Третий маршрут будет обрабатывать загрузки, а последний маршрут будет отображать домашнюю страницу.
Теперь откройте файл ./resources/views/welcome.blade.php
и замените его содержимое следующим HTML-кодом:
<!doctype html> <html lang="{{ app()->getLocale() }}"> <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="csrf-token" content="{{csrf_token()}}"> <title>Upload to get Feedback</title> <link href="https://fonts.googleapis.com/css?family=Roboto:400,600" rel="stylesheet" type="text/css"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" href="{{ asset('css/app.css') }}"> </head> <body> <div id="app"> <div class="flex-center position-ref full-height"> <div class="content"> <uploadarea></uploadarea> </div> </div> </div> <script src="js/app.js"></script> </body> </html>
Это простой HTML-документ. Если вы присмотритесь, вы увидите ссылку на тег uploadarea
, который не существует в HTML, но является компонентом Vue.
Откройте файл ./resources/assets/sass/app.scss
и вставьте следующий код под операторами импорта:
html, body { background-color: #fff; color: #636b6f; font-family: 'Roboto', sans-serif; font-weight: 100; height: 100vh; margin: 0; } .full-height { height: 100vh; } .flex-center { align-items: center; display: flex; justify-content: center; } .position-ref { position: relative; } .content { text-align: center; } .m-b-md { margin-bottom: 30px; } .dropzone.dz-clickable { width: 100vw; height: 100vh; .dz-message { span { font-size: 19px; font-weight: 600; } } } #canvas { width: 90%; margin: 0 auto; img { width: 100%; } } .modal { text-align: center; padding: 0!important; z-index: 9999; } .modal-backdrop.in { opacity: 0.8; filter: alpha(opacity=80); } .modal:before { content: ''; display: inline-block; height: 100%; vertical-align: middle; margin-right: -4px; } .modal-dialog { display: inline-block; text-align: left; vertical-align: middle; } .image-hotspot { position: relative; > img { display: block; height: auto; transition: all .5s; } } .hotspot-point { z-index: 2; position: absolute; display: block; span { position: relative; display: flex; justify-content: center; align-items: center; width: 1.8em; height: 1.8em; background: #cf00f1; border-radius: 50%; animation: pulse 3s ease infinite; transition: background .3s; box-shadow: 0 2px 10px rgba(#000, .2); &:after { content: attr(data-price); position: absolute; bottom: 130%; left: 50%; color: white; text-shadow: 0 1px black; font-weight: 600; font-size: 1.2em; opacity: 0; transform: translate(-50%, 10%) scale(.5); transition: all .25s; } } svg { opacity: 0; color: #cf00f1; font-size: 1.4em; transition: opacity .2s; } &:before, &:after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: 50%; pointer-events: none; } &:before { z-index: -1; border: .15em solid rgba(#fff, .9); opacity: 0; transform: scale(2); transition: transform .25s, opacity .2s; } &:after { z-index: -2; background:#fff; animation: wave 3s linear infinite; } &:hover{ span { animation: none; background: #fff; &:after { opacity: 1; transform: translate(-50%, 0) scale(1); } } svg { opacity: 1; } &:before { opacity: 1; transform: scale(1.5); animation: borderColor 2s linear infinite; } &:after { animation: none; opacity: 0; } } } @-webkit-keyframes pulse{ 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } } @keyframes pulse{ 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } } .popover { min-width: 250px; }
Сохраните файл и выйдите. Теперь перейдем к созданию наших компонентов Vue.
Использование Vue для создания функций нашего прототипа приложения обратной связи
Откройте файл ./resources/assets/js/app.js
и создайте в нем компонент Vue. В этом файле найдите строку ниже:
Vue.component('example', require('./components/Example.vue'));
и замените его на:
Vue.component('uploadarea', require('./components/UploadArea.vue'));
Vue.component('feedback',require('./components/FeedbackCanvas.vue'));
Теперь давайте создадим наш первый компонент Vue. В каталоге ./resources/assets/js/components
создайте файл с именем UploadArea.vue
. В новый файл вставьте следующее:
<template> <dropzone ref="dropzone" id="dropzone" url="/upload" accepted-file-types="image/*" v-on:vdropzone-success="showImagePage" :headers="csrfHeader" class="flex-center position-ref full-height"> <input type="hidden" name="csrf-token" :value="csrfToken"> </dropzone> </template> <script> import Dropzone from 'vue2-dropzone'; const LARAVEL_TOKEN = document.head.querySelector('meta[name="csrf-token"]').content export default { components: { Dropzone }, data() { return { csrfToken: LARAVEL_TOKEN, csrfHeader: { 'X-CSRF-TOKEN': LARAVEL_TOKEN } } }, methods: { showImagePage: (file, response) => { if (response.url) { return window.location = `/feedback/${response.url}`; } } }, mounted () { this.$refs.dropzone.dropzone.on('addedfile', function (file) { if (this.files.length > 1) { this.removeFile(this.files[0]) } }) } } </script>
В разделе template
мы просто используем пакет Vue dropzone для определения области, через которую можно загружать файлы. Вы можете просмотреть документацию здесь.
В разделе script
мы получаем токен Laravel CSRF из заголовка страницы и импортируем компонент Dropzone
в наш текущий компонент Vue.
В свойстве methods
мы определяем метод showImagePage
, который просто перенаправляет пользователя на страницу изображения после успешной загрузки изображения. В методе mounted
мы ограничиваем возможность загрузки файла dropzone по одному файлу за раз.
Давайте теперь создадим наш следующий компонент Vue. В каталоге ./resources/assets/js/components
создайте новый файл с именем FeedbackCanvas.vue
и вставьте следующее:
<template> <div class="feedback-area"> <div class="content"> <div id="canvas"> <div class="image-hotspot" id="imghotspot"> <transition-group name="hotspots"> <a href="#" class="hotspot-point" v-for="(comment, index) in image.comments" v-bind:style="{ left: comment.position.left+'%', top: comment.position.top+'%' }" :key="index" @click.prevent data-placement="top" data-toggle="popover" :data-content="comment.comment" > <span> <svg class="icon icon-close" viewBox="0 0 24 24"> <path d="M18.984 12.984h-6v6h-1.969v-6h-6v-1.969h6v-6h1.969v6h6v1.969z"></path> </svg> </span> </a> </transition-group> <img ref="img" :src="'/storage/'+image.image" id="loaded-img" @click="addCommentPoint"> </div> </div> </div> <add-comment-modal :image="image"></add-comment-modal> </div> </template>
Мы определили template
для нашего компонента Vue. Это область, где будет отображаться изображение и где будет предоставляться обратная связь.
А теперь немного разберем его на части.
Тег a
имеет набор атрибутов.
v-for
просматривает каждый комментарий / отзыв, который есть у изображения.
v-bind:style
применяет атрибут style
к тегу a
, используя свойства left
и top
комментария / обратной связи.
У нас также есть :data-content
, data-toggle
и data-placement
, которые нужны Bootstrap для его Popovers.
Тег img
имеет событие @click
, которое запускает функцию addCommentPoint
при нажатии на область изображения.
И, наконец, есть компонент Vue add-comment-modal
, который принимает свойство image
. Этот компонент будет отображать форму, чтобы любой мог оставить комментарий.
В этом же файле после закрывающего тега template
вставьте следующий код:
<script> let AddCommentModal = require('./AddCommentModal.vue') export default { props: ['photo'], components: { AddCommentModal }, data() { return { image: this.photo } }, mounted() { let vm = this Echo.channel(`feedback-${this.photo.id}`) .listen('.added', (e) => { // Look through the comments and if no comment matches the // existing comments, add it if (vm.image.comments.filter((comment) => comment.id === e.comment.id).length === 0) { vm.image.comments.push(e.comment) $(document).ready(() => $('[data-toggle="popover"]').popover()) } }) }, created() { /** Activate popovers */ $(document).ready(() => $('[data-toggle="popover"]').popover()); /** Calculates the coordinates of the click point */ this.calculateClickCordinates = function (evt) { let rect = evt.target.getBoundingClientRect() return { left: Math.floor((evt.clientX - rect.left - 7) * 100 / this.$refs.img.width), top: Math.floor((evt.clientY - rect.top - 7) * 100 / this.$refs.img.height) } } /** Removes comments that have not been saved */ this.removeUnsavedComments = function () { var i = this.image.comments.length while (i--) { if ( ! this.image.comments[i]['id']) { this.image.comments.splice(i, 1) } } } }, methods: { addCommentPoint: function(evt) { let vm = this let position = vm.calculateClickCordinates(evt) let count = this.image.comments.push({ position }) // Show the modal and add a callback for when the modal is closed let modalElem = $("#add-modal") modalElem.data({"comment-index": count-1, "comment-position": position}) modalElem.modal("show").on("hide.bs.modal", () => vm.removeUnsavedComments()) } }, } </script>
💡 Методы
created
иmounted
- это перехватчики, которые вызываются автоматически во время создания компонента Vue. Вы можете узнать о жизненном цикле Vue здесь.
В методе mounted
мы используем Laravel Echo для прослушивания канала Pusher. Название канала зависит от идентификатора просматриваемого в данный момент изображения. Каждое изображение будет транслироваться на другом канале в зависимости от идентификатора изображения.
Когда событие added
запускается на канале feedback-$id
, оно просматривает доступные image.comments
и, если транслируемый комментарий не существует, добавляет его в массив комментариев.
В методе create
мы активируем всплывающие окна Bootstrap, определяем функцию, которая вычисляет координаты точки щелчка, и мы определяем функцию, которая удаляет комментарии, которые не были сохранены из массива image.comments
.
В разделе methods
мы определяем метод addCommentPoint
, который вычисляет координаты щелчка и затем запускает новый модальный файл Bootstrap. Он будет создан в add-comment-modal
компоненте Vue.
Чтобы Laravel Echo заработал, нам нужно открыть файл ./resources/assets/js/bootstrap.js
и добавить код ниже внизу файла:
import Echo from 'laravel-echo' window.Pusher = require('pusher-js'); window.Echo = new Echo({ broadcaster: 'pusher', key: 'PUSHER_KEY', encrypted: true, cluster: 'PUSHER_CLUSTER' });
Вы должны заменить PUSHER_KEY
и PUSHER_CLUSTER
ключом и кластером для вашего приложения Pusher.
Теперь давайте создадим наш следующий компонент Vue, AddCommentModal.vue
. Он уже упоминается в нашем FeedbackCanvas.vue
компоненте Vue.
<template> <div id="add-modal" class="modal fade" role="dialog" data-backdrop="static" data-keyboard="false"> <div class="modal-dialog"> <div class="modal-content"> <form method="post" :action="'/feedback/'+photo.url+'post'" @submit.prevent="submitFeedback()"> <div class="modal-header"> <h4 class="modal-title">Add Feedback</h4> </div> <div class="modal-body"> <textarea name="feedback" id="feedback-provided" cols="10" rows="5" class="form-control" v-model="feedback" placeholder="Enter feedback..." required minlength="2" maxlength="2000"></textarea> </div> <div class="modal-footer"> <button type="submit" class="btn btn-primary pull-right">Submit</button> <button type="button" class="btn btn-default pull-left" data-dismiss="modal">Cancel</button> </div> </form> </div> </div> </div> </template> <script> export default { props: ['image'], data() { return { photo: this.image, feedback: null } }, methods: { submitFeedback: function () { let vm = this let modal = $('#add-modal') let position = modal.data("comment-position") // Create url and payload let url = `/feedback/${this.photo.url}/comment`; let payload = {comment: this.feedback, left: position.left, top: position.top} axios.post(url, payload).then(response => { this.feedback = null modal.modal('hide') vm.photo.comments[modal.data('comment-index')] = response.data $(document).ready(() => $('[data-toggle="popover"]').popover()) }) } } } </script>
В разделе template
мы определили типичный модальный файл Bootstrap. В модальной форме мы прикрепили вызов к submitFeedback()
, который запускается при отправке формы.
В разделе script
мы определили метод submitFeedback()
в свойстве methods
компонента Vue. Эта функция просто отправляет комментарий серверной части для хранения. Если есть положительный ответ от API, модальное окно Bootstrap скрывается, а комментарий добавляется к массиву image.comments
. Затем всплывающее окно Bootstrap перезагружается и принимает изменения.
Этим последним изменением мы определили все наши компоненты Vue. Откройте свой терминал и выполните команду ниже, чтобы создать свои ресурсы JS и CSS:
$ npm run dev
Большой! Теперь давайте создадим серверную часть.
Создание конечных точек для нашего прототипа приложения обратной связи
В вашем терминале введите команду ниже:
php artisan make:event FeedbackAdded
Это создаст класс событий с именем FeedbackAdded
. Мы будем использовать этот файл, чтобы инициировать события для Pusher, когда мы добавим обратную связь. Это позволит отображать обратную связь в реальном времени для всех, кто смотрит на изображение.
Откройте класс PhotoController
и замените его содержимое приведенным ниже кодом:
<?php namespace App\Http\Controllers; use App\Events\FeedbackAdded; use App\{Photo, PhotoComment}; class PhotoController extends Controller { public function show($url) { $photo = Photo::whereUrl($url)->firstOrFail(); return view('image', compact('photo')); } public function comment(string $url) { $photo = Photo::whereUrl($url)->firstOrFail(); $data = request()->validate([ "comment" => "required|between:2,2000", "left" => "required|numeric|between:0,100", "top" => "required|numeric|between:0,100", ]); $comment = $photo->comments()->save(new PhotoComment($data)); event(new FeedbackAdded($photo->id, $comment->toArray())); return response()->json($comment); } public function upload() { request()->validate(['file' => 'required|image']); $gibberish = md5(str_random().time()); $imgName = "{$gibberish}.".request('file')->getClientOriginalExtension(); request('file')->move(public_path('storage'), $imgName); $photo = Photo::create(['image' => $imgName, 'url' => $gibberish]); return response()->json($photo->toArray()); } }
Выше у нас есть show
метод, который показывает изображение, чтобы люди могли оставить о нем отзыв. Далее идет метод comment
, который сохраняет новый комментарий к изображению. Последний метод - это метод upload
, который просто загружает изображение на сервер и сохраняет его в базе данных.
Давайте создадим представление для метода show
. Создайте новый файл в каталоге ./resources/views
с именем image.blade.php
. В этот файл вставьте приведенный ниже код:
<!doctype html> <html lang="{{ app()->getLocale() }}"> <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="csrf-token" content="{{csrf_token()}}"> <title>Laravel</title> <link href="https://fonts.googleapis.com/css?family=Roboto:400,600" rel="stylesheet" type="text/css"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" href="{{ asset('css/app.css') }}"> </head> <body> <div id="app"> <feedback :photo='@json($photo)'></feedback> </div> <script src="{{asset('js/app.js')}}"></script> </body> </html>
В приведенном выше описании выделяется только тег feedback
. В основном это относится к компоненту обратной связи Vue, который мы создали ранее в этой статье. Все остальное - это просто базовый Blade и HTML.
Теперь, когда мы создали представление, нам нужно добавить каталог для загрузок, определенных в методе upload
. В вашем терминале выполните следующую команду:
$ php artisan storage:link
Эта команда создаст символическую ссылку из каталога ./storage
в каталог ./public/storage
. Если вы посмотрите в каталог ./public
, вы увидите символическую ссылку.
Теперь, когда мы создали бэкэнд для поддержки нашего веб-приложения, нам нужно добавить Pusher в бэкэнд, чтобы сделанные комментарии транслировались и могли быть приняты другими людьми, просматривающими изображение.
Добавление функциональности в реальном времени в прототип приложения обратной связи с помощью Pusher
Откройте свой терминал и введите команду ниже, чтобы установить Pusher PHP SDK:
$ composer require pusher/pusher-php-server "~3.0"
Откройте файл .env
, прокрутите вниз и настройте клавиши Pusher, как показано ниже:
PUSHER_APP_ID="PUSHER_ID"
PUSHER_APP_KEY="PUSHER_KEY"
PUSHER_APP_SECRET="PUSHER_SECRET"
Также в том же файле найдите BROADCAST_DRIVER
и измените его с log
на pusher
.
Затем откройте ./config/broadcasting.php
и перейдите к клавише pusher
. Замените options
ключ этой конфигурации приведенным ниже кодом:
// ...
'options' => [ 'cluster' => 'PUSHER_CLUSTER', 'encrypted' => true ],
// ...
💡 Не забудьте заменить
PUSHER_ID
,PUSHER_KEY
,PUSHER_SECRET
иPUSHER_CLUSTER
значениями из вашего приложения Pusher.
Теперь откройте класс FeedbackAdded
и замените содержимое приведенным ниже кодом:
<?php namespace App\Events; use Illuminate\Broadcasting\Channel; use Illuminate\Queue\SerializesModels; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; class FeedbackAdded implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; public $comment; public $photo_id; public function __construct(int $photo_id, array $comment) { $this->comment = $comment; $this->photo_id = $photo_id; } public function broadcastOn() { return new Channel("feedback-{$this->photo_id}"); } public function broadcastAs() { return 'added'; } }
В приведенном выше классе мы определяем объект comment
и photo_id
, которые будут использоваться для составления имени канала в методе broadcastOn
. Мы также определяем метод broadcastAs
, который позволит нам настроить имя события, отправляемого в Pusher.
Это все. Теперь давайте запустим наше приложение. В вашем терминале запустите приведенный ниже код:
$ php artisan serve
Это должно запустить новый сервер PHP. Затем вы можете использовать его для тестирования своего приложения. Перейдите по указанному URL-адресу, и вы должны увидеть свое приложение.
Заключение
В этой статье мы успешно создали функцию обратной связи прототипа приложения, которая позволит дизайнерам делиться своими проектами с другими и получать отзывы о них.
Этот пост впервые был опубликован в Pusher.