diff --git a/.gitignore b/.gitignore index 5756bac73..c66419bdf 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.* *.diff *.err *.orig @@ -12,21 +11,36 @@ # OS or Editor folders .DS_Store +.idea .cache .project .settings .tmproj -nbproject +.nvmrc +sftp-config.json Thumbs.db +# private downloads +download/ + +# database dump for tutorial export +dump/ + +# extra handlers are not in the repo +extra + # NPM packages folder. node_modules/ -# Brunch folder for temporary files. +# TMP folder (run-time tmp) tmp/ -# Brunch output folder. -www/ +# Manifest (build-generated content, versions) +manifest/ + + +# contains v8 executable for linux-tick-processor (run from project root) +out/* -# Bower stuff. -bower_components/ +# Generated content +public/* diff --git a/.jshintrc b/.jshintrc new file mode 100755 index 000000000..2653b08b8 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,19 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": true, + "node": true, // for browserify require etc + "globals": ["$", "Prism", "describe", "it", "before", "after", "beforeEach", "afterEach"], + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "multistr": true, + "esnext": true, + "noyield": true, + "devel": true, + "loopfunc": true, + "-W004": true, + "-W030": true, // for yield* ... + "-W078": true // allow setter w/o getter +} diff --git a/.travis.yml b/.travis.yml old mode 100644 new mode 100755 index db6bc0c1f..cef82271b --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,68 @@ language: node_js node_js: - - 0.10 - - 0.11 -services: - - mongodb + - iojs +git: + # javascipt-turorial submodule is checked out manually with token, + # in install hook + submodules: false + # Don't need history to do tests + # But depth: 1 would be too small + # The problem with using git clone depth 1 is that if the contributer + # pushes multiple commits in short succession before Travis is able to + # kick off a build. The build will fail due to being unable to find the + #commit. This is because Travis checks out a branch and then checks out + # the exact commit. So it is possible to get into a situation where the + # latest branch is newer than the commit Travis is trying to build + # against. + depth: 10 + +install: + - source ./scripts/travis/install.sh + +script: + - source ./scripts/travis/test.sh + +env: + global: # not a build matrix, but variables for every build + #- TRAVIS_DEBUG=1 + - NODE_ENV=test + - E2E_BROWSER=firefox + # id_rsa (by travis_key.sh) + - secure: "jXxCMfeJGuoQomJr/BzXtAnpjz6DVBRHsQqGLS5jky5KvQAaxwsC4MSOMCEPiTCUSRZ0/dTBhwzHeiFQ9c3m83NIwBmb27b/IkTbVUz8g515kHu1greDFWtdlVCwUOyTlc6bET84QDjMHnwmSTXHJw02j7D44mbOyvP07P1akAE=" + - secure: "JzmDqycU+E+3MFG6iSW5+yZ9wPns4a1vC3i2/SVhLUvOAQpi/NNiNZQjcRkLmOiiV2Z9ykm4Tw0inYRmGR1LyXTFDyukjrfd9SiZ2o8iSuP3bIACyB2uGwJcRdTt0RFXEFvjE9l7O8fiQxPo8Fa3ZMF5Vx9zDGV+JGudycbGfMQ=" + - secure: "cdqo9zt5EfTnEfHTl4BZi9eI3/V3F+piYS0W1yCe3YazgfOL4dk6SMm3nPvfrH1GafbKWDWH+PP79btqKuS27cBycRyBFyE9cOJ5KMqQZOxgtlwvoPRToxNuGkMf+K2mIaL3xLt+ydphWsoOndhBvuef5U9viGhZGiSy+v4AWGE=" + - secure: "YvntZ1dqChna300Q2M4eHArwfxC7r5Ds+dGb3rokigszU8+orpKzNj9Ryv2v5w5TwtYV/IfdR1q+oabD0nrJzZ8iiQZVMjktfK9wWAIM7LqSN08vWeZVEV8XC9dLAjsMFAyLzm7+f3ReDcxyWeRIiq8HWdGEGqZS7adhvJhl18w=" + - secure: "Sa2gtWs+1It2txzWkqJybNh/4q8l/owYuJgph40axFtUJt34XDJGVWIIr2uo4DUhyDt7pwI+UuiyLBC031kUNogKKm3k8W/Y3dUQUoTWTSxM9mdtMmirg8RS/C0HLp7w5d81zyJj8hzT5C0axSvNAsCLu9aXn9L4mgLlL9yb7o0=" + - secure: "Fzy6mZQcLbsYEKiPCpTPGSfzn0eJ/ma5Bjw4t0/po3PHjnX9TTqryMp68ca7yMN7cXVE2cEgjXL1uE3DJygF+8BVmx7bt3vfHqxqhxjVTBvOcD64IPXOjoH0jkj0kX1kP72Cu7IQelPqpDToKn8HzlGwTayGG6HYcoMK6Vksd5g=" + - secure: "c3sWfOvzmhfaaVqBc8MO2I0j2sjEB7yCtDmejza5NM/fh8NtOOmC58dEbaGmjTTdPmH5Ci0rwXBLm9JCZq62/PrLao2pLjIqGJLnEcmV51iwO0NREmX98xIjb5jWPMbppgJzf9huZfaKaC2opYzWtH5/oX2ti7mUGwBGysUM+mM=" + - secure: "G7GYb5ARiMm8lOsvub3CGUqsLuWCg55Dh+c5CW971TXV26ZNi1l2ubLf5bdnoRueSvOMo3EmzhCBGXoXrnHnCuai5Z4srvtqTgPAf1k/9gfPAugMEf/SO13lazJs1aJsDaOtTmVf5j6yJBRDbLJexvBigsHPbTYA6qL+g6lo4Ug=" + - secure: "B6qRlAwxo55nDxNDhIkTaPx6tgWK8wXjUK32kETQ/LZ5RWs1eb0TuvHon53WOhVYg+Sc6etbQw/Hf9w0dVTCZtKXrmCMLOjLpawnFWmmhGNQHrrXMXVsYYZZ2s1JNZYENi6m/KD1d4keL6ZRcxIY9UBJ28hwPGw7DLlaIZbh2ZM=" + - secure: "fl65/grTfH39r9/N73yA8lByYQS/m6LUYRCCF+Y13/U7A9q2JzRhSbVkDqosT69cuvJAsGyyqyGstQQkldf9uGw7egwMepwRp4AWY/eV1jSsQYIlpCyl50BlC7FhNvVJmiXepo3yEgJGYhIlKNU9SkkLbJr4sIh3qgsRFz5Lpa8=" + - secure: "c24KmkcbSbtc3WD6qSzU++oI+GItEFd1sjKydxTA7ZpIHQDtZADNh5XUByt1mbJ8a11xqPihzMIi3dulrVLMQM2L42oGxxDabOf7Y8qY362m32efrodvMyLf0rpHa4eJbYOn2nfxWS0HsQUt9HntES6yk15YLUzwxbzYKx7wpHc=" + - secure: "aW9Y8WyAAj2Z+Xn1uy/wKSwDmf2G4XEOFibXzAAZFs0MwsNQHcMjY7+2UKD0BIio14XpQIgxxlxNbNZrSb/ushx3ON3SP+hzOA+tybO0a65PD1vpqUIyz5lHCY9/H7SCxdrBFqDT3pkAaQnCejvUKnQWyR4GKJtwyzZrVhuUj8A=" + - secure: "pbQRrvcJjqfDr6XNR87XQ+KCnqhhFA1/sojHjgUziNQBf24pVHyBadGVwi243iiqTfWspDklLT8VSZe+Q/HYGm/aINLEs4w5jJ3cu3MzlWRn4kjE7/5K0QE+ZnbfCdgolIkTmxECVBFrqqMmDl/p2oys3PM3cTLjNvPGv6c5JqE=" + - secure: "Rb3VPzv8zfSsmLXJuZpwRNiYop4Iz7wgS/Cu96oM1840LiKSoufFtKpB6Y2yoAYhkyDYdgOP9LgYQxT6P9n8PYpaN38l7JIewnkM4tfB0SU0wo5nauMiIUE1Y58QLebe0xvnzk1GvPm76SuZRPU3rg1XmCxT5M7ovGI5Zrd2cXM=" + - secure: "Vt4Lclq4fAWRf1nkeY54osLKasJvCC6GKDvSFP7lFKcHCmArw9yvkVhlH/w2iegV1iO/Hqqggmxtq0EIu+Tqfmkkjvd1wMsH5+iOJMXvP9soINuN+wwDJwsn93mriJGaNwv2z4tRMl5klPFPgFDEZkqKDbBiuMUyG8XtWsmcAR0=" + - secure: "OMIGENvQus8ukVIekuhK768gx/8P3xbmY2kCaDIEndo2sRNriZynsJ4WkXbM/Ov9jxPb1kmdHpjv6ChOoIjcComclld+lKMoh6AAR8dcz3PZfT0FEY7a6N0XKel6V2stF7LXgJlDbKZvCuylEESjbVRIYuZBRwni9+UmUpxcI7w=" + - secure: "Wsg2SsZN7wsJ6FnytVZIM/+iz0Ufr96A39x9D07OjjYTdLowYxE1jU1b/25uc5Wy6V5cWddkCjjiECyKtK3qe+OVPDYDR1R+W1QvLC2XLy/PmGGhuf8EBRBaVrwgUgqAcjB53UPviVBgOpMTuu/qVzQHpehpAcJ0DDbYMBzyG/A=" + - secure: "glTBrJyC6Trrj17ud2wmjj4pDDBEyKWEgz+m4e2xRWZ+/Y3PAf43XBWTrp3UpS0isADvoxhsN+IzYoyGfW66X7+KNS4spT43wbaT/1GrNUZDQxNP1k7rLC++tk3/6QMvUFtn/+wkCCMtQS70cZVhSTynxNa51fiXACl0oxJcUNU=" + - secure: "DPN/qQ58H9qH/rX4YZlVUUzoK4wKTBE1dW9ROE164kazZ2DLfbLAyuUn54buFB2Xh+i2vufzvGpsUNqCh4Rk7cqznmD5YeR0Z3bp57bbFHUolxqzdg4X5Tu7IbplU4YElc12775doKviDFI5LVRjHtkbLDqVXb5u9rOQCiwLbxQ=" + - secure: "CmpJyG4xjIPQCxdfrsl8hZ3lRyAoAk9U3WekNqcu5m85EyLbnUuC8IRdrIJ8WPTpEqO8JA6loavAGuc8Ek/NgaMKNFq/NLS7eVe9XfHvOrMHl7YplmwtTcy+a7pMyP8mHUcWZddIQO1VBG2lmEg0/mI2fVWGDhIO02/Ma2XqJtE=" + - secure: "Uq2jBQj3+X3uec5Y5TIsZqwKOQ90xAAtwaZBEM6aSd8d6Mpo9CmmT7xtiBViZkbmAHhXYybKI9j5S5TtbGfbd66pYIDQWu5aOQq+2yIVHQnt4TsyLxDYlEW9263rdklKFcfvc3Q8aMggz8ShKCLyCPeCDm9LCSCRGsPFgYAzdM4=" + - secure: "o6HD1f3TS/OFmAA4nuCeo9UqeZfDLcxXgI7aUu8kUe3WB1RqiFscZmi8oP4c+x+nf6D3CRq9W5t0UrifSSP0HLHumf9SJsJW8NAZuEBSBMtAlLxN4SlsE6Q65hJGIy5/VxqYbfA6nfmWf6Hzanxd5cb+4nD8nP8Rb5yhUK/WnLg=" + - secure: "l6IO9z+QLwR8jSp8qkx72ZIpOzcGwLJopYgsPkipT/RhmDCRZiLZtqQ6+kohG1IDt0XFc9ZkyK3f5IeFA6laLrCb/6GuWCyXNtBGrP8jhOO52k2iCHrTJcr4e9NR0wldOGbuhY57ipFVs2xmFCJjIjOBEwsBNwmBxeApYlZeHxg=" + # id_rsa.pub (by travis_key.sh) + - secure: "U3hGgFIvhaBxRkFTsFvOdIcUYKJHpXd6J8RWf5PrdfKJ26XDwDCrS9cYahPiBZjYzB7NhMbhfIMFxKzujxeDWx86Utw8jNpQ69U1ge+C2zhRezzDzBECLE2AAh3EvLOI6DZa5r2tWkeBUoBeGuZlBXASGLpje2kzZT3r3ggGBbA=" + - secure: "H0xIXyYrVkLXMlH6OAkIdudwA+aaj5N+YNNruhR3WqKFaiLQkiU2OptpuN2jL+bMHyOxGGSxbHMoweVUryjX3DcBsfrNbhsRccryBC35MDrZACB2lUnwh+VJUgoHTqhVAcdIeQz1ALVlhvEgTx2+Xwe05fLjtrvkdOPtj2KZ9Ck=" + - secure: "LlUZS83kfwVWaDD1ahw/z8hYKVvRF1lp3Y3BTCH1qPHBluqrBmg1+/kbcVAzmIaBe3/f4M3fQgQvt7rlwgKzkDfS22Z52bMqj9cSv5Am1XdDAYlomldwJpV291Rt/qG7VajlTYqKXCua2zCSGgnlybIIshca5K0OxYGLwKjHO+0=" + - secure: "ns1sdmT8ZYr2w55/6n7AWgs0yK9XsaLPvmwYuPVcubnA12BdobtehYUNCqyjfIz6wtsUmiQHLDvFFOq5tic4c9qD217B2c8mP5fxuf9v5V2TBX8fL2RxmvPYDxi9nl5i4tSoNJjyfMWzlQLDaBiGw0Q2X6XKHZDqjg1JBWJK3Ww=" + - secure: "PFlzNIBId1aCj6htuWqnklVPqZzc7Kb50ubpwZSXjD08txj6y9WwKyI/4aT/Qc4/g+xedBP8t6xal4qP4yd2GqWA6T3IoFusHGyjguqu08BvYad+O6ri92ZcnegmGkfAMiIlbtBjdPD3JkbtEr9+opVGfPFfd+56smZEzBZWO/M=" + - secure: "P4Tg2Sb/TYwXx9rQsDbQ38f8bgjjNog7/6qsT2KfZsCrATk5g1vd4BaGS8G3GyVINFQTy1ozISfwEgM/SZEeDIUx9hkr9aAKqIwnQROn8FS+CIP2YO6vPHRIxBwCA49+e6wCkeeu7GMM/aHxH7O9JU9E73Dr03QRlQ2b0OQJ3SE=" + +notifications: + email: false + +# blacklist +branches: + except: + - production diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100755 index 000000000..cf2d06519 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,17 @@ + +В этом файле перечислены (в алфавитном порядке) люди, которые внесли весомый вклад в учебник или код проекта: + + + +Проект существует давно, а список создан совсем недавно. + +Наверняка я кого-то забыл. + +Если вы должны быть в этом списке, но вас нет — обязательно напишите мне на mk@javascript.ru. diff --git a/Install.md b/Install.md new file mode 100755 index 000000000..5ca3d90c4 --- /dev/null +++ b/Install.md @@ -0,0 +1,119 @@ + +# Как поднять сайт локально + +## 0. Операционная система + +Сайт работает под MacOS, Unix (протестировано на Ubuntu, Debian), но не Windows. Сам код сайта написан более-менее универсально, но под Windows криво работают некоторые сторонние модули. + +## 1. Поставьте IO.JS 0.12 + +Нужна именно последняя версия [IO.JS](https://iojs.org/en/index.html). + +## 2. Поставьте и запустите MongoDB. + +Если у вас Mac, то проще всего сделать это через [MacPorts](http://www.macports.org/install.php) или [Homebrew](http://brew.sh), чтобы было проще ставить дополнительные пакеты. + +Если через MacPorts, то: +``` +sudo port install mongodb +sudo port load mondogb +``` + +## 3. Клонируйте репозитарий + +Предположу, что Git у вас уже стоит и вы умеете им пользоваться. + +Клонируйте только ветку `master`: +``` +git clone -b master --single-branch https://github.com/iliakan/javascript-nodejs +``` + +## 4. Глобальные модули + +Поставьте глобальные модули: + +``` +npm install -g mocha bunyan gulp nodemon +``` + +## 5. Системные пакеты + +Для работы нужны Nginx, GraphicsMagick, ImageMagick (обычно используется GM, он лучше, но иногда IM). + +``` +sudo port install ImageMagick GraphicsMagick +sudo port install nginx +debug+gzip_static+realip + +sudo port load nginx +``` + +## 6. Конфигурация Nginx + +Если в системе ранее не стоял nginx, то ставим настройки для сайта: + +Например: +``` +gulp config:nginx --prefix /opt/local/etc/nginx --root /js/javascript-nodejs --env development --clear +``` + +Здесь `--prefix` -- место для конфигов nginx, обычно `/etc/nginx`, в случае MacPorts это `/opt/local/etc/nginx`. +В параметр `--root` запишите место установки сайта. + +Опция `--clear` полностью удалит старые конфиги nginx. + +Если уже есть nginx, то можно без `--clear`. Тогда команда только скопирует файлы из директории nginx (с минимальной шаблонизацией) в указанную директорию. +Основные конфиги будут перезаписаны, но в `sites-enabled` останутся и будут подключены и другие сайты. + +Также рекомендуется в `/etc/hosts` добавить строку: +``` +127.0.0.1 javascript.in +``` + +Такое имя хоста стоит в конфигурации Nginx. + +## 7. `npm install` + +В директории, в которую клонировали, запустите: + +``` +npm install +``` + +## 8. База + +Инициализуйте базу сайта командой: + +``` +gulp db:load --from fixture/init +``` + +Учебник находится в отдельном репозитарии: +``` +git clone -b master --single-branch https://github.com/iliakan/javascript-tutorial +``` + +После клонирования импортируйте учебник командой: +``` +gulp tutorial:import --root /js/javascript-tutorial +``` + +Здесь `/js/javascript=tutorial` -- директория с репозитарием учебника. + +## 9. Запуск сайта + +Запуск сайта в режиме разработки: +``` +./dev +``` + +Это поднимет сразу и сайт и механизмы автосборки стилей-скриптов и livereload. + +Обратите внимание: ходить на сайт нужно через Nginx (обычно порт 80), не напрямую в IO.JS (не будет статики). + +Если в `/etc/hosts` есть строка `127.0.0.1 javascript.in`, то адрес будет `http://javascript.in/`. + +# TroubleShooting + +Если что-то не работает -- [пишите issue](https://github.com/iliakan/javascript-nodejs/issues/new). + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 000000000..82a2b611a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,14 @@ +Код публикуется под открытой лицензией CC-BY-NC-SA. + +Это означает, что вы можете свободно распространять, использовать и адаптировать этот код при выполнении следующих условий: + + - Аттрибуция: указать автора (Ilya Kantor) исходного кода и эту лицензию. + - В некоммерческих целях. + - Переделанные вами части должны быть доступны на этих же условиях. + +Это было совсем краткое изложение лицензии, +более полный текст которой находится на https://creativecommons.org/licenses/by-nc-sa/3.0/, +а юридически оформленный -- на https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode. + +Если для вашего проекта нужна другая лицензия - напишите мне: iliakan@javascript.ru. + diff --git a/README.md b/README.md index 83b578c01..8b0a82ea2 100755 --- a/README.md +++ b/README.md @@ -1,46 +1,45 @@ -**Всем желающим предлагается поучаствовать в разработке новой версии сайта http://javascript.ru на Node.JS, Open Source on GitHub.** +# Движок javascript.ru на javascript -О проекте: +Всем привет! -* Это сайт по JavaScript и смежным технологиям (AJAX, COMET, Browser APIs...) -* Сайт достаточно большой и сложный. В новом проекте предусмотрены разделы: - * учебник (с генерацией PDF) - * вопрос-ответ - * тесты знаний - * онлайн-курсы - * справочник - * события - * работа -* Логин через соц. сети в том числе, личные сообщения и профиль. -* Сайт достаточно посещаемый: порядка 1-1.5 млн просмотров в месяц, и их станет больше при успешной реализации. -* Планируется перевод учебника на английский, после реализации на русском. -* Основная аудитория - разработчики, так что поддержка старых IE не нужна. Совсем. +А это исходный код для движка сайта [https://learn.javascript.ru](https://learn.javascript.ru) на платформе Node.JS. -Так как сайт должен хорошо индексироваться поисковиками, он будет состоять из страниц с переходом между ними, не SPA. Хотя в различных интерфейсах элементы SPA приветствуются. +[![Build Status](https://travis-ci.org/iliakan/javascript-nodejs.svg?branch=master)](https://travis-ci.org/iliakan/javascript-nodejs) -Мы будем стараться, чтобы сайт работал как можно быстрее. Это означает параллельные запросы к БД и кеширование на сервере и, по возможности, плавную инициализацию на клиенте. +## Что делаем? -Сейчас есть существенная часть дизайна и его вёрстка в HTML/SASS. +* Сайт по JavaScript и смежным технологиям (AJAX, COMET, Browser APIs...) +* Сайт достаточно посещаемый: порядка 1-1.5 млн просмотров в месяц. +* Сайт быстрый, генерация страницы до 100мс, лучше до 50мс. +* Сайт пока на русском, на английском сделаем потом. +* Сайт для разработчиков, да, кстати, они не пользуются старыми и страшными IE. -Общий стиль вы можете посмотреть здесь: https://www.dropbox.com/s/mo6yx0ct9rrzic4/Learn_Home.png. +С элементами SPA, но не SPA, потому что нафига сове биплан. Она и так летает. -RoadMap: +## Что в опен-сорсе? -* Определиться с архитектурой проекта, технологиями. -* Реализовать профиль посетителя, логин через соц. сети, с заглушкой на title-page. -* Реализовать показ учебника и навигацию по нему, древовидные комментарии с оценками, подгрузкой. -* Сделать покупку PDF учебника (оформление, приём оплаты, почтовое уведомление, скачивание). +В опен-сорсе весь код сайта, включая такие аспекты как: + + -Это примерно соответствует текущему http://learn.javascript.ru. Когда закончим -- будет первый релиз, вместо старого learn.javascript.ru. +Многие модули из него можно взять и выделить в отдельные проекты, было бы желание. -Далее или, если будет возможность, параллельно, реализуем вопрос-ответ, справочник, тесты знаний. +Также в опен-сорсе – текст учебника JavaScript. +Правда, он в другом репозитарии [https://github.com/iliakan/javascript-tutorial](https://github.com/iliakan/javascript-tutorial), здесь только код. -Обсуждение происходит в чате Node.JS (Skype), собрание сегодня 24.06.2014 в 11:00 **GMT+2**. - -Если не можете войти - напишите мне в Skype, ник: "ilya.a.kantor". - -Code Style: - * https://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml - * `use strict` +Для установки dev-среды см. [INSTALL.md](https://github.com/iliakan/javascript-nodejs/blob/master/Install.md). +## ♡ + +Пишите в issues, если есть о чём. diff --git a/app.js b/app.js deleted file mode 100755 index 0c6865fac..000000000 --- a/app.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -const log = require('lib/log')(module); -const config = require('config'); - -const koa = require('koa'); - -require('models'); - -const app = koa(); - -require('setup/static')(app); - -require('setup/errors')(app); - -require('setup/logger')(app); -require('setup/bodyParser')(app); -require('setup/session')(app); -require('setup/render')(app); -require('setup/router')(app); - -require('./routes')(app); - -module.exports = app; \ No newline at end of file diff --git a/app/stylesheets/main.styl b/app/stylesheets/main.styl deleted file mode 100755 index 6eec76731..000000000 --- a/app/stylesheets/main.styl +++ /dev/null @@ -1,4 +0,0 @@ -/* :-) */ -h1 - font-style italic - diff --git a/assets/about/amax.jpg b/assets/about/amax.jpg new file mode 100755 index 000000000..f6ba0fc75 Binary files /dev/null and b/assets/about/amax.jpg differ diff --git a/assets/about/bezart.jpg b/assets/about/bezart.jpg new file mode 100755 index 000000000..3d629a077 Binary files /dev/null and b/assets/about/bezart.jpg differ diff --git a/assets/about/iliakan.jpg b/assets/about/iliakan.jpg new file mode 100755 index 000000000..fd52ed4c1 Binary files /dev/null and b/assets/about/iliakan.jpg differ diff --git a/assets/about/tyv.jpg b/assets/about/tyv.jpg new file mode 100755 index 000000000..5d8eba4e9 Binary files /dev/null and b/assets/about/tyv.jpg differ diff --git a/assets/babel-core/browser-polyfill.js b/assets/babel-core/browser-polyfill.js new file mode 100644 index 000000000..7773ac0a7 --- /dev/null +++ b/assets/babel-core/browser-polyfill.js @@ -0,0 +1,3207 @@ +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o Array#indexOf +// true -> Array#includes +var $ = require('./$'); +module.exports = function(IS_INCLUDES){ + return function(el /*, fromIndex = 0 */){ + var O = $.toObject(this) + , length = $.toLength(O.length) + , index = $.toIndex(arguments[1], length) + , value; + if(IS_INCLUDES && el != el)while(length > index){ + value = O[index++]; + if(value != value)return true; + } else for(;length > index; index++)if(IS_INCLUDES || index in O){ + if(O[index] === el)return IS_INCLUDES || index; + } return !IS_INCLUDES && -1; + }; +}; +},{"./$":21}],3:[function(require,module,exports){ +'use strict'; +// 0 -> Array#forEach +// 1 -> Array#map +// 2 -> Array#filter +// 3 -> Array#some +// 4 -> Array#every +// 5 -> Array#find +// 6 -> Array#findIndex +var $ = require('./$') + , ctx = require('./$.ctx'); +module.exports = function(TYPE){ + var IS_MAP = TYPE == 1 + , IS_FILTER = TYPE == 2 + , IS_SOME = TYPE == 3 + , IS_EVERY = TYPE == 4 + , IS_FIND_INDEX = TYPE == 6 + , NO_HOLES = TYPE == 5 || IS_FIND_INDEX; + return function(callbackfn/*, that = undefined */){ + var O = Object($.assertDefined(this)) + , self = $.ES5Object(O) + , f = ctx(callbackfn, arguments[1], 3) + , length = $.toLength(self.length) + , index = 0 + , result = IS_MAP ? Array(length) : IS_FILTER ? [] : undefined + , val, res; + for(;length > index; index++)if(NO_HOLES || index in self){ + val = self[index]; + res = f(val, index, O); + if(TYPE){ + if(IS_MAP)result[index] = res; // map + else if(res)switch(TYPE){ + case 3: return true; // some + case 5: return val; // find + case 6: return index; // findIndex + case 2: result.push(val); // filter + } else if(IS_EVERY)return false; // every + } + } + return IS_FIND_INDEX ? -1 : IS_SOME || IS_EVERY ? IS_EVERY : result; + }; +}; +},{"./$":21,"./$.ctx":11}],4:[function(require,module,exports){ +var $ = require('./$'); +function assert(condition, msg1, msg2){ + if(!condition)throw TypeError(msg2 ? msg1 + msg2 : msg1); +} +assert.def = $.assertDefined; +assert.fn = function(it){ + if(!$.isFunction(it))throw TypeError(it + ' is not a function!'); + return it; +}; +assert.obj = function(it){ + if(!$.isObject(it))throw TypeError(it + ' is not an object!'); + return it; +}; +assert.inst = function(it, Constructor, name){ + if(!(it instanceof Constructor))throw TypeError(name + ": use the 'new' operator!"); + return it; +}; +module.exports = assert; +},{"./$":21}],5:[function(require,module,exports){ +var $ = require('./$') + , enumKeys = require('./$.enum-keys'); +// 19.1.2.1 Object.assign(target, source, ...) +/* eslint-disable no-unused-vars */ +module.exports = Object.assign || function assign(target, source){ +/* eslint-enable no-unused-vars */ + var T = Object($.assertDefined(target)) + , l = arguments.length + , i = 1; + while(l > i){ + var S = $.ES5Object(arguments[i++]) + , keys = enumKeys(S) + , length = keys.length + , j = 0 + , key; + while(length > j)T[key = keys[j++]] = S[key]; + } + return T; +}; +},{"./$":21,"./$.enum-keys":13}],6:[function(require,module,exports){ +var $ = require('./$') + , TAG = require('./$.wks')('toStringTag') + , toString = {}.toString; +function cof(it){ + return toString.call(it).slice(8, -1); +} +cof.classof = function(it){ + var O, T; + return it == undefined ? it === undefined ? 'Undefined' : 'Null' + : typeof (T = (O = Object(it))[TAG]) == 'string' ? T : cof(O); +}; +cof.set = function(it, tag, stat){ + if(it && !$.has(it = stat ? it : it.prototype, TAG))$.hide(it, TAG, tag); +}; +module.exports = cof; +},{"./$":21,"./$.wks":32}],7:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , ctx = require('./$.ctx') + , safe = require('./$.uid').safe + , assert = require('./$.assert') + , forOf = require('./$.for-of') + , step = require('./$.iter').step + , has = $.has + , set = $.set + , isObject = $.isObject + , hide = $.hide + , isFrozen = Object.isFrozen || $.core.Object.isFrozen + , ID = safe('id') + , O1 = safe('O1') + , LAST = safe('last') + , FIRST = safe('first') + , ITER = safe('iter') + , SIZE = $.DESC ? safe('size') : 'size' + , id = 0; + +function fastKey(it, create){ + // return primitive with prefix + if(!isObject(it))return (typeof it == 'string' ? 'S' : 'P') + it; + // can't set id to frozen object + if(isFrozen(it))return 'F'; + if(!has(it, ID)){ + // not necessary to add id + if(!create)return 'E'; + // add missing object id + hide(it, ID, ++id); + // return object id with prefix + } return 'O' + it[ID]; +} + +function getEntry(that, key){ + // fast case + var index = fastKey(key), entry; + if(index != 'F')return that[O1][index]; + // frozen object case + for(entry = that[FIRST]; entry; entry = entry.n){ + if(entry.k == key)return entry; + } +} + +module.exports = { + getConstructor: function(NAME, IS_MAP, ADDER){ + function C(){ + var that = assert.inst(this, C, NAME) + , iterable = arguments[0]; + set(that, O1, $.create(null)); + set(that, SIZE, 0); + set(that, LAST, undefined); + set(that, FIRST, undefined); + if(iterable != undefined)forOf(iterable, IS_MAP, that[ADDER], that); + } + $.mix(C.prototype, { + // 23.1.3.1 Map.prototype.clear() + // 23.2.3.2 Set.prototype.clear() + clear: function clear(){ + for(var that = this, data = that[O1], entry = that[FIRST]; entry; entry = entry.n){ + entry.r = true; + if(entry.p)entry.p = entry.p.n = undefined; + delete data[entry.i]; + } + that[FIRST] = that[LAST] = undefined; + that[SIZE] = 0; + }, + // 23.1.3.3 Map.prototype.delete(key) + // 23.2.3.4 Set.prototype.delete(value) + 'delete': function(key){ + var that = this + , entry = getEntry(that, key); + if(entry){ + var next = entry.n + , prev = entry.p; + delete that[O1][entry.i]; + entry.r = true; + if(prev)prev.n = next; + if(next)next.p = prev; + if(that[FIRST] == entry)that[FIRST] = next; + if(that[LAST] == entry)that[LAST] = prev; + that[SIZE]--; + } return !!entry; + }, + // 23.2.3.6 Set.prototype.forEach(callbackfn, thisArg = undefined) + // 23.1.3.5 Map.prototype.forEach(callbackfn, thisArg = undefined) + forEach: function forEach(callbackfn /*, that = undefined */){ + var f = ctx(callbackfn, arguments[1], 3) + , entry; + while(entry = entry ? entry.n : this[FIRST]){ + f(entry.v, entry.k, this); + // revert to the last existing entry + while(entry && entry.r)entry = entry.p; + } + }, + // 23.1.3.7 Map.prototype.has(key) + // 23.2.3.7 Set.prototype.has(value) + has: function has(key){ + return !!getEntry(this, key); + } + }); + if($.DESC)$.setDesc(C.prototype, 'size', { + get: function(){ + return assert.def(this[SIZE]); + } + }); + return C; + }, + def: function(that, key, value){ + var entry = getEntry(that, key) + , prev, index; + // change existing entry + if(entry){ + entry.v = value; + // create new entry + } else { + that[LAST] = entry = { + i: index = fastKey(key, true), // <- index + k: key, // <- key + v: value, // <- value + p: prev = that[LAST], // <- previous entry + n: undefined, // <- next entry + r: false // <- removed + }; + if(!that[FIRST])that[FIRST] = entry; + if(prev)prev.n = entry; + that[SIZE]++; + // add to index + if(index != 'F')that[O1][index] = entry; + } return that; + }, + getEntry: getEntry, + // add .keys, .values, .entries, [@@iterator] + // 23.1.3.4, 23.1.3.8, 23.1.3.11, 23.1.3.12, 23.2.3.5, 23.2.3.8, 23.2.3.10, 23.2.3.11 + setIter: function(C, NAME, IS_MAP){ + require('./$.iter-define')(C, NAME, function(iterated, kind){ + set(this, ITER, {o: iterated, k: kind}); + }, function(){ + var iter = this[ITER] + , kind = iter.k + , entry = iter.l; + // revert to the last existing entry + while(entry && entry.r)entry = entry.p; + // get next entry + if(!iter.o || !(iter.l = entry = entry ? entry.n : iter.o[FIRST])){ + // or finish the iteration + iter.o = undefined; + return step(1); + } + // return step by kind + if(kind == 'keys' )return step(0, entry.k); + if(kind == 'values')return step(0, entry.v); + return step(0, [entry.k, entry.v]); + }, IS_MAP ? 'entries' : 'values' , !IS_MAP, true); + } +}; +},{"./$":21,"./$.assert":4,"./$.ctx":11,"./$.for-of":14,"./$.iter":20,"./$.iter-define":18,"./$.uid":30}],8:[function(require,module,exports){ +// https://github.com/DavidBruant/Map-Set.prototype.toJSON +var $def = require('./$.def') + , forOf = require('./$.for-of'); +module.exports = function(NAME){ + $def($def.P, NAME, { + toJSON: function toJSON(){ + var arr = []; + forOf(this, false, arr.push, arr); + return arr; + } + }); +}; +},{"./$.def":12,"./$.for-of":14}],9:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , safe = require('./$.uid').safe + , assert = require('./$.assert') + , forOf = require('./$.for-of') + , _has = $.has + , isObject = $.isObject + , hide = $.hide + , isFrozen = Object.isFrozen || $.core.Object.isFrozen + , id = 0 + , ID = safe('id') + , WEAK = safe('weak') + , LEAK = safe('leak') + , method = require('./$.array-methods') + , find = method(5) + , findIndex = method(6); +function findFrozen(store, key){ + return find.call(store.array, function(it){ + return it[0] === key; + }); +} +// fallback for frozen keys +function leakStore(that){ + return that[LEAK] || hide(that, LEAK, { + array: [], + get: function(key){ + var entry = findFrozen(this, key); + if(entry)return entry[1]; + }, + has: function(key){ + return !!findFrozen(this, key); + }, + set: function(key, value){ + var entry = findFrozen(this, key); + if(entry)entry[1] = value; + else this.array.push([key, value]); + }, + 'delete': function(key){ + var index = findIndex.call(this.array, function(it){ + return it[0] === key; + }); + if(~index)this.array.splice(index, 1); + return !!~index; + } + })[LEAK]; +} + +module.exports = { + getConstructor: function(NAME, IS_MAP, ADDER){ + function C(){ + $.set(assert.inst(this, C, NAME), ID, id++); + var iterable = arguments[0]; + if(iterable != undefined)forOf(iterable, IS_MAP, this[ADDER], this); + } + $.mix(C.prototype, { + // 23.3.3.2 WeakMap.prototype.delete(key) + // 23.4.3.3 WeakSet.prototype.delete(value) + 'delete': function(key){ + if(!isObject(key))return false; + if(isFrozen(key))return leakStore(this)['delete'](key); + return _has(key, WEAK) && _has(key[WEAK], this[ID]) && delete key[WEAK][this[ID]]; + }, + // 23.3.3.4 WeakMap.prototype.has(key) + // 23.4.3.4 WeakSet.prototype.has(value) + has: function has(key){ + if(!isObject(key))return false; + if(isFrozen(key))return leakStore(this).has(key); + return _has(key, WEAK) && _has(key[WEAK], this[ID]); + } + }); + return C; + }, + def: function(that, key, value){ + if(isFrozen(assert.obj(key))){ + leakStore(that).set(key, value); + } else { + _has(key, WEAK) || hide(key, WEAK, {}); + key[WEAK][that[ID]] = value; + } return that; + }, + leakStore: leakStore, + WEAK: WEAK, + ID: ID +}; +},{"./$":21,"./$.array-methods":3,"./$.assert":4,"./$.for-of":14,"./$.uid":30}],10:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , $def = require('./$.def') + , BUGGY = require('./$.iter').BUGGY + , forOf = require('./$.for-of') + , species = require('./$.species') + , assertInstance = require('./$.assert').inst; + +module.exports = function(NAME, methods, common, IS_MAP, IS_WEAK){ + var Base = $.g[NAME] + , C = Base + , ADDER = IS_MAP ? 'set' : 'add' + , proto = C && C.prototype + , O = {}; + function fixMethod(KEY, CHAIN){ + var method = proto[KEY]; + if($.FW)proto[KEY] = function(a, b){ + var result = method.call(this, a === 0 ? 0 : a, b); + return CHAIN ? this : result; + }; + } + if(!$.isFunction(C) || !(IS_WEAK || !BUGGY && proto.forEach && proto.entries)){ + // create collection constructor + C = common.getConstructor(NAME, IS_MAP, ADDER); + $.mix(C.prototype, methods); + } else { + var inst = new C + , chain = inst[ADDER](IS_WEAK ? {} : -0, 1) + , buggyZero; + // wrap for init collections from iterable + if(!require('./$.iter-detect')(function(iter){ new C(iter); })){ // eslint-disable-line no-new + C = function(){ + assertInstance(this, C, NAME); + var that = new Base + , iterable = arguments[0]; + if(iterable != undefined)forOf(iterable, IS_MAP, that[ADDER], that); + return that; + }; + C.prototype = proto; + if($.FW)proto.constructor = C; + } + IS_WEAK || inst.forEach(function(val, key){ + buggyZero = 1 / key === -Infinity; + }); + // fix converting -0 key to +0 + if(buggyZero){ + fixMethod('delete'); + fixMethod('has'); + IS_MAP && fixMethod('get'); + } + // + fix .add & .set for chaining + if(buggyZero || chain !== inst)fixMethod(ADDER, true); + } + + require('./$.cof').set(C, NAME); + + O[NAME] = C; + $def($def.G + $def.W + $def.F * (C != Base), O); + species(C); + species($.core[NAME]); // for wrapper + + if(!IS_WEAK)common.setIter(C, NAME, IS_MAP); + + return C; +}; +},{"./$":21,"./$.assert":4,"./$.cof":6,"./$.def":12,"./$.for-of":14,"./$.iter":20,"./$.iter-detect":19,"./$.species":27}],11:[function(require,module,exports){ +// Optional / simple context binding +var assertFunction = require('./$.assert').fn; +module.exports = function(fn, that, length){ + assertFunction(fn); + if(~length && that === undefined)return fn; + switch(length){ + case 1: return function(a){ + return fn.call(that, a); + }; + case 2: return function(a, b){ + return fn.call(that, a, b); + }; + case 3: return function(a, b, c){ + return fn.call(that, a, b, c); + }; + } return function(/* ...args */){ + return fn.apply(that, arguments); + }; +}; +},{"./$.assert":4}],12:[function(require,module,exports){ +var $ = require('./$') + , global = $.g + , core = $.core + , isFunction = $.isFunction; +function ctx(fn, that){ + return function(){ + return fn.apply(that, arguments); + }; +} +global.core = core; +// type bitmap +$def.F = 1; // forced +$def.G = 2; // global +$def.S = 4; // static +$def.P = 8; // proto +$def.B = 16; // bind +$def.W = 32; // wrap +function $def(type, name, source){ + var key, own, out, exp + , isGlobal = type & $def.G + , target = isGlobal ? global : type & $def.S + ? global[name] : (global[name] || {}).prototype + , exports = isGlobal ? core : core[name] || (core[name] = {}); + if(isGlobal)source = name; + for(key in source){ + // contains in native + own = !(type & $def.F) && target && key in target; + // export native or passed + out = (own ? target : source)[key]; + // bind timers to global for call from export context + if(type & $def.B && own)exp = ctx(out, global); + else exp = type & $def.P && isFunction(out) ? ctx(Function.call, out) : out; + // extend global + if(target && !own){ + if(isGlobal)target[key] = out; + else delete target[key] && $.hide(target, key, out); + } + // export + if(exports[key] != out)$.hide(exports, key, exp); + } +} +module.exports = $def; +},{"./$":21}],13:[function(require,module,exports){ +var $ = require('./$'); +module.exports = function(it){ + var keys = $.getKeys(it) + , getDesc = $.getDesc + , getSymbols = $.getSymbols; + if(getSymbols)$.each.call(getSymbols(it), function(key){ + if(getDesc(it, key).enumerable)keys.push(key); + }); + return keys; +}; +},{"./$":21}],14:[function(require,module,exports){ +var ctx = require('./$.ctx') + , get = require('./$.iter').get + , call = require('./$.iter-call'); +module.exports = function(iterable, entries, fn, that){ + var iterator = get(iterable) + , f = ctx(fn, that, entries ? 2 : 1) + , step; + while(!(step = iterator.next()).done){ + if(call(iterator, f, step.value, entries) === false){ + return call.close(iterator); + } + } +}; +},{"./$.ctx":11,"./$.iter":20,"./$.iter-call":17}],15:[function(require,module,exports){ +module.exports = function($){ + $.FW = true; + $.path = $.g; + return $; +}; +},{}],16:[function(require,module,exports){ +// Fast apply +// http://jsperf.lnkit.com/fast-apply/5 +module.exports = function(fn, args, that){ + var un = that === undefined; + switch(args.length){ + case 0: return un ? fn() + : fn.call(that); + case 1: return un ? fn(args[0]) + : fn.call(that, args[0]); + case 2: return un ? fn(args[0], args[1]) + : fn.call(that, args[0], args[1]); + case 3: return un ? fn(args[0], args[1], args[2]) + : fn.call(that, args[0], args[1], args[2]); + case 4: return un ? fn(args[0], args[1], args[2], args[3]) + : fn.call(that, args[0], args[1], args[2], args[3]); + case 5: return un ? fn(args[0], args[1], args[2], args[3], args[4]) + : fn.call(that, args[0], args[1], args[2], args[3], args[4]); + } return fn.apply(that, args); +}; +},{}],17:[function(require,module,exports){ +var assertObject = require('./$.assert').obj; +function close(iterator){ + var ret = iterator['return']; + if(ret !== undefined)assertObject(ret.call(iterator)); +} +function call(iterator, fn, value, entries){ + try { + return entries ? fn(assertObject(value)[0], value[1]) : fn(value); + } catch(e){ + close(iterator); + throw e; + } +} +call.close = close; +module.exports = call; +},{"./$.assert":4}],18:[function(require,module,exports){ +var $def = require('./$.def') + , $ = require('./$') + , cof = require('./$.cof') + , $iter = require('./$.iter') + , SYMBOL_ITERATOR = require('./$.wks')('iterator') + , FF_ITERATOR = '@@iterator' + , VALUES = 'values' + , Iterators = $iter.Iterators; +module.exports = function(Base, NAME, Constructor, next, DEFAULT, IS_SET, FORCE){ + $iter.create(Constructor, NAME, next); + function createMethod(kind){ + return function(){ + return new Constructor(this, kind); + }; + } + var TAG = NAME + ' Iterator' + , proto = Base.prototype + , _native = proto[SYMBOL_ITERATOR] || proto[FF_ITERATOR] || DEFAULT && proto[DEFAULT] + , _default = _native || createMethod(DEFAULT) + , methods, key; + // Fix native + if(_native){ + var IteratorPrototype = $.getProto(_default.call(new Base)); + // Set @@toStringTag to native iterators + cof.set(IteratorPrototype, TAG, true); + // FF fix + if($.FW && $.has(proto, FF_ITERATOR))$iter.set(IteratorPrototype, $.that); + } + // Define iterator + if($.FW)$iter.set(proto, _default); + // Plug for library + Iterators[NAME] = _default; + Iterators[TAG] = $.that; + if(DEFAULT){ + methods = { + keys: IS_SET ? _default : createMethod('keys'), + values: DEFAULT == VALUES ? _default : createMethod(VALUES), + entries: DEFAULT != VALUES ? _default : createMethod('entries') + }; + if(FORCE)for(key in methods){ + if(!(key in proto))$.hide(proto, key, methods[key]); + } else $def($def.P + $def.F * $iter.BUGGY, NAME, methods); + } +}; +},{"./$":21,"./$.cof":6,"./$.def":12,"./$.iter":20,"./$.wks":32}],19:[function(require,module,exports){ +var SYMBOL_ITERATOR = require('./$.wks')('iterator') + , SAFE_CLOSING = false; +try { + var riter = [7][SYMBOL_ITERATOR](); + riter['return'] = function(){ SAFE_CLOSING = true; }; + Array.from(riter, function(){ throw 2; }); +} catch(e){ /* empty */ } +module.exports = function(exec){ + if(!SAFE_CLOSING)return false; + var safe = false; + try { + var arr = [7] + , iter = arr[SYMBOL_ITERATOR](); + iter.next = function(){ safe = true; }; + arr[SYMBOL_ITERATOR] = function(){ return iter; }; + exec(arr); + } catch(e){ /* empty */ } + return safe; +}; +},{"./$.wks":32}],20:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , cof = require('./$.cof') + , assertObject = require('./$.assert').obj + , SYMBOL_ITERATOR = require('./$.wks')('iterator') + , FF_ITERATOR = '@@iterator' + , Iterators = {} + , IteratorPrototype = {}; +// 25.1.2.1.1 %IteratorPrototype%[@@iterator]() +setIterator(IteratorPrototype, $.that); +function setIterator(O, value){ + $.hide(O, SYMBOL_ITERATOR, value); + // Add iterator for FF iterator protocol + if(FF_ITERATOR in [])$.hide(O, FF_ITERATOR, value); +} + +module.exports = { + // Safari has buggy iterators w/o `next` + BUGGY: 'keys' in [] && !('next' in [].keys()), + Iterators: Iterators, + step: function(done, value){ + return {value: value, done: !!done}; + }, + is: function(it){ + var O = Object(it) + , Symbol = $.g.Symbol + , SYM = Symbol && Symbol.iterator || FF_ITERATOR; + return SYM in O || SYMBOL_ITERATOR in O || $.has(Iterators, cof.classof(O)); + }, + get: function(it){ + var Symbol = $.g.Symbol + , ext = it[Symbol && Symbol.iterator || FF_ITERATOR] + , getIter = ext || it[SYMBOL_ITERATOR] || Iterators[cof.classof(it)]; + return assertObject(getIter.call(it)); + }, + set: setIterator, + create: function(Constructor, NAME, next, proto){ + Constructor.prototype = $.create(proto || IteratorPrototype, {next: $.desc(1, next)}); + cof.set(Constructor, NAME + ' Iterator'); + } +}; +},{"./$":21,"./$.assert":4,"./$.cof":6,"./$.wks":32}],21:[function(require,module,exports){ +'use strict'; +var global = typeof self != 'undefined' ? self : Function('return this')() + , core = {} + , defineProperty = Object.defineProperty + , hasOwnProperty = {}.hasOwnProperty + , ceil = Math.ceil + , floor = Math.floor + , max = Math.max + , min = Math.min; +// The engine works fine with descriptors? Thank's IE8 for his funny defineProperty. +var DESC = !!function(){ + try { + return defineProperty({}, 'a', {get: function(){ return 2; }}).a == 2; + } catch(e){ /* empty */ } +}(); +var hide = createDefiner(1); +// 7.1.4 ToInteger +function toInteger(it){ + return isNaN(it = +it) ? 0 : (it > 0 ? floor : ceil)(it); +} +function desc(bitmap, value){ + return { + enumerable : !(bitmap & 1), + configurable: !(bitmap & 2), + writable : !(bitmap & 4), + value : value + }; +} +function simpleSet(object, key, value){ + object[key] = value; + return object; +} +function createDefiner(bitmap){ + return DESC ? function(object, key, value){ + return $.setDesc(object, key, desc(bitmap, value)); + } : simpleSet; +} + +function isObject(it){ + return it !== null && (typeof it == 'object' || typeof it == 'function'); +} +function isFunction(it){ + return typeof it == 'function'; +} +function assertDefined(it){ + if(it == undefined)throw TypeError("Can't call method on " + it); + return it; +} + +var $ = module.exports = require('./$.fw')({ + g: global, + core: core, + html: global.document && document.documentElement, + // http://jsperf.com/core-js-isobject + isObject: isObject, + isFunction: isFunction, + it: function(it){ + return it; + }, + that: function(){ + return this; + }, + // 7.1.4 ToInteger + toInteger: toInteger, + // 7.1.15 ToLength + toLength: function(it){ + return it > 0 ? min(toInteger(it), 0x1fffffffffffff) : 0; // pow(2, 53) - 1 == 9007199254740991 + }, + toIndex: function(index, length){ + index = toInteger(index); + return index < 0 ? max(index + length, 0) : min(index, length); + }, + has: function(it, key){ + return hasOwnProperty.call(it, key); + }, + create: Object.create, + getProto: Object.getPrototypeOf, + DESC: DESC, + desc: desc, + getDesc: Object.getOwnPropertyDescriptor, + setDesc: defineProperty, + setDescs: Object.defineProperties, + getKeys: Object.keys, + getNames: Object.getOwnPropertyNames, + getSymbols: Object.getOwnPropertySymbols, + assertDefined: assertDefined, + // Dummy, fix for not array-like ES3 string in es5 module + ES5Object: Object, + toObject: function(it){ + return $.ES5Object(assertDefined(it)); + }, + hide: hide, + def: createDefiner(0), + set: global.Symbol ? simpleSet : hide, + mix: function(target, src){ + for(var key in src)hide(target, key, src[key]); + return target; + }, + each: [].forEach +}); +/* eslint-disable no-undef */ +if(typeof __e != 'undefined')__e = core; +if(typeof __g != 'undefined')__g = global; +},{"./$.fw":15}],22:[function(require,module,exports){ +var $ = require('./$'); +module.exports = function(object, el){ + var O = $.toObject(object) + , keys = $.getKeys(O) + , length = keys.length + , index = 0 + , key; + while(length > index)if(O[key = keys[index++]] === el)return key; +}; +},{"./$":21}],23:[function(require,module,exports){ +var $ = require('./$') + , assertObject = require('./$.assert').obj; +module.exports = function ownKeys(it){ + assertObject(it); + var keys = $.getNames(it) + , getSymbols = $.getSymbols; + return getSymbols ? keys.concat(getSymbols(it)) : keys; +}; +},{"./$":21,"./$.assert":4}],24:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , invoke = require('./$.invoke') + , assertFunction = require('./$.assert').fn; +module.exports = function(/* ...pargs */){ + var fn = assertFunction(this) + , length = arguments.length + , pargs = Array(length) + , i = 0 + , _ = $.path._ + , holder = false; + while(length > i)if((pargs[i] = arguments[i++]) === _)holder = true; + return function(/* ...args */){ + var that = this + , _length = arguments.length + , j = 0, k = 0, args; + if(!holder && !_length)return invoke(fn, pargs, that); + args = pargs.slice(); + if(holder)for(;length > j; j++)if(args[j] === _)args[j] = arguments[k++]; + while(_length > k)args.push(arguments[k++]); + return invoke(fn, args, that); + }; +}; +},{"./$":21,"./$.assert":4,"./$.invoke":16}],25:[function(require,module,exports){ +'use strict'; +module.exports = function(regExp, replace, isStatic){ + var replacer = replace === Object(replace) ? function(part){ + return replace[part]; + } : replace; + return function(it){ + return String(isStatic ? it : this).replace(regExp, replacer); + }; +}; +},{}],26:[function(require,module,exports){ +// Works with __proto__ only. Old v8 can't work with null proto objects. +/* eslint-disable no-proto */ +var $ = require('./$') + , assert = require('./$.assert'); +function check(O, proto){ + assert.obj(O); + assert(proto === null || $.isObject(proto), proto, ": can't set as prototype!"); +} +module.exports = { + set: Object.setPrototypeOf || ('__proto__' in {} // eslint-disable-line + ? function(buggy, set){ + try { + set = require('./$.ctx')(Function.call, $.getDesc(Object.prototype, '__proto__').set, 2); + set({}, []); + } catch(e){ buggy = true; } + return function setPrototypeOf(O, proto){ + check(O, proto); + if(buggy)O.__proto__ = proto; + else set(O, proto); + return O; + }; + }() + : undefined), + check: check +}; +},{"./$":21,"./$.assert":4,"./$.ctx":11}],27:[function(require,module,exports){ +var $ = require('./$') + , SPECIES = require('./$.wks')('species'); +module.exports = function(C){ + if($.DESC && !(SPECIES in C))$.setDesc(C, SPECIES, { + configurable: true, + get: $.that + }); +}; +},{"./$":21,"./$.wks":32}],28:[function(require,module,exports){ +'use strict'; +// true -> String#at +// false -> String#codePointAt +var $ = require('./$'); +module.exports = function(TO_STRING){ + return function(pos){ + var s = String($.assertDefined(this)) + , i = $.toInteger(pos) + , l = s.length + , a, b; + if(i < 0 || i >= l)return TO_STRING ? '' : undefined; + a = s.charCodeAt(i); + return a < 0xd800 || a > 0xdbff || i + 1 === l + || (b = s.charCodeAt(i + 1)) < 0xdc00 || b > 0xdfff + ? TO_STRING ? s.charAt(i) : a + : TO_STRING ? s.slice(i, i + 2) : (a - 0xd800 << 10) + (b - 0xdc00) + 0x10000; + }; +}; +},{"./$":21}],29:[function(require,module,exports){ +'use strict'; +var $ = require('./$') + , ctx = require('./$.ctx') + , cof = require('./$.cof') + , invoke = require('./$.invoke') + , global = $.g + , isFunction = $.isFunction + , html = $.html + , document = global.document + , process = global.process + , setTask = global.setImmediate + , clearTask = global.clearImmediate + , postMessage = global.postMessage + , addEventListener = global.addEventListener + , MessageChannel = global.MessageChannel + , counter = 0 + , queue = {} + , ONREADYSTATECHANGE = 'onreadystatechange' + , defer, channel, port; +function run(){ + var id = +this; + if($.has(queue, id)){ + var fn = queue[id]; + delete queue[id]; + fn(); + } +} +function listner(event){ + run.call(event.data); +} +// Node.js 0.9+ & IE10+ has setImmediate, otherwise: +if(!isFunction(setTask) || !isFunction(clearTask)){ + setTask = function(fn){ + var args = [], i = 1; + while(arguments.length > i)args.push(arguments[i++]); + queue[++counter] = function(){ + invoke(isFunction(fn) ? fn : Function(fn), args); + }; + defer(counter); + return counter; + }; + clearTask = function(id){ + delete queue[id]; + }; + // Node.js 0.8- + if(cof(process) == 'process'){ + defer = function(id){ + process.nextTick(ctx(run, id, 1)); + }; + // Modern browsers, skip implementation for WebWorkers + // IE8 has postMessage, but it's sync & typeof its postMessage is object + } else if(addEventListener && isFunction(postMessage) && !global.importScripts){ + defer = function(id){ + postMessage(id, '*'); + }; + addEventListener('message', listner, false); + // WebWorkers + } else if(isFunction(MessageChannel)){ + channel = new MessageChannel; + port = channel.port2; + channel.port1.onmessage = listner; + defer = ctx(port.postMessage, port, 1); + // IE8- + } else if(document && ONREADYSTATECHANGE in document.createElement('script')){ + defer = function(id){ + html.appendChild(document.createElement('script'))[ONREADYSTATECHANGE] = function(){ + html.removeChild(this); + run.call(id); + }; + }; + // Rest old browsers + } else { + defer = function(id){ + setTimeout(ctx(run, id, 1), 0); + }; + } +} +module.exports = { + set: setTask, + clear: clearTask +}; +},{"./$":21,"./$.cof":6,"./$.ctx":11,"./$.invoke":16}],30:[function(require,module,exports){ +var sid = 0; +function uid(key){ + return 'Symbol(' + key + ')_' + (++sid + Math.random()).toString(36); +} +uid.safe = require('./$').g.Symbol || uid; +module.exports = uid; +},{"./$":21}],31:[function(require,module,exports){ +// 22.1.3.31 Array.prototype[@@unscopables] +var $ = require('./$') + , UNSCOPABLES = require('./$.wks')('unscopables'); +if($.FW && !(UNSCOPABLES in []))$.hide(Array.prototype, UNSCOPABLES, {}); +module.exports = function(key){ + if($.FW)[][UNSCOPABLES][key] = true; +}; +},{"./$":21,"./$.wks":32}],32:[function(require,module,exports){ +var global = require('./$').g + , store = {}; +module.exports = function(name){ + return store[name] || (store[name] = + global.Symbol && global.Symbol[name] || require('./$.uid').safe('Symbol.' + name)); +}; +},{"./$":21,"./$.uid":30}],33:[function(require,module,exports){ +var $ = require('./$') + , cof = require('./$.cof') + , $def = require('./$.def') + , invoke = require('./$.invoke') + , arrayMethod = require('./$.array-methods') + , IE_PROTO = require('./$.uid').safe('__proto__') + , assert = require('./$.assert') + , assertObject = assert.obj + , ObjectProto = Object.prototype + , A = [] + , slice = A.slice + , indexOf = A.indexOf + , classof = cof.classof + , has = $.has + , defineProperty = $.setDesc + , getOwnDescriptor = $.getDesc + , defineProperties = $.setDescs + , isFunction = $.isFunction + , toObject = $.toObject + , toLength = $.toLength + , IE8_DOM_DEFINE = false; + +if(!$.DESC){ + try { + IE8_DOM_DEFINE = defineProperty(document.createElement('div'), 'x', + {get: function(){ return 8; }} + ).x == 8; + } catch(e){ /* empty */ } + $.setDesc = function(O, P, Attributes){ + if(IE8_DOM_DEFINE)try { + return defineProperty(O, P, Attributes); + } catch(e){ /* empty */ } + if('get' in Attributes || 'set' in Attributes)throw TypeError('Accessors not supported!'); + if('value' in Attributes)assertObject(O)[P] = Attributes.value; + return O; + }; + $.getDesc = function(O, P){ + if(IE8_DOM_DEFINE)try { + return getOwnDescriptor(O, P); + } catch(e){ /* empty */ } + if(has(O, P))return $.desc(!ObjectProto.propertyIsEnumerable.call(O, P), O[P]); + }; + $.setDescs = defineProperties = function(O, Properties){ + assertObject(O); + var keys = $.getKeys(Properties) + , length = keys.length + , i = 0 + , P; + while(length > i)$.setDesc(O, P = keys[i++], Properties[P]); + return O; + }; +} +$def($def.S + $def.F * !$.DESC, 'Object', { + // 19.1.2.6 / 15.2.3.3 Object.getOwnPropertyDescriptor(O, P) + getOwnPropertyDescriptor: $.getDesc, + // 19.1.2.4 / 15.2.3.6 Object.defineProperty(O, P, Attributes) + defineProperty: $.setDesc, + // 19.1.2.3 / 15.2.3.7 Object.defineProperties(O, Properties) + defineProperties: defineProperties +}); + + // IE 8- don't enum bug keys +var keys1 = ('constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,' + + 'toLocaleString,toString,valueOf').split(',') + // Additional keys for getOwnPropertyNames + , keys2 = keys1.concat('length', 'prototype') + , keysLen1 = keys1.length; + +// Create object with `null` prototype: use iframe Object with cleared prototype +var createDict = function(){ + // Thrash, waste and sodomy: IE GC bug + var iframe = document.createElement('iframe') + , i = keysLen1 + , gt = '>' + , iframeDocument; + iframe.style.display = 'none'; + $.html.appendChild(iframe); + iframe.src = 'javascript:'; // eslint-disable-line no-script-url + // createDict = iframe.contentWindow.Object; + // html.removeChild(iframe); + iframeDocument = iframe.contentWindow.document; + iframeDocument.open(); + iframeDocument.write(' + + + diff --git a/assets/drag-heroes/ball.png b/assets/drag-heroes/ball.png new file mode 100755 index 000000000..834bea209 Binary files /dev/null and b/assets/drag-heroes/ball.png differ diff --git a/assets/drag-heroes/field.png b/assets/drag-heroes/field.png new file mode 100755 index 000000000..ebabc14af Binary files /dev/null and b/assets/drag-heroes/field.png differ diff --git a/assets/drag-heroes/heroes.png b/assets/drag-heroes/heroes.png new file mode 100755 index 000000000..9566b5017 Binary files /dev/null and b/assets/drag-heroes/heroes.png differ diff --git a/assets/drag-heroes/soccer_ball.png b/assets/drag-heroes/soccer_ball.png new file mode 100755 index 000000000..4aa55877a Binary files /dev/null and b/assets/drag-heroes/soccer_ball.png differ diff --git a/assets/gallery/img1-lg.jpg b/assets/gallery/img1-lg.jpg new file mode 100755 index 000000000..b0719d5a8 Binary files /dev/null and b/assets/gallery/img1-lg.jpg differ diff --git a/assets/gallery/img2-lg.jpg b/assets/gallery/img2-lg.jpg new file mode 100755 index 000000000..08df6db2e Binary files /dev/null and b/assets/gallery/img2-lg.jpg differ diff --git a/assets/gallery/img2-thumb.jpg b/assets/gallery/img2-thumb.jpg new file mode 100755 index 000000000..a388d3bfb Binary files /dev/null and b/assets/gallery/img2-thumb.jpg differ diff --git a/assets/gallery/img3-lg.jpg b/assets/gallery/img3-lg.jpg new file mode 100755 index 000000000..a08bf36eb Binary files /dev/null and b/assets/gallery/img3-lg.jpg differ diff --git a/assets/gallery/img3-thumb.jpg b/assets/gallery/img3-thumb.jpg new file mode 100755 index 000000000..744b89a4f Binary files /dev/null and b/assets/gallery/img3-thumb.jpg differ diff --git a/assets/gallery/img4-lg.jpg b/assets/gallery/img4-lg.jpg new file mode 100755 index 000000000..1a16b4e10 Binary files /dev/null and b/assets/gallery/img4-lg.jpg differ diff --git a/assets/gallery/img4-thumb.jpg b/assets/gallery/img4-thumb.jpg new file mode 100755 index 000000000..f24fa2497 Binary files /dev/null and b/assets/gallery/img4-thumb.jpg differ diff --git a/assets/gallery/img5-lg.jpg b/assets/gallery/img5-lg.jpg new file mode 100755 index 000000000..945e39805 Binary files /dev/null and b/assets/gallery/img5-lg.jpg differ diff --git a/assets/gallery/img5-thumb.jpg b/assets/gallery/img5-thumb.jpg new file mode 100755 index 000000000..b6704ef92 Binary files /dev/null and b/assets/gallery/img5-thumb.jpg differ diff --git a/assets/gallery/img6-lg.jpg b/assets/gallery/img6-lg.jpg new file mode 100755 index 000000000..3213ab5bd Binary files /dev/null and b/assets/gallery/img6-lg.jpg differ diff --git a/assets/gallery/img6-thumb.jpg b/assets/gallery/img6-thumb.jpg new file mode 100755 index 000000000..c1ee1d565 Binary files /dev/null and b/assets/gallery/img6-thumb.jpg differ diff --git a/assets/height-percent/arrow_left.png b/assets/height-percent/arrow_left.png new file mode 100755 index 000000000..3399acc6b Binary files /dev/null and b/assets/height-percent/arrow_left.png differ diff --git a/assets/hello/ads.js b/assets/hello/ads.js new file mode 100755 index 000000000..bddc4f5ec --- /dev/null +++ b/assets/hello/ads.js @@ -0,0 +1 @@ +alert("Реклама загружена!"); diff --git a/assets/images-load/1.jpg b/assets/images-load/1.jpg new file mode 100755 index 000000000..f0b9ab25b Binary files /dev/null and b/assets/images-load/1.jpg differ diff --git a/assets/images-load/2.jpg b/assets/images-load/2.jpg new file mode 100755 index 000000000..1f2fb3f6d Binary files /dev/null and b/assets/images-load/2.jpg differ diff --git a/assets/images-load/3.jpg b/assets/images-load/3.jpg new file mode 100755 index 000000000..100530c91 Binary files /dev/null and b/assets/images-load/3.jpg differ diff --git a/assets/img/ball.gif b/assets/img/ball.gif new file mode 100755 index 000000000..0b2c177f1 Binary files /dev/null and b/assets/img/ball.gif differ diff --git a/assets/img/close-button.png b/assets/img/close-button.png new file mode 100755 index 000000000..591dafb8f Binary files /dev/null and b/assets/img/close-button.png differ diff --git a/assets/img/courses/andrewsumin.jpg b/assets/img/courses/andrewsumin.jpg new file mode 100644 index 000000000..3e4b1697f Binary files /dev/null and b/assets/img/courses/andrewsumin.jpg differ diff --git a/assets/img/courses/dmitryx.jpg b/assets/img/courses/dmitryx.jpg new file mode 100644 index 000000000..dfd706f43 Binary files /dev/null and b/assets/img/courses/dmitryx.jpg differ diff --git a/assets/img/courses/tyv.jpg b/assets/img/courses/tyv.jpg new file mode 100644 index 000000000..5d8eba4e9 Binary files /dev/null and b/assets/img/courses/tyv.jpg differ diff --git a/assets/img/email__logo.png b/assets/img/email__logo.png new file mode 100755 index 000000000..e5f8a0695 Binary files /dev/null and b/assets/img/email__logo.png differ diff --git a/assets/img/favicon/apple-touch-icon-precomposed.png b/assets/img/favicon/apple-touch-icon-precomposed.png new file mode 100755 index 000000000..8139bf08c Binary files /dev/null and b/assets/img/favicon/apple-touch-icon-precomposed.png differ diff --git a/assets/img/favicon/favicon.ico b/assets/img/favicon/favicon.ico new file mode 100755 index 000000000..283184a19 Binary files /dev/null and b/assets/img/favicon/favicon.ico differ diff --git a/assets/img/favicon/favicon.png b/assets/img/favicon/favicon.png new file mode 100755 index 000000000..a664829d9 Binary files /dev/null and b/assets/img/favicon/favicon.png differ diff --git a/assets/img/favicon/tileicon.png b/assets/img/favicon/tileicon.png new file mode 100755 index 000000000..bb4cfb4f3 Binary files /dev/null and b/assets/img/favicon/tileicon.png differ diff --git a/assets/img/flags/ad.svg b/assets/img/flags/ad.svg new file mode 100644 index 000000000..9190d9ea1 --- /dev/null +++ b/assets/img/flags/ad.svg @@ -0,0 +1,152 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ae.svg b/assets/img/flags/ae.svg new file mode 100644 index 000000000..e634982d8 --- /dev/null +++ b/assets/img/flags/ae.svg @@ -0,0 +1,44 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/af.svg b/assets/img/flags/af.svg new file mode 100644 index 000000000..937edcae3 --- /dev/null +++ b/assets/img/flags/af.svg @@ -0,0 +1,779 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ag.svg b/assets/img/flags/ag.svg new file mode 100644 index 000000000..c7450f055 --- /dev/null +++ b/assets/img/flags/ag.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ai.svg b/assets/img/flags/ai.svg new file mode 100644 index 000000000..69c5041b3 --- /dev/null +++ b/assets/img/flags/ai.svg @@ -0,0 +1,791 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/al.svg b/assets/img/flags/al.svg new file mode 100644 index 000000000..e9709ccb1 --- /dev/null +++ b/assets/img/flags/al.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/am.svg b/assets/img/flags/am.svg new file mode 100644 index 000000000..03054df0d --- /dev/null +++ b/assets/img/flags/am.svg @@ -0,0 +1,16 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ao.svg b/assets/img/flags/ao.svg new file mode 100644 index 000000000..2e5ca479e --- /dev/null +++ b/assets/img/flags/ao.svg @@ -0,0 +1,39 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/aq.svg b/assets/img/flags/aq.svg new file mode 100644 index 000000000..40dec4751 --- /dev/null +++ b/assets/img/flags/aq.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/ar.svg b/assets/img/flags/ar.svg new file mode 100644 index 000000000..c36b65a8b --- /dev/null +++ b/assets/img/flags/ar.svg @@ -0,0 +1,160 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/as.svg b/assets/img/flags/as.svg new file mode 100644 index 000000000..b3585f3af --- /dev/null +++ b/assets/img/flags/as.svg @@ -0,0 +1,120 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/at.svg b/assets/img/flags/at.svg new file mode 100644 index 000000000..388a6b8b9 --- /dev/null +++ b/assets/img/flags/at.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/au.svg b/assets/img/flags/au.svg new file mode 100644 index 000000000..c064c7475 --- /dev/null +++ b/assets/img/flags/au.svg @@ -0,0 +1,31 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/aw.svg b/assets/img/flags/aw.svg new file mode 100644 index 000000000..ac8440a83 --- /dev/null +++ b/assets/img/flags/aw.svg @@ -0,0 +1,210 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ax.svg b/assets/img/flags/ax.svg new file mode 100644 index 000000000..fd17f0bb8 --- /dev/null +++ b/assets/img/flags/ax.svg @@ -0,0 +1,27 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/az.svg b/assets/img/flags/az.svg new file mode 100644 index 000000000..d77a2f53a --- /dev/null +++ b/assets/img/flags/az.svg @@ -0,0 +1,49 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ba.svg b/assets/img/flags/ba.svg new file mode 100644 index 000000000..4336c07db --- /dev/null +++ b/assets/img/flags/ba.svg @@ -0,0 +1,53 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bb.svg b/assets/img/flags/bb.svg new file mode 100644 index 000000000..2bf861da5 --- /dev/null +++ b/assets/img/flags/bb.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bd.svg b/assets/img/flags/bd.svg new file mode 100644 index 000000000..4b9d0c022 --- /dev/null +++ b/assets/img/flags/bd.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/img/flags/be.svg b/assets/img/flags/be.svg new file mode 100644 index 000000000..891501cbd --- /dev/null +++ b/assets/img/flags/be.svg @@ -0,0 +1,40 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/bf.svg b/assets/img/flags/bf.svg new file mode 100644 index 000000000..6d354c608 --- /dev/null +++ b/assets/img/flags/bf.svg @@ -0,0 +1,19 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/bg.svg b/assets/img/flags/bg.svg new file mode 100644 index 000000000..2cf07bfc9 --- /dev/null +++ b/assets/img/flags/bg.svg @@ -0,0 +1,19 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/bh.svg b/assets/img/flags/bh.svg new file mode 100644 index 000000000..a73eb8dde --- /dev/null +++ b/assets/img/flags/bh.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/bi.svg b/assets/img/flags/bi.svg new file mode 100644 index 000000000..20a3533f2 --- /dev/null +++ b/assets/img/flags/bi.svg @@ -0,0 +1,29 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bj.svg b/assets/img/flags/bj.svg new file mode 100644 index 000000000..132f9b5ed --- /dev/null +++ b/assets/img/flags/bj.svg @@ -0,0 +1,47 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bl.svg b/assets/img/flags/bl.svg new file mode 100644 index 000000000..454ecc943 --- /dev/null +++ b/assets/img/flags/bl.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/bm.svg b/assets/img/flags/bm.svg new file mode 100644 index 000000000..b2d83f5c3 --- /dev/null +++ b/assets/img/flags/bm.svg @@ -0,0 +1,363 @@ + + + + + + + Bermuda + + + + + caribbean + america + flag + sign + + + + + Caleb Moore + + + + + Caleb Moore + + + + + Caleb Moore + + + + image/svg+xml + + + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bn.svg b/assets/img/flags/bn.svg new file mode 100644 index 000000000..2cb386e86 --- /dev/null +++ b/assets/img/flags/bn.svg @@ -0,0 +1,137 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bo.svg b/assets/img/flags/bo.svg new file mode 100644 index 000000000..5952c3094 --- /dev/null +++ b/assets/img/flags/bo.svg @@ -0,0 +1,37 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/bq.svg b/assets/img/flags/bq.svg new file mode 100644 index 000000000..5da21323d --- /dev/null +++ b/assets/img/flags/bq.svg @@ -0,0 +1,17 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/br.svg b/assets/img/flags/br.svg new file mode 100644 index 000000000..df1132002 --- /dev/null +++ b/assets/img/flags/br.svg @@ -0,0 +1,88 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bs.svg b/assets/img/flags/bs.svg new file mode 100644 index 000000000..570d7a5b3 --- /dev/null +++ b/assets/img/flags/bs.svg @@ -0,0 +1,24 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bt.svg b/assets/img/flags/bt.svg new file mode 100644 index 000000000..fada20a30 --- /dev/null +++ b/assets/img/flags/bt.svg @@ -0,0 +1,217 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bv.svg b/assets/img/flags/bv.svg new file mode 100644 index 000000000..104b29bd7 --- /dev/null +++ b/assets/img/flags/bv.svg @@ -0,0 +1,26 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bw.svg b/assets/img/flags/bw.svg new file mode 100644 index 000000000..d7f0c92f3 --- /dev/null +++ b/assets/img/flags/bw.svg @@ -0,0 +1,39 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/by.svg b/assets/img/flags/by.svg new file mode 100644 index 000000000..260168059 --- /dev/null +++ b/assets/img/flags/by.svg @@ -0,0 +1,266 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/bz.svg b/assets/img/flags/bz.svg new file mode 100644 index 000000000..d352f9a15 --- /dev/null +++ b/assets/img/flags/bz.svg @@ -0,0 +1,302 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ca.svg b/assets/img/flags/ca.svg new file mode 100644 index 000000000..eb528d941 --- /dev/null +++ b/assets/img/flags/ca.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cc.svg b/assets/img/flags/cc.svg new file mode 100644 index 000000000..d250b0523 --- /dev/null +++ b/assets/img/flags/cc.svg @@ -0,0 +1,29 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cd.svg b/assets/img/flags/cd.svg new file mode 100644 index 000000000..0bc79360d --- /dev/null +++ b/assets/img/flags/cd.svg @@ -0,0 +1,16 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/cf.svg b/assets/img/flags/cf.svg new file mode 100644 index 000000000..ee8a291f1 --- /dev/null +++ b/assets/img/flags/cf.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cg.svg b/assets/img/flags/cg.svg new file mode 100644 index 000000000..76cd8ea7e --- /dev/null +++ b/assets/img/flags/cg.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ch.svg b/assets/img/flags/ch.svg new file mode 100644 index 000000000..36483307a --- /dev/null +++ b/assets/img/flags/ch.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/ci.svg b/assets/img/flags/ci.svg new file mode 100644 index 000000000..4a6fc7f91 --- /dev/null +++ b/assets/img/flags/ci.svg @@ -0,0 +1,39 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ck.svg b/assets/img/flags/ck.svg new file mode 100644 index 000000000..57efc0707 --- /dev/null +++ b/assets/img/flags/ck.svg @@ -0,0 +1,38 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cl.svg b/assets/img/flags/cl.svg new file mode 100644 index 000000000..ffa1d2023 --- /dev/null +++ b/assets/img/flags/cl.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cm.svg b/assets/img/flags/cm.svg new file mode 100644 index 000000000..a1186c333 --- /dev/null +++ b/assets/img/flags/cm.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cn.svg b/assets/img/flags/cn.svg new file mode 100644 index 000000000..121d077a1 --- /dev/null +++ b/assets/img/flags/cn.svg @@ -0,0 +1,19 @@ + + + Flag of the People's Republic of China + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/co.svg b/assets/img/flags/co.svg new file mode 100644 index 000000000..9a220d175 --- /dev/null +++ b/assets/img/flags/co.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/cr.svg b/assets/img/flags/cr.svg new file mode 100644 index 000000000..79e5dd24e --- /dev/null +++ b/assets/img/flags/cr.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/cu.svg b/assets/img/flags/cu.svg new file mode 100644 index 000000000..897fa77f3 --- /dev/null +++ b/assets/img/flags/cu.svg @@ -0,0 +1,24 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cv.svg b/assets/img/flags/cv.svg new file mode 100644 index 000000000..1a65d045c --- /dev/null +++ b/assets/img/flags/cv.svg @@ -0,0 +1,34 @@ + + + + + + + image/svg+xml + + + + + The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cw.svg b/assets/img/flags/cw.svg new file mode 100644 index 000000000..f4cd92bfc --- /dev/null +++ b/assets/img/flags/cw.svg @@ -0,0 +1,29 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cx.svg b/assets/img/flags/cx.svg new file mode 100644 index 000000000..70f8b8b6d --- /dev/null +++ b/assets/img/flags/cx.svg @@ -0,0 +1,30 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cy.svg b/assets/img/flags/cy.svg new file mode 100644 index 000000000..e6cc05d00 --- /dev/null +++ b/assets/img/flags/cy.svg @@ -0,0 +1,47 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/cz.svg b/assets/img/flags/cz.svg new file mode 100644 index 000000000..ee59f947f --- /dev/null +++ b/assets/img/flags/cz.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/de.svg b/assets/img/flags/de.svg new file mode 100644 index 000000000..aa101a3d1 --- /dev/null +++ b/assets/img/flags/de.svg @@ -0,0 +1,37 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/dj.svg b/assets/img/flags/dj.svg new file mode 100644 index 000000000..a5621c55e --- /dev/null +++ b/assets/img/flags/dj.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/dk.svg b/assets/img/flags/dk.svg new file mode 100644 index 000000000..c91659308 --- /dev/null +++ b/assets/img/flags/dk.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/dm.svg b/assets/img/flags/dm.svg new file mode 100644 index 000000000..82fa18a3c --- /dev/null +++ b/assets/img/flags/dm.svg @@ -0,0 +1,192 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/do.svg b/assets/img/flags/do.svg new file mode 100644 index 000000000..22ff46493 --- /dev/null +++ b/assets/img/flags/do.svg @@ -0,0 +1,6801 @@ + + + + + + + image/svg+xml + + + + + The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/dz.svg b/assets/img/flags/dz.svg new file mode 100644 index 000000000..e05bd11f8 --- /dev/null +++ b/assets/img/flags/dz.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ec.svg b/assets/img/flags/ec.svg new file mode 100644 index 000000000..0116d3787 --- /dev/null +++ b/assets/img/flags/ec.svg @@ -0,0 +1,184 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ee.svg b/assets/img/flags/ee.svg new file mode 100644 index 000000000..cb04c4da0 --- /dev/null +++ b/assets/img/flags/ee.svg @@ -0,0 +1,39 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/eg.svg b/assets/img/flags/eg.svg new file mode 100644 index 000000000..e6b608a6e --- /dev/null +++ b/assets/img/flags/eg.svg @@ -0,0 +1,81 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/eh.svg b/assets/img/flags/eh.svg new file mode 100644 index 000000000..68d82c7a9 --- /dev/null +++ b/assets/img/flags/eh.svg @@ -0,0 +1,48 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/er.svg b/assets/img/flags/er.svg new file mode 100644 index 000000000..9c5ebcf02 --- /dev/null +++ b/assets/img/flags/er.svg @@ -0,0 +1,42 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/es.svg b/assets/img/flags/es.svg new file mode 100644 index 000000000..506b28bcc --- /dev/null +++ b/assets/img/flags/es.svg @@ -0,0 +1,705 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/et.svg b/assets/img/flags/et.svg new file mode 100644 index 000000000..f81a16a5e --- /dev/null +++ b/assets/img/flags/et.svg @@ -0,0 +1,24 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fi.svg b/assets/img/flags/fi.svg new file mode 100644 index 000000000..f0d3c8e03 --- /dev/null +++ b/assets/img/flags/fi.svg @@ -0,0 +1,53 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fj.svg b/assets/img/flags/fj.svg new file mode 100644 index 000000000..0f2ea3c9a --- /dev/null +++ b/assets/img/flags/fj.svg @@ -0,0 +1,154 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fk.svg b/assets/img/flags/fk.svg new file mode 100644 index 000000000..904400621 --- /dev/null +++ b/assets/img/flags/fk.svg @@ -0,0 +1,215 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fm.svg b/assets/img/flags/fm.svg new file mode 100644 index 000000000..1b57bbfb4 --- /dev/null +++ b/assets/img/flags/fm.svg @@ -0,0 +1,24 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fo.svg b/assets/img/flags/fo.svg new file mode 100644 index 000000000..37211bee8 --- /dev/null +++ b/assets/img/flags/fo.svg @@ -0,0 +1,42 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/fr.svg b/assets/img/flags/fr.svg new file mode 100644 index 000000000..7e0bdb8e1 --- /dev/null +++ b/assets/img/flags/fr.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ga.svg b/assets/img/flags/ga.svg new file mode 100644 index 000000000..74be3500e --- /dev/null +++ b/assets/img/flags/ga.svg @@ -0,0 +1,40 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/gb.svg b/assets/img/flags/gb.svg new file mode 100644 index 000000000..5389a49d3 --- /dev/null +++ b/assets/img/flags/gb.svg @@ -0,0 +1,52 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gd.svg b/assets/img/flags/gd.svg new file mode 100644 index 000000000..44c61b160 --- /dev/null +++ b/assets/img/flags/gd.svg @@ -0,0 +1,32 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ge.svg b/assets/img/flags/ge.svg new file mode 100644 index 000000000..a508b2b42 --- /dev/null +++ b/assets/img/flags/ge.svg @@ -0,0 +1,31 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gf.svg b/assets/img/flags/gf.svg new file mode 100644 index 000000000..8fada7233 --- /dev/null +++ b/assets/img/flags/gf.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/gg.svg b/assets/img/flags/gg.svg new file mode 100644 index 000000000..1144540ca --- /dev/null +++ b/assets/img/flags/gg.svg @@ -0,0 +1,29 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gh.svg b/assets/img/flags/gh.svg new file mode 100644 index 000000000..b6fa30d17 --- /dev/null +++ b/assets/img/flags/gh.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gi.svg b/assets/img/flags/gi.svg new file mode 100644 index 000000000..e40d6e753 --- /dev/null +++ b/assets/img/flags/gi.svg @@ -0,0 +1,347 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gl.svg b/assets/img/flags/gl.svg new file mode 100644 index 000000000..37aaccf88 --- /dev/null +++ b/assets/img/flags/gl.svg @@ -0,0 +1,46 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gm.svg b/assets/img/flags/gm.svg new file mode 100644 index 000000000..d4409b23a --- /dev/null +++ b/assets/img/flags/gm.svg @@ -0,0 +1,47 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gn.svg b/assets/img/flags/gn.svg new file mode 100644 index 000000000..23ce5b33e --- /dev/null +++ b/assets/img/flags/gn.svg @@ -0,0 +1,42 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/gp.svg b/assets/img/flags/gp.svg new file mode 100644 index 000000000..a775d8f3b --- /dev/null +++ b/assets/img/flags/gp.svg @@ -0,0 +1,19 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/gq.svg b/assets/img/flags/gq.svg new file mode 100644 index 000000000..4e878ccef --- /dev/null +++ b/assets/img/flags/gq.svg @@ -0,0 +1,86 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gr.svg b/assets/img/flags/gr.svg new file mode 100644 index 000000000..4bff36c12 --- /dev/null +++ b/assets/img/flags/gr.svg @@ -0,0 +1,60 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gs.svg b/assets/img/flags/gs.svg new file mode 100644 index 000000000..18c6dbd4f --- /dev/null +++ b/assets/img/flags/gs.svg @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + L + + + E + + + O + + + T + + + E + + + R + + + R + + + R + + + R + + + R + + + E + + + O + + + O + + + A + + + A + + + A + + + M + + + P + + + P + + + P + + + I + + + T + + + T + + + M + + + G + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + South Georgia and South Sandwich Islands + + + + united_kingdom + flags + antarctic + sign + signs_and_symbols + + europe + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + image/svg+xml + + + + + en + + + + + + + + + diff --git a/assets/img/flags/gt.svg b/assets/img/flags/gt.svg new file mode 100644 index 000000000..9ae0d79eb --- /dev/null +++ b/assets/img/flags/gt.svg @@ -0,0 +1,139 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gu.svg b/assets/img/flags/gu.svg new file mode 100644 index 000000000..797b88dc0 --- /dev/null +++ b/assets/img/flags/gu.svg @@ -0,0 +1,75 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + G + + + U + + + A + + + M + + + + + + + + G + + + U + + + A + + + M + + + + diff --git a/assets/img/flags/gw.svg b/assets/img/flags/gw.svg new file mode 100644 index 000000000..277f3a178 --- /dev/null +++ b/assets/img/flags/gw.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/gy.svg b/assets/img/flags/gy.svg new file mode 100644 index 000000000..626eff887 --- /dev/null +++ b/assets/img/flags/gy.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/hk.svg b/assets/img/flags/hk.svg new file mode 100644 index 000000000..7ad03f360 --- /dev/null +++ b/assets/img/flags/hk.svg @@ -0,0 +1,52 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/hm.svg b/assets/img/flags/hm.svg new file mode 100644 index 000000000..a3cd071cd --- /dev/null +++ b/assets/img/flags/hm.svg @@ -0,0 +1,29 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/hn.svg b/assets/img/flags/hn.svg new file mode 100644 index 000000000..7e79933f5 --- /dev/null +++ b/assets/img/flags/hn.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/hr.svg b/assets/img/flags/hr.svg new file mode 100644 index 000000000..4ba52b07f --- /dev/null +++ b/assets/img/flags/hr.svg @@ -0,0 +1,144 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ht.svg b/assets/img/flags/ht.svg new file mode 100644 index 000000000..53475ab1e --- /dev/null +++ b/assets/img/flags/ht.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/hu.svg b/assets/img/flags/hu.svg new file mode 100644 index 000000000..8ab9d1b3a --- /dev/null +++ b/assets/img/flags/hu.svg @@ -0,0 +1,19 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/id.svg b/assets/img/flags/id.svg new file mode 100644 index 000000000..ba4f9585b --- /dev/null +++ b/assets/img/flags/id.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/ie.svg b/assets/img/flags/ie.svg new file mode 100644 index 000000000..f10a9c558 --- /dev/null +++ b/assets/img/flags/ie.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/il.svg b/assets/img/flags/il.svg new file mode 100644 index 000000000..518a2bc0d --- /dev/null +++ b/assets/img/flags/il.svg @@ -0,0 +1,31 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/im.svg b/assets/img/flags/im.svg new file mode 100644 index 000000000..275cf86fa --- /dev/null +++ b/assets/img/flags/im.svg @@ -0,0 +1,71 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/in.svg b/assets/img/flags/in.svg new file mode 100644 index 000000000..3265f8f5f --- /dev/null +++ b/assets/img/flags/in.svg @@ -0,0 +1,47 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/io.svg b/assets/img/flags/io.svg new file mode 100644 index 000000000..6051b09b8 --- /dev/null +++ b/assets/img/flags/io.svg @@ -0,0 +1,187 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/iq.svg b/assets/img/flags/iq.svg new file mode 100644 index 000000000..e0dd0b9a9 --- /dev/null +++ b/assets/img/flags/iq.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ir.svg b/assets/img/flags/ir.svg new file mode 100644 index 000000000..9779aeada --- /dev/null +++ b/assets/img/flags/ir.svg @@ -0,0 +1,522 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/is.svg b/assets/img/flags/is.svg new file mode 100644 index 000000000..046ffd0b5 --- /dev/null +++ b/assets/img/flags/is.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/it.svg b/assets/img/flags/it.svg new file mode 100644 index 000000000..1a9f297f2 --- /dev/null +++ b/assets/img/flags/it.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/je.svg b/assets/img/flags/je.svg new file mode 100644 index 000000000..4dd97f1bf --- /dev/null +++ b/assets/img/flags/je.svg @@ -0,0 +1,67 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/jm.svg b/assets/img/flags/jm.svg new file mode 100644 index 000000000..5ccd524ff --- /dev/null +++ b/assets/img/flags/jm.svg @@ -0,0 +1,45 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/jo.svg b/assets/img/flags/jo.svg new file mode 100644 index 000000000..152749e30 --- /dev/null +++ b/assets/img/flags/jo.svg @@ -0,0 +1,49 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/jp.svg b/assets/img/flags/jp.svg new file mode 100644 index 000000000..f36071b8c --- /dev/null +++ b/assets/img/flags/jp.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/ke.svg b/assets/img/flags/ke.svg new file mode 100644 index 000000000..79d223f2a --- /dev/null +++ b/assets/img/flags/ke.svg @@ -0,0 +1,33 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kg.svg b/assets/img/flags/kg.svg new file mode 100644 index 000000000..e560aefb8 --- /dev/null +++ b/assets/img/flags/kg.svg @@ -0,0 +1,47 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kh.svg b/assets/img/flags/kh.svg new file mode 100644 index 000000000..48e782e19 --- /dev/null +++ b/assets/img/flags/kh.svg @@ -0,0 +1,156 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ki.svg b/assets/img/flags/ki.svg new file mode 100644 index 000000000..c145dd325 --- /dev/null +++ b/assets/img/flags/ki.svg @@ -0,0 +1,49 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/km.svg b/assets/img/flags/km.svg new file mode 100644 index 000000000..235ec0227 --- /dev/null +++ b/assets/img/flags/km.svg @@ -0,0 +1,51 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kn.svg b/assets/img/flags/kn.svg new file mode 100644 index 000000000..d60ae2e76 --- /dev/null +++ b/assets/img/flags/kn.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kp.svg b/assets/img/flags/kp.svg new file mode 100644 index 000000000..6ce531d50 --- /dev/null +++ b/assets/img/flags/kp.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kr.svg b/assets/img/flags/kr.svg new file mode 100644 index 000000000..a0b266d50 --- /dev/null +++ b/assets/img/flags/kr.svg @@ -0,0 +1,42 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kw.svg b/assets/img/flags/kw.svg new file mode 100644 index 000000000..5822eb614 --- /dev/null +++ b/assets/img/flags/kw.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ky.svg b/assets/img/flags/ky.svg new file mode 100644 index 000000000..fb5e915af --- /dev/null +++ b/assets/img/flags/ky.svg @@ -0,0 +1,123 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/kz.svg b/assets/img/flags/kz.svg new file mode 100644 index 000000000..bd7130af8 --- /dev/null +++ b/assets/img/flags/kz.svg @@ -0,0 +1,67 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/la.svg b/assets/img/flags/la.svg new file mode 100644 index 000000000..fe009db97 --- /dev/null +++ b/assets/img/flags/la.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/lb.svg b/assets/img/flags/lb.svg new file mode 100644 index 000000000..433b2c28e --- /dev/null +++ b/assets/img/flags/lb.svg @@ -0,0 +1,45 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/lc.svg b/assets/img/flags/lc.svg new file mode 100644 index 000000000..ab006b5ba --- /dev/null +++ b/assets/img/flags/lc.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/li.svg b/assets/img/flags/li.svg new file mode 100644 index 000000000..78d5325a1 --- /dev/null +++ b/assets/img/flags/li.svg @@ -0,0 +1,195 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/lk.svg b/assets/img/flags/lk.svg new file mode 100644 index 000000000..e28985d4d --- /dev/null +++ b/assets/img/flags/lk.svg @@ -0,0 +1,44 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/lr.svg b/assets/img/flags/lr.svg new file mode 100644 index 000000000..f9d3939f3 --- /dev/null +++ b/assets/img/flags/lr.svg @@ -0,0 +1,29 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ls.svg b/assets/img/flags/ls.svg new file mode 100644 index 000000000..d5836fa03 --- /dev/null +++ b/assets/img/flags/ls.svg @@ -0,0 +1,173 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/lt.svg b/assets/img/flags/lt.svg new file mode 100644 index 000000000..b67359b99 --- /dev/null +++ b/assets/img/flags/lt.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/lu.svg b/assets/img/flags/lu.svg new file mode 100644 index 000000000..1f6fe91ce --- /dev/null +++ b/assets/img/flags/lu.svg @@ -0,0 +1,36 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/lv.svg b/assets/img/flags/lv.svg new file mode 100644 index 000000000..a2fbe7538 --- /dev/null +++ b/assets/img/flags/lv.svg @@ -0,0 +1,37 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ly.svg b/assets/img/flags/ly.svg new file mode 100644 index 000000000..092275246 --- /dev/null +++ b/assets/img/flags/ly.svg @@ -0,0 +1,23 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ma.svg b/assets/img/flags/ma.svg new file mode 100644 index 000000000..457afd2bc --- /dev/null +++ b/assets/img/flags/ma.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/mc.svg b/assets/img/flags/mc.svg new file mode 100644 index 000000000..9bd4ab13b --- /dev/null +++ b/assets/img/flags/mc.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/img/flags/md.svg b/assets/img/flags/md.svg new file mode 100644 index 000000000..27729f56b --- /dev/null +++ b/assets/img/flags/md.svg @@ -0,0 +1,101 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/me.svg b/assets/img/flags/me.svg new file mode 100644 index 000000000..473f64045 --- /dev/null +++ b/assets/img/flags/me.svg @@ -0,0 +1,5 @@ + + +image/svg+xml + + diff --git a/assets/img/flags/mf.svg b/assets/img/flags/mf.svg new file mode 100644 index 000000000..7e0bdb8e1 --- /dev/null +++ b/assets/img/flags/mf.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/mg.svg b/assets/img/flags/mg.svg new file mode 100644 index 000000000..05bd5b982 --- /dev/null +++ b/assets/img/flags/mg.svg @@ -0,0 +1,38 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/mh.svg b/assets/img/flags/mh.svg new file mode 100644 index 000000000..df29219e5 --- /dev/null +++ b/assets/img/flags/mh.svg @@ -0,0 +1,42 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/mk.svg b/assets/img/flags/mk.svg new file mode 100644 index 000000000..62e5e37b9 --- /dev/null +++ b/assets/img/flags/mk.svg @@ -0,0 +1,30 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ml.svg b/assets/img/flags/ml.svg new file mode 100644 index 000000000..dc71966d9 --- /dev/null +++ b/assets/img/flags/ml.svg @@ -0,0 +1,38 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/mm.svg b/assets/img/flags/mm.svg new file mode 100644 index 000000000..add89f8d2 --- /dev/null +++ b/assets/img/flags/mm.svg @@ -0,0 +1,146 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mn.svg b/assets/img/flags/mn.svg new file mode 100644 index 000000000..819ce2f07 --- /dev/null +++ b/assets/img/flags/mn.svg @@ -0,0 +1,26 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mo.svg b/assets/img/flags/mo.svg new file mode 100644 index 000000000..769e94a5f --- /dev/null +++ b/assets/img/flags/mo.svg @@ -0,0 +1,31 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mp.svg b/assets/img/flags/mp.svg new file mode 100644 index 000000000..93ca16865 --- /dev/null +++ b/assets/img/flags/mp.svg @@ -0,0 +1,268 @@ + + + + + + + + + + + micronesia + + oceania + flag + sign + + + + + + + + + + + + + + + + + + + image/svg+xml + + + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mq.svg b/assets/img/flags/mq.svg new file mode 100644 index 000000000..d6ebd3885 --- /dev/null +++ b/assets/img/flags/mq.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/mr.svg b/assets/img/flags/mr.svg new file mode 100644 index 000000000..0066c90f9 --- /dev/null +++ b/assets/img/flags/mr.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ms.svg b/assets/img/flags/ms.svg new file mode 100644 index 000000000..c9ef72b10 --- /dev/null +++ b/assets/img/flags/ms.svg @@ -0,0 +1,76 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mt.svg b/assets/img/flags/mt.svg new file mode 100644 index 000000000..b14194b75 --- /dev/null +++ b/assets/img/flags/mt.svg @@ -0,0 +1,82 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mu.svg b/assets/img/flags/mu.svg new file mode 100644 index 000000000..6ae235d89 --- /dev/null +++ b/assets/img/flags/mu.svg @@ -0,0 +1,40 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/mv.svg b/assets/img/flags/mv.svg new file mode 100644 index 000000000..eb20d9ef8 --- /dev/null +++ b/assets/img/flags/mv.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/mw.svg b/assets/img/flags/mw.svg new file mode 100644 index 000000000..cf3922bc7 --- /dev/null +++ b/assets/img/flags/mw.svg @@ -0,0 +1,55 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mx.svg b/assets/img/flags/mx.svg new file mode 100644 index 000000000..7fc1393f8 --- /dev/null +++ b/assets/img/flags/mx.svg @@ -0,0 +1,349 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/my.svg b/assets/img/flags/my.svg new file mode 100644 index 000000000..2654cef5e --- /dev/null +++ b/assets/img/flags/my.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/mz.svg b/assets/img/flags/mz.svg new file mode 100644 index 000000000..605a9d59c --- /dev/null +++ b/assets/img/flags/mz.svg @@ -0,0 +1,34 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/na.svg b/assets/img/flags/na.svg new file mode 100644 index 000000000..af516371d --- /dev/null +++ b/assets/img/flags/na.svg @@ -0,0 +1,30 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/nc.svg b/assets/img/flags/nc.svg new file mode 100644 index 000000000..042507e51 --- /dev/null +++ b/assets/img/flags/nc.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ne.svg b/assets/img/flags/ne.svg new file mode 100644 index 000000000..e6a778141 --- /dev/null +++ b/assets/img/flags/ne.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/nf.svg b/assets/img/flags/nf.svg new file mode 100644 index 000000000..84f84ec82 --- /dev/null +++ b/assets/img/flags/nf.svg @@ -0,0 +1,46 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ng.svg b/assets/img/flags/ng.svg new file mode 100644 index 000000000..4eccb54f7 --- /dev/null +++ b/assets/img/flags/ng.svg @@ -0,0 +1,19 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/ni.svg b/assets/img/flags/ni.svg new file mode 100644 index 000000000..2fe085a17 --- /dev/null +++ b/assets/img/flags/ni.svg @@ -0,0 +1,122 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/nl.svg b/assets/img/flags/nl.svg new file mode 100644 index 000000000..be962d33d --- /dev/null +++ b/assets/img/flags/nl.svg @@ -0,0 +1,37 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/no.svg b/assets/img/flags/no.svg new file mode 100644 index 000000000..699ed31ce --- /dev/null +++ b/assets/img/flags/no.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/np.svg b/assets/img/flags/np.svg new file mode 100644 index 000000000..373225e07 --- /dev/null +++ b/assets/img/flags/np.svg @@ -0,0 +1,47 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/nr.svg b/assets/img/flags/nr.svg new file mode 100644 index 000000000..78a65e02e --- /dev/null +++ b/assets/img/flags/nr.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/nu.svg b/assets/img/flags/nu.svg new file mode 100644 index 000000000..645a66e3e --- /dev/null +++ b/assets/img/flags/nu.svg @@ -0,0 +1,40 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/nz.svg b/assets/img/flags/nz.svg new file mode 100644 index 000000000..0f7cf2f52 --- /dev/null +++ b/assets/img/flags/nz.svg @@ -0,0 +1,69 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/om.svg b/assets/img/flags/om.svg new file mode 100644 index 000000000..f28f32dae --- /dev/null +++ b/assets/img/flags/om.svg @@ -0,0 +1,349 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pa.svg b/assets/img/flags/pa.svg new file mode 100644 index 000000000..c1368b259 --- /dev/null +++ b/assets/img/flags/pa.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pe.svg b/assets/img/flags/pe.svg new file mode 100644 index 000000000..ebd2e5f32 --- /dev/null +++ b/assets/img/flags/pe.svg @@ -0,0 +1,40 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/pf.svg b/assets/img/flags/pf.svg new file mode 100644 index 000000000..63f056e71 --- /dev/null +++ b/assets/img/flags/pf.svg @@ -0,0 +1,77 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pg.svg b/assets/img/flags/pg.svg new file mode 100644 index 000000000..fd1fe93b2 --- /dev/null +++ b/assets/img/flags/pg.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ph.svg b/assets/img/flags/ph.svg new file mode 100644 index 000000000..95f9d3761 --- /dev/null +++ b/assets/img/flags/ph.svg @@ -0,0 +1,36 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pk.svg b/assets/img/flags/pk.svg new file mode 100644 index 000000000..1c33e675f --- /dev/null +++ b/assets/img/flags/pk.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pl.svg b/assets/img/flags/pl.svg new file mode 100644 index 000000000..a8d406aff --- /dev/null +++ b/assets/img/flags/pl.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/img/flags/pm.svg b/assets/img/flags/pm.svg new file mode 100644 index 000000000..d7fb73650 --- /dev/null +++ b/assets/img/flags/pm.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/pn.svg b/assets/img/flags/pn.svg new file mode 100644 index 000000000..d310c313f --- /dev/null +++ b/assets/img/flags/pn.svg @@ -0,0 +1,146 @@ + + + + + + + image/svg+xml + + + + + + + + + + The above line is the ensign field color: #CF142B red and #00247D blue + + + + + + + + + I think the above two lines give the simplest way to make the diagonals + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pr.svg b/assets/img/flags/pr.svg new file mode 100644 index 000000000..771c304c1 --- /dev/null +++ b/assets/img/flags/pr.svg @@ -0,0 +1,45 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ps.svg b/assets/img/flags/ps.svg new file mode 100644 index 000000000..e960374a9 --- /dev/null +++ b/assets/img/flags/ps.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pt.svg b/assets/img/flags/pt.svg new file mode 100644 index 000000000..752ea6476 --- /dev/null +++ b/assets/img/flags/pt.svg @@ -0,0 +1,526 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/pw.svg b/assets/img/flags/pw.svg new file mode 100644 index 000000000..222f3c25b --- /dev/null +++ b/assets/img/flags/pw.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/py.svg b/assets/img/flags/py.svg new file mode 100644 index 000000000..80d30bf02 --- /dev/null +++ b/assets/img/flags/py.svg @@ -0,0 +1,242 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/qa.svg b/assets/img/flags/qa.svg new file mode 100644 index 000000000..099b508f1 --- /dev/null +++ b/assets/img/flags/qa.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/re.svg b/assets/img/flags/re.svg new file mode 100644 index 000000000..d5a5e2158 --- /dev/null +++ b/assets/img/flags/re.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ro.svg b/assets/img/flags/ro.svg new file mode 100644 index 000000000..311c372df --- /dev/null +++ b/assets/img/flags/ro.svg @@ -0,0 +1,42 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/assets/img/flags/rs.svg b/assets/img/flags/rs.svg new file mode 100644 index 000000000..21e21c2dd --- /dev/null +++ b/assets/img/flags/rs.svg @@ -0,0 +1,1562 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ru.svg b/assets/img/flags/ru.svg new file mode 100644 index 000000000..e931a6b6c --- /dev/null +++ b/assets/img/flags/ru.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/rw.svg b/assets/img/flags/rw.svg new file mode 100644 index 000000000..282288223 --- /dev/null +++ b/assets/img/flags/rw.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sa.svg b/assets/img/flags/sa.svg new file mode 100644 index 000000000..f2bacbb39 --- /dev/null +++ b/assets/img/flags/sa.svg @@ -0,0 +1,56 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sb.svg b/assets/img/flags/sb.svg new file mode 100644 index 000000000..334ae6475 --- /dev/null +++ b/assets/img/flags/sb.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sc.svg b/assets/img/flags/sc.svg new file mode 100644 index 000000000..a4841f81d --- /dev/null +++ b/assets/img/flags/sc.svg @@ -0,0 +1,45 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sd.svg b/assets/img/flags/sd.svg new file mode 100644 index 000000000..bbfac97be --- /dev/null +++ b/assets/img/flags/sd.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/se.svg b/assets/img/flags/se.svg new file mode 100644 index 000000000..913d8a83d --- /dev/null +++ b/assets/img/flags/se.svg @@ -0,0 +1,28 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sg.svg b/assets/img/flags/sg.svg new file mode 100644 index 000000000..7a85ef521 --- /dev/null +++ b/assets/img/flags/sg.svg @@ -0,0 +1,29 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sh.svg b/assets/img/flags/sh.svg new file mode 100644 index 000000000..21cf48865 --- /dev/null +++ b/assets/img/flags/sh.svg @@ -0,0 +1,795 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/si.svg b/assets/img/flags/si.svg new file mode 100644 index 000000000..7461f6361 --- /dev/null +++ b/assets/img/flags/si.svg @@ -0,0 +1,30 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sj.svg b/assets/img/flags/sj.svg new file mode 100644 index 000000000..7550d890e --- /dev/null +++ b/assets/img/flags/sj.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sk.svg b/assets/img/flags/sk.svg new file mode 100644 index 000000000..84c09e8c8 --- /dev/null +++ b/assets/img/flags/sk.svg @@ -0,0 +1,26 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sl.svg b/assets/img/flags/sl.svg new file mode 100644 index 000000000..662d1d71b --- /dev/null +++ b/assets/img/flags/sl.svg @@ -0,0 +1,43 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/sm.svg b/assets/img/flags/sm.svg new file mode 100644 index 000000000..88284d40d --- /dev/null +++ b/assets/img/flags/sm.svg @@ -0,0 +1,209 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + L + + + I + + + B + + + E + + + R + + + T + + + A + + + S + + + + + + + + + + + + + diff --git a/assets/img/flags/sn.svg b/assets/img/flags/sn.svg new file mode 100644 index 000000000..a3fa37a44 --- /dev/null +++ b/assets/img/flags/sn.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/so.svg b/assets/img/flags/so.svg new file mode 100644 index 000000000..534c6abbf --- /dev/null +++ b/assets/img/flags/so.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/sr.svg b/assets/img/flags/sr.svg new file mode 100644 index 000000000..99514a928 --- /dev/null +++ b/assets/img/flags/sr.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ss.svg b/assets/img/flags/ss.svg new file mode 100644 index 000000000..56aff0574 --- /dev/null +++ b/assets/img/flags/ss.svg @@ -0,0 +1,21 @@ + + + + + + image/svg+xml + + Flag of South Sudan + + + + Flag of South Sudan + + + + + + + + + diff --git a/assets/img/flags/st.svg b/assets/img/flags/st.svg new file mode 100644 index 000000000..8f09d115e --- /dev/null +++ b/assets/img/flags/st.svg @@ -0,0 +1,27 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sv.svg b/assets/img/flags/sv.svg new file mode 100644 index 000000000..0fcd4f4a4 --- /dev/null +++ b/assets/img/flags/sv.svg @@ -0,0 +1,301 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sx.svg b/assets/img/flags/sx.svg new file mode 100644 index 000000000..142b30fce --- /dev/null +++ b/assets/img/flags/sx.svg @@ -0,0 +1,106 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sy.svg b/assets/img/flags/sy.svg new file mode 100644 index 000000000..d85aadf9a --- /dev/null +++ b/assets/img/flags/sy.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/sz.svg b/assets/img/flags/sz.svg new file mode 100644 index 000000000..c92e5979d --- /dev/null +++ b/assets/img/flags/sz.svg @@ -0,0 +1,93 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tc.svg b/assets/img/flags/tc.svg new file mode 100644 index 000000000..865b4ad92 --- /dev/null +++ b/assets/img/flags/tc.svg @@ -0,0 +1,102 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/td.svg b/assets/img/flags/td.svg new file mode 100644 index 000000000..1d01cdc0d --- /dev/null +++ b/assets/img/flags/td.svg @@ -0,0 +1,41 @@ + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/tf.svg b/assets/img/flags/tf.svg new file mode 100644 index 000000000..213c2841b --- /dev/null +++ b/assets/img/flags/tf.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/tg.svg b/assets/img/flags/tg.svg new file mode 100644 index 000000000..20dbfa347 --- /dev/null +++ b/assets/img/flags/tg.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/th.svg b/assets/img/flags/th.svg new file mode 100644 index 000000000..a539e3825 --- /dev/null +++ b/assets/img/flags/th.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/tj.svg b/assets/img/flags/tj.svg new file mode 100644 index 000000000..c1b0a89f6 --- /dev/null +++ b/assets/img/flags/tj.svg @@ -0,0 +1,37 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tk.svg b/assets/img/flags/tk.svg new file mode 100644 index 000000000..5d1649cc9 --- /dev/null +++ b/assets/img/flags/tk.svg @@ -0,0 +1,42 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tl.svg b/assets/img/flags/tl.svg new file mode 100644 index 000000000..3b56c52f9 --- /dev/null +++ b/assets/img/flags/tl.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tm.svg b/assets/img/flags/tm.svg new file mode 100644 index 000000000..83cd9aa1b --- /dev/null +++ b/assets/img/flags/tm.svg @@ -0,0 +1,325 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tn.svg b/assets/img/flags/tn.svg new file mode 100644 index 000000000..ef0d0b737 --- /dev/null +++ b/assets/img/flags/tn.svg @@ -0,0 +1,23 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/to.svg b/assets/img/flags/to.svg new file mode 100644 index 000000000..f51522f03 --- /dev/null +++ b/assets/img/flags/to.svg @@ -0,0 +1,22 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tr.svg b/assets/img/flags/tr.svg new file mode 100644 index 000000000..779779e41 --- /dev/null +++ b/assets/img/flags/tr.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/tt.svg b/assets/img/flags/tt.svg new file mode 100644 index 000000000..46e554773 --- /dev/null +++ b/assets/img/flags/tt.svg @@ -0,0 +1,18 @@ + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/assets/img/flags/tv.svg b/assets/img/flags/tv.svg new file mode 100644 index 000000000..d83f0d223 --- /dev/null +++ b/assets/img/flags/tv.svg @@ -0,0 +1,49 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tw.svg b/assets/img/flags/tw.svg new file mode 100644 index 000000000..e9fb0c2f7 --- /dev/null +++ b/assets/img/flags/tw.svg @@ -0,0 +1,24 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/tz.svg b/assets/img/flags/tz.svg new file mode 100644 index 000000000..85179d6e0 --- /dev/null +++ b/assets/img/flags/tz.svg @@ -0,0 +1,45 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ua.svg b/assets/img/flags/ua.svg new file mode 100644 index 000000000..6a8cf878a --- /dev/null +++ b/assets/img/flags/ua.svg @@ -0,0 +1,16 @@ + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/img/flags/ug.svg b/assets/img/flags/ug.svg new file mode 100644 index 000000000..7facab455 --- /dev/null +++ b/assets/img/flags/ug.svg @@ -0,0 +1,42 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/um.svg b/assets/img/flags/um.svg new file mode 100644 index 000000000..88b7f77cc --- /dev/null +++ b/assets/img/flags/um.svg @@ -0,0 +1,141 @@ + + + + + + + + + image/svg+xml + + + + + The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/us.svg b/assets/img/flags/us.svg new file mode 100644 index 000000000..b6516b4d5 --- /dev/null +++ b/assets/img/flags/us.svg @@ -0,0 +1,141 @@ + + + + + + + + + image/svg+xml + + + + + The United States of America flag, produced by Daniel McRae + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/uy.svg b/assets/img/flags/uy.svg new file mode 100644 index 000000000..4a29ce5a7 --- /dev/null +++ b/assets/img/flags/uy.svg @@ -0,0 +1,63 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/uz.svg b/assets/img/flags/uz.svg new file mode 100644 index 000000000..97468a7fe --- /dev/null +++ b/assets/img/flags/uz.svg @@ -0,0 +1,37 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/va.svg b/assets/img/flags/va.svg new file mode 100644 index 000000000..097e3b4b1 --- /dev/null +++ b/assets/img/flags/va.svg @@ -0,0 +1,501 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/vc.svg b/assets/img/flags/vc.svg new file mode 100644 index 000000000..de35200cd --- /dev/null +++ b/assets/img/flags/vc.svg @@ -0,0 +1,20 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/assets/img/flags/ve.svg b/assets/img/flags/ve.svg new file mode 100644 index 000000000..565d2683f --- /dev/null +++ b/assets/img/flags/ve.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/vg.svg b/assets/img/flags/vg.svg new file mode 100644 index 000000000..fe88d47a8 --- /dev/null +++ b/assets/img/flags/vg.svg @@ -0,0 +1,249 @@ + + + + + + + British Virgin Islands + + + + + united_kingdom + flags + caribbean + america + signs_and_symbols + sign + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + image/svg+xml + + + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/vi.svg b/assets/img/flags/vi.svg new file mode 100644 index 000000000..fe88d47a8 --- /dev/null +++ b/assets/img/flags/vi.svg @@ -0,0 +1,249 @@ + + + + + + + British Virgin Islands + + + + + united_kingdom + flags + caribbean + america + signs_and_symbols + sign + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + + Tobias Jakobs + + + + image/svg+xml + + + en + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/vn.svg b/assets/img/flags/vn.svg new file mode 100644 index 000000000..ee7ab4f26 --- /dev/null +++ b/assets/img/flags/vn.svg @@ -0,0 +1,21 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/assets/img/flags/vu.svg b/assets/img/flags/vu.svg new file mode 100644 index 000000000..bdea587c8 --- /dev/null +++ b/assets/img/flags/vu.svg @@ -0,0 +1,28 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/wf.svg b/assets/img/flags/wf.svg new file mode 100644 index 000000000..ae7ec827d --- /dev/null +++ b/assets/img/flags/wf.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/ws.svg b/assets/img/flags/ws.svg new file mode 100644 index 000000000..e799dd161 --- /dev/null +++ b/assets/img/flags/ws.svg @@ -0,0 +1,25 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/ye.svg b/assets/img/flags/ye.svg new file mode 100644 index 000000000..1d68b8216 --- /dev/null +++ b/assets/img/flags/ye.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/yt.svg b/assets/img/flags/yt.svg new file mode 100644 index 000000000..c0f60f64c --- /dev/null +++ b/assets/img/flags/yt.svg @@ -0,0 +1,17 @@ + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/assets/img/flags/za.svg b/assets/img/flags/za.svg new file mode 100644 index 000000000..d7a118700 --- /dev/null +++ b/assets/img/flags/za.svg @@ -0,0 +1,58 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/zm.svg b/assets/img/flags/zm.svg new file mode 100644 index 000000000..bc16746a7 --- /dev/null +++ b/assets/img/flags/zm.svg @@ -0,0 +1,40 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/flags/zw.svg b/assets/img/flags/zw.svg new file mode 100644 index 000000000..1227ba886 --- /dev/null +++ b/assets/img/flags/zw.svg @@ -0,0 +1,52 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/img/highlights-icons/distance.png b/assets/img/highlights-icons/distance.png new file mode 100755 index 000000000..8e8f25a85 Binary files /dev/null and b/assets/img/highlights-icons/distance.png differ diff --git a/assets/img/highlights-icons/quality.png b/assets/img/highlights-icons/quality.png new file mode 100755 index 000000000..2e7aa4ea1 Binary files /dev/null and b/assets/img/highlights-icons/quality.png differ diff --git a/assets/img/highlights-icons/result.png b/assets/img/highlights-icons/result.png new file mode 100755 index 000000000..237fa79a6 Binary files /dev/null and b/assets/img/highlights-icons/result.png differ diff --git a/assets/img/highlights-icons/support.png b/assets/img/highlights-icons/support.png new file mode 100755 index 000000000..93d181f06 Binary files /dev/null and b/assets/img/highlights-icons/support.png differ diff --git a/assets/img/highlights-icons/warranty.png b/assets/img/highlights-icons/warranty.png new file mode 100755 index 000000000..3fdfe7a68 Binary files /dev/null and b/assets/img/highlights-icons/warranty.png differ diff --git a/assets/img/important-icons-s32ff0be2d7.png b/assets/img/important-icons-s32ff0be2d7.png new file mode 100755 index 000000000..ccdac4cf0 Binary files /dev/null and b/assets/img/important-icons-s32ff0be2d7.png differ diff --git a/assets/img/interkassa.png b/assets/img/interkassa.png new file mode 100755 index 000000000..5db8ac111 Binary files /dev/null and b/assets/img/interkassa.png differ diff --git a/assets/img/invoice.png b/assets/img/invoice.png new file mode 100755 index 000000000..ebeffa6b7 Binary files /dev/null and b/assets/img/invoice.png differ diff --git a/assets/img/learning-pyramide.jpg b/assets/img/learning-pyramide.jpg new file mode 100755 index 000000000..c2a157187 Binary files /dev/null and b/assets/img/learning-pyramide.jpg differ diff --git a/assets/img/linked-upic.png b/assets/img/linked-upic.png new file mode 100755 index 000000000..bdf15fc06 Binary files /dev/null and b/assets/img/linked-upic.png differ diff --git a/assets/img/linked-userpic.gif b/assets/img/linked-userpic.gif new file mode 100755 index 000000000..366c28b3f Binary files /dev/null and b/assets/img/linked-userpic.gif differ diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100755 index 000000000..4e8ac5356 Binary files /dev/null and b/assets/img/logo.png differ diff --git a/assets/img/logo.svg b/assets/img/logo.svg new file mode 100755 index 000000000..9c7678f1e --- /dev/null +++ b/assets/img/logo.svg @@ -0,0 +1 @@ +GroupCreated with Sketch. \ No newline at end of file diff --git a/assets/img/logo_square.png b/assets/img/logo_square.png new file mode 100644 index 000000000..a6017b324 Binary files /dev/null and b/assets/img/logo_square.png differ diff --git a/assets/img/markup/sitetoolbar-userpic.png b/assets/img/markup/sitetoolbar-userpic.png new file mode 100755 index 000000000..80036691d Binary files /dev/null and b/assets/img/markup/sitetoolbar-userpic.png differ diff --git a/assets/img/noisy.png b/assets/img/noisy.png new file mode 100755 index 000000000..e429e4099 Binary files /dev/null and b/assets/img/noisy.png differ diff --git a/assets/img/page-footer.png b/assets/img/page-footer.png new file mode 100755 index 000000000..c2345babf Binary files /dev/null and b/assets/img/page-footer.png differ diff --git a/assets/img/pager-scroll.png b/assets/img/pager-scroll.png new file mode 100755 index 000000000..4285dfadb Binary files /dev/null and b/assets/img/pager-scroll.png differ diff --git a/assets/img/pay-method__bank-bill.png b/assets/img/pay-method__bank-bill.png new file mode 100755 index 000000000..6375f6d2e Binary files /dev/null and b/assets/img/pay-method__bank-bill.png differ diff --git a/assets/img/pay-method__interkassa.png b/assets/img/pay-method__interkassa.png new file mode 100755 index 000000000..de6959585 Binary files /dev/null and b/assets/img/pay-method__interkassa.png differ diff --git a/assets/img/pay-method__payanyway.png b/assets/img/pay-method__payanyway.png new file mode 100755 index 000000000..db07649e0 Binary files /dev/null and b/assets/img/pay-method__payanyway.png differ diff --git a/assets/img/pay-method__paypal.png b/assets/img/pay-method__paypal.png new file mode 100755 index 000000000..1335e9446 Binary files /dev/null and b/assets/img/pay-method__paypal.png differ diff --git a/assets/img/pay-method__webmoney.png b/assets/img/pay-method__webmoney.png new file mode 100755 index 000000000..b9f1aab43 Binary files /dev/null and b/assets/img/pay-method__webmoney.png differ diff --git a/assets/img/pay-method__yandexmoney.png b/assets/img/pay-method__yandexmoney.png new file mode 100755 index 000000000..949a2b50c Binary files /dev/null and b/assets/img/pay-method__yandexmoney.png differ diff --git a/assets/img/payanyway.png b/assets/img/payanyway.png new file mode 100755 index 000000000..fea4e2489 Binary files /dev/null and b/assets/img/payanyway.png differ diff --git a/assets/img/paypal.png b/assets/img/paypal.png new file mode 100755 index 000000000..a8b101b02 Binary files /dev/null and b/assets/img/paypal.png differ diff --git a/assets/img/profile__confirmed.png b/assets/img/profile__confirmed.png new file mode 100755 index 000000000..8fc1b03a3 Binary files /dev/null and b/assets/img/profile__confirmed.png differ diff --git a/assets/img/receipts__separator.png b/assets/img/receipts__separator.png new file mode 100755 index 000000000..4603047b5 Binary files /dev/null and b/assets/img/receipts__separator.png differ diff --git a/assets/img/reviewer.jpg b/assets/img/reviewer.jpg new file mode 100755 index 000000000..63ce87fb7 Binary files /dev/null and b/assets/img/reviewer.jpg differ diff --git a/assets/img/reviews-arrows-sea1675148f.png b/assets/img/reviews-arrows-sea1675148f.png new file mode 100755 index 000000000..489c9b272 Binary files /dev/null and b/assets/img/reviews-arrows-sea1675148f.png differ diff --git a/assets/img/reviews-arrows/next.png b/assets/img/reviews-arrows/next.png new file mode 100755 index 000000000..eb5ba02c9 Binary files /dev/null and b/assets/img/reviews-arrows/next.png differ diff --git a/assets/img/reviews-arrows/prev.png b/assets/img/reviews-arrows/prev.png new file mode 100755 index 000000000..70c170dbb Binary files /dev/null and b/assets/img/reviews-arrows/prev.png differ diff --git a/assets/img/reviews-speech.gif b/assets/img/reviews-speech.gif new file mode 100755 index 000000000..5b6001690 Binary files /dev/null and b/assets/img/reviews-speech.gif differ diff --git a/assets/img/sberbank.png b/assets/img/sberbank.png new file mode 100755 index 000000000..dc7275fa8 Binary files /dev/null and b/assets/img/sberbank.png differ diff --git a/assets/img/sidebar-bg.png b/assets/img/sidebar-bg.png new file mode 100755 index 000000000..5f48419d5 Binary files /dev/null and b/assets/img/sidebar-bg.png differ diff --git a/assets/img/sitetoolbar__logo.en.svg b/assets/img/sitetoolbar__logo.en.svg new file mode 100644 index 000000000..19e42238f --- /dev/null +++ b/assets/img/sitetoolbar__logo.en.svg @@ -0,0 +1,82 @@ + + + + + + + + + + diff --git a/assets/img/sitetoolbar__logo.svg b/assets/img/sitetoolbar__logo.svg new file mode 100755 index 000000000..a13df52c2 --- /dev/null +++ b/assets/img/sitetoolbar__logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/img/sitetoolbar__logo_small.svg b/assets/img/sitetoolbar__logo_small.svg new file mode 100755 index 000000000..330869802 --- /dev/null +++ b/assets/img/sitetoolbar__logo_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/img/userpic/userpic-deleted.png b/assets/img/userpic/userpic-deleted.png new file mode 100755 index 000000000..3f49322e5 Binary files /dev/null and b/assets/img/userpic/userpic-deleted.png differ diff --git a/assets/img/userpic/userpic-deleted.svg b/assets/img/userpic/userpic-deleted.svg new file mode 100755 index 000000000..0bd3b21d2 --- /dev/null +++ b/assets/img/userpic/userpic-deleted.svg @@ -0,0 +1 @@ +GroupCreated with Sketch. \ No newline at end of file diff --git a/assets/img/userpic/userpic.png b/assets/img/userpic/userpic.png new file mode 100755 index 000000000..d2f85b2ee Binary files /dev/null and b/assets/img/userpic/userpic.png differ diff --git a/assets/img/userpic/userpic.svg b/assets/img/userpic/userpic.svg new file mode 100755 index 000000000..d9573eaeb --- /dev/null +++ b/assets/img/userpic/userpic.svg @@ -0,0 +1 @@ +Userpic Default RoundedCreated with Sketch. \ No newline at end of file diff --git a/assets/img/webmoney.png b/assets/img/webmoney.png new file mode 100755 index 000000000..ee7bb9492 Binary files /dev/null and b/assets/img/webmoney.png differ diff --git a/assets/img/x.gif b/assets/img/x.gif new file mode 100755 index 000000000..7c8e9e98f Binary files /dev/null and b/assets/img/x.gif differ diff --git a/assets/img/yamoney.png b/assets/img/yamoney.png new file mode 100755 index 000000000..07d531a7f Binary files /dev/null and b/assets/img/yamoney.png differ diff --git a/assets/lazyimg/1.gif b/assets/lazyimg/1.gif new file mode 100755 index 000000000..a13b1e82e Binary files /dev/null and b/assets/lazyimg/1.gif differ diff --git a/assets/lazyimg/1.jpg b/assets/lazyimg/1.jpg new file mode 100755 index 000000000..f84536a89 Binary files /dev/null and b/assets/lazyimg/1.jpg differ diff --git a/assets/lazyimg/2-1.jpg b/assets/lazyimg/2-1.jpg new file mode 100755 index 000000000..25e08843a Binary files /dev/null and b/assets/lazyimg/2-1.jpg differ diff --git a/assets/lazyimg/2-2.jpg b/assets/lazyimg/2-2.jpg new file mode 100755 index 000000000..44ba334e5 Binary files /dev/null and b/assets/lazyimg/2-2.jpg differ diff --git a/assets/lazyimg/3-1.jpg b/assets/lazyimg/3-1.jpg new file mode 100755 index 000000000..93a1c1f1d Binary files /dev/null and b/assets/lazyimg/3-1.jpg differ diff --git a/assets/lazyimg/3-2.jpg b/assets/lazyimg/3-2.jpg new file mode 100755 index 000000000..1b0987bca Binary files /dev/null and b/assets/lazyimg/3-2.jpg differ diff --git a/assets/lazyimg/4.jpg b/assets/lazyimg/4.jpg new file mode 100755 index 000000000..6c619fd76 Binary files /dev/null and b/assets/lazyimg/4.jpg differ diff --git a/assets/lazyimg/5.jpg b/assets/lazyimg/5.jpg new file mode 100755 index 000000000..ad753f6b1 Binary files /dev/null and b/assets/lazyimg/5.jpg differ diff --git a/assets/lazyimg/6.jpg b/assets/lazyimg/6.jpg new file mode 100755 index 000000000..8100fca48 Binary files /dev/null and b/assets/lazyimg/6.jpg differ diff --git a/assets/lazyimg/7.jpg b/assets/lazyimg/7.jpg new file mode 100755 index 000000000..5a9d056dd Binary files /dev/null and b/assets/lazyimg/7.jpg differ diff --git a/assets/lazyimg/8.jpg b/assets/lazyimg/8.jpg new file mode 100755 index 000000000..a4bdb2a4d Binary files /dev/null and b/assets/lazyimg/8.jpg differ diff --git a/assets/libs/animate.js b/assets/libs/animate.js new file mode 100755 index 000000000..d2ecc4546 --- /dev/null +++ b/assets/libs/animate.js @@ -0,0 +1,20 @@ +function animate(options) { + + var start = performance.now(); + + requestAnimationFrame(function animate(time) { + // timeFraction от 0 до 1 + var timeFraction = (time - start) / options.duration; + if (timeFraction > 1) timeFraction = 1; + + // текущее состояние анимации + var progress = options.timing(timeFraction) + + options.draw(progress); + + if (timeFraction < 1) { + requestAnimationFrame(animate); + } + + }); +} \ No newline at end of file diff --git a/assets/libs/class-extend.js b/assets/libs/class-extend.js new file mode 100755 index 000000000..dba244525 --- /dev/null +++ b/assets/libs/class-extend.js @@ -0,0 +1,108 @@ +/** + * Синтаксис: + * Class.extend(props) + * Class.extend(props, staticProps) + * Class.extend([mixins], props) + * Class.extend([mixins], props, staticProps) +*/ +!function() { + + window.Class = function() { /* вся магия - в Class.extend */ }; + + + Class.extend = function(props, staticProps) { + + var mixins = []; + + // если первый аргумент -- массив, то переназначить аргументы + if ({}.toString.apply(arguments[0]) == "[object Array]") { + mixins = arguments[0]; + props = arguments[1]; + staticProps = arguments[2]; + } + + // эта функция будет возвращена как результат работы extend + function Constructor() { + this.init && this.init.apply(this, arguments); + } + + // this -- это класс "перед точкой", для которого вызван extend (Animal.extend) + // наследуем от него: + Constructor.prototype = Class.inherit(this.prototype); + + // constructor был затёрт вызовом inherit + Constructor.prototype.constructor = Constructor; + + // добавим возможность наследовать дальше + Constructor.extend = Class.extend; + + // скопировать в Constructor статические свойства + copyWrappedProps(staticProps, Constructor, this); + + // скопировать в Constructor.prototype свойства из примесей и props + for (var i = 0; i < mixins.length; i++) { + copyWrappedProps(mixins[i], Constructor.prototype, this.prototype); + } + copyWrappedProps(props, Constructor.prototype, this.prototype); + + return Constructor; + }; + + + //---------- вспомогательные методы ---------- + + // fnTest -- регулярное выражение, + // которое проверяет функцию на то, есть ли в её коде вызов _super + // + // для его объявления мы проверяем, поддерживает ли функция преобразование + // в код вызовом toString: /xyz/.test(function() {xyz}) + // в редких мобильных браузерах -- не поддерживает, поэтому регэксп будет /./ + var fnTest = /xyz/.test(function() {xyz}) ? /\b_super\b/ : /./; + + + // копирует свойства из props в targetPropsObj + // третий аргумент -- это свойства родителя + // + // при копировании, если выясняется что свойство есть и в родителе тоже, + // и является функцией -- его вызов оборачивается в обёртку, + // которая ставит this._super на метод родителя, + // затем вызывает его, затем возвращает this._super + function copyWrappedProps(props, targetPropsObj, parentPropsObj) { + if (!props) return; + + for (var name in props) { + if (typeof props[name] == "function" + && typeof parentPropsObj[name] == "function" + && fnTest.test(props[name])) { + // скопировать, завернув в обёртку + targetPropsObj[name] = wrap(props[name], parentPropsObj[name]); + } else { + targetPropsObj[name] = props[name]; + } + } + + } + + // возвращает обёртку вокруг method, которая ставит this._super на родителя + // и возвращает его потом + function wrap(method, parentMethod) { + return function() { + var backup = this._super; + + this._super = parentMethod; + + try { + return method.apply(this, arguments); + } finally { + this._super = backup; + } + } + } + + // эмуляция Object.create для старых IE + Class.inherit = Object.create || function(proto) { + function F() {} + F.prototype = proto; + return new F; + }; +}(); diff --git a/assets/libs/compareDocumentPosition.js b/assets/libs/compareDocumentPosition.js new file mode 100755 index 000000000..0feca5b45 --- /dev/null +++ b/assets/libs/compareDocumentPosition.js @@ -0,0 +1,28 @@ + +// полифилл для compareDocumentPosition в ie8 + +!function(){ + var el = document.documentElement; + if( !el.compareDocumentPosition && el.sourceIndex !== undefined ){ + + /* ?? + Node = Element; + Node.DOCUMENT_POSITION_DISCONNECTED = 1; + Node.DOCUMENT_POSITION_PRECEDING = 2 + Node.DOCUMENT_POSITION_FOLLOWING = 4; + Node.DOCUMENT_POSITION_CONTAINS = 8; + Node.DOCUMENT_POSITION_CONTAINED_BY = 16; + Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 32; + */ + + Element.prototype.compareDocumentPosition = function(other){ + return (this != other && this.contains(other) && 16) + + (this != other && other.contains(this) && 8) + + (this.sourceIndex >= 0 && other.sourceIndex >= 0 ? + (this.sourceIndex < other.sourceIndex && 4) + + (this.sourceIndex > other.sourceIndex && 2) + : 1 + ) + 0; + } + } +}(); diff --git a/assets/libs/d3.js b/assets/libs/d3.js new file mode 100755 index 000000000..d4cd8bef7 --- /dev/null +++ b/assets/libs/d3.js @@ -0,0 +1,5 @@ +!function(){function n(n){return null!=n&&!isNaN(n)}function t(n){return n.length}function e(n){for(var t=1;n*t%1;)t*=10;return t}function r(n,t){try{for(var e in t)Object.defineProperty(n.prototype,e,{value:t[e],enumerable:!1})}catch(r){n.prototype=t}}function u(){}function i(n){return aa+n in this}function o(n){return n=aa+n,n in this&&delete this[n]}function a(){var n=[];return this.forEach(function(t){n.push(t)}),n}function c(){var n=0;for(var t in this)t.charCodeAt(0)===ca&&++n;return n}function s(){for(var n in this)if(n.charCodeAt(0)===ca)return!1;return!0}function l(){}function f(n,t,e){return function(){var r=e.apply(t,arguments);return r===t?n:r}}function h(n,t){if(t in n)return t;t=t.charAt(0).toUpperCase()+t.substring(1);for(var e=0,r=sa.length;r>e;++e){var u=sa[e]+t;if(u in n)return u}}function g(){}function p(){}function v(n){function t(){for(var t,r=e,u=-1,i=r.length;++ue;e++)for(var u,i=n[e],o=0,a=i.length;a>o;o++)(u=i[o])&&t(u,o,e);return n}function D(n){return fa(n,ya),n}function P(n){var t,e;return function(r,u,i){var o,a=n[i].update,c=a.length;for(i!=e&&(e=i,t=0),u>=t&&(t=u+1);!(o=a[t])&&++t0&&(n=n.substring(0,a));var s=Ma.get(n);return s&&(n=s,c=F),a?t?u:r:t?g:i}function H(n,t){return function(e){var r=Xo.event;Xo.event=e,t[0]=this.__data__;try{n.apply(this,t)}finally{Xo.event=r}}}function F(n,t){var e=H(n,t);return function(n){var t=this,r=n.relatedTarget;r&&(r===t||8&r.compareDocumentPosition(t))||e.call(t,n)}}function O(){var n=".dragsuppress-"+ ++ba,t="click"+n,e=Xo.select(Go).on("touchmove"+n,d).on("dragstart"+n,d).on("selectstart"+n,d);if(_a){var r=Jo.style,u=r[_a];r[_a]="none"}return function(i){function o(){e.on(t,null)}e.on(n,null),_a&&(r[_a]=u),i&&(e.on(t,function(){d(),o()},!0),setTimeout(o,0))}}function Y(n,t){t.changedTouches&&(t=t.changedTouches[0]);var e=n.ownerSVGElement||n;if(e.createSVGPoint){var r=e.createSVGPoint();if(0>wa&&(Go.scrollX||Go.scrollY)){e=Xo.select("body").append("svg").style({position:"absolute",top:0,left:0,margin:0,padding:0,border:"none"},"important");var u=e[0][0].getScreenCTM();wa=!(u.f||u.e),e.remove()}return wa?(r.x=t.pageX,r.y=t.pageY):(r.x=t.clientX,r.y=t.clientY),r=r.matrixTransform(n.getScreenCTM().inverse()),[r.x,r.y]}var i=n.getBoundingClientRect();return[t.clientX-i.left-n.clientLeft,t.clientY-i.top-n.clientTop]}function I(n){return n>0?1:0>n?-1:0}function Z(n,t,e){return(t[0]-n[0])*(e[1]-n[1])-(t[1]-n[1])*(e[0]-n[0])}function V(n){return n>1?0:-1>n?Sa:Math.acos(n)}function X(n){return n>1?Ea:-1>n?-Ea:Math.asin(n)}function $(n){return((n=Math.exp(n))-1/n)/2}function B(n){return((n=Math.exp(n))+1/n)/2}function W(n){return((n=Math.exp(2*n))-1)/(n+1)}function J(n){return(n=Math.sin(n/2))*n}function G(){}function K(n,t,e){return new Q(n,t,e)}function Q(n,t,e){this.h=n,this.s=t,this.l=e}function nt(n,t,e){function r(n){return n>360?n-=360:0>n&&(n+=360),60>n?i+(o-i)*n/60:180>n?o:240>n?i+(o-i)*(240-n)/60:i}function u(n){return Math.round(255*r(n))}var i,o;return n=isNaN(n)?0:(n%=360)<0?n+360:n,t=isNaN(t)?0:0>t?0:t>1?1:t,e=0>e?0:e>1?1:e,o=.5>=e?e*(1+t):e+t-e*t,i=2*e-o,gt(u(n+120),u(n),u(n-120))}function tt(n,t,e){return new et(n,t,e)}function et(n,t,e){this.h=n,this.c=t,this.l=e}function rt(n,t,e){return isNaN(n)&&(n=0),isNaN(t)&&(t=0),ut(e,Math.cos(n*=Na)*t,Math.sin(n)*t)}function ut(n,t,e){return new it(n,t,e)}function it(n,t,e){this.l=n,this.a=t,this.b=e}function ot(n,t,e){var r=(n+16)/116,u=r+t/500,i=r-e/200;return u=ct(u)*Fa,r=ct(r)*Oa,i=ct(i)*Ya,gt(lt(3.2404542*u-1.5371385*r-.4985314*i),lt(-.969266*u+1.8760108*r+.041556*i),lt(.0556434*u-.2040259*r+1.0572252*i))}function at(n,t,e){return n>0?tt(Math.atan2(e,t)*La,Math.sqrt(t*t+e*e),n):tt(0/0,0/0,n)}function ct(n){return n>.206893034?n*n*n:(n-4/29)/7.787037}function st(n){return n>.008856?Math.pow(n,1/3):7.787037*n+4/29}function lt(n){return Math.round(255*(.00304>=n?12.92*n:1.055*Math.pow(n,1/2.4)-.055))}function ft(n){return gt(n>>16,255&n>>8,255&n)}function ht(n){return ft(n)+""}function gt(n,t,e){return new pt(n,t,e)}function pt(n,t,e){this.r=n,this.g=t,this.b=e}function vt(n){return 16>n?"0"+Math.max(0,n).toString(16):Math.min(255,n).toString(16)}function dt(n,t,e){var r,u,i,o=0,a=0,c=0;if(r=/([a-z]+)\((.*)\)/i.exec(n))switch(u=r[2].split(","),r[1]){case"hsl":return e(parseFloat(u[0]),parseFloat(u[1])/100,parseFloat(u[2])/100);case"rgb":return t(Mt(u[0]),Mt(u[1]),Mt(u[2]))}return(i=Va.get(n))?t(i.r,i.g,i.b):(null!=n&&"#"===n.charAt(0)&&(4===n.length?(o=n.charAt(1),o+=o,a=n.charAt(2),a+=a,c=n.charAt(3),c+=c):7===n.length&&(o=n.substring(1,3),a=n.substring(3,5),c=n.substring(5,7)),o=parseInt(o,16),a=parseInt(a,16),c=parseInt(c,16)),t(o,a,c))}function mt(n,t,e){var r,u,i=Math.min(n/=255,t/=255,e/=255),o=Math.max(n,t,e),a=o-i,c=(o+i)/2;return a?(u=.5>c?a/(o+i):a/(2-o-i),r=n==o?(t-e)/a+(e>t?6:0):t==o?(e-n)/a+2:(n-t)/a+4,r*=60):(r=0/0,u=c>0&&1>c?0:r),K(r,u,c)}function yt(n,t,e){n=xt(n),t=xt(t),e=xt(e);var r=st((.4124564*n+.3575761*t+.1804375*e)/Fa),u=st((.2126729*n+.7151522*t+.072175*e)/Oa),i=st((.0193339*n+.119192*t+.9503041*e)/Ya);return ut(116*u-16,500*(r-u),200*(u-i))}function xt(n){return(n/=255)<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4)}function Mt(n){var t=parseFloat(n);return"%"===n.charAt(n.length-1)?Math.round(2.55*t):t}function _t(n){return"function"==typeof n?n:function(){return n}}function bt(n){return n}function wt(n){return function(t,e,r){return 2===arguments.length&&"function"==typeof e&&(r=e,e=null),St(t,e,n,r)}}function St(n,t,e,r){function u(){var n,t=c.status;if(!t&&c.responseText||t>=200&&300>t||304===t){try{n=e.call(i,c)}catch(r){return o.error.call(i,r),void 0}o.load.call(i,n)}else o.error.call(i,c)}var i={},o=Xo.dispatch("beforesend","progress","load","error"),a={},c=new XMLHttpRequest,s=null;return!Go.XDomainRequest||"withCredentials"in c||!/^(http(s)?:)?\/\//.test(n)||(c=new XDomainRequest),"onload"in c?c.onload=c.onerror=u:c.onreadystatechange=function(){c.readyState>3&&u()},c.onprogress=function(n){var t=Xo.event;Xo.event=n;try{o.progress.call(i,c)}finally{Xo.event=t}},i.header=function(n,t){return n=(n+"").toLowerCase(),arguments.length<2?a[n]:(null==t?delete a[n]:a[n]=t+"",i)},i.mimeType=function(n){return arguments.length?(t=null==n?null:n+"",i):t},i.responseType=function(n){return arguments.length?(s=n,i):s},i.response=function(n){return e=n,i},["get","post"].forEach(function(n){i[n]=function(){return i.send.apply(i,[n].concat(Bo(arguments)))}}),i.send=function(e,r,u){if(2===arguments.length&&"function"==typeof r&&(u=r,r=null),c.open(e,n,!0),null==t||"accept"in a||(a.accept=t+",*/*"),c.setRequestHeader)for(var l in a)c.setRequestHeader(l,a[l]);return null!=t&&c.overrideMimeType&&c.overrideMimeType(t),null!=s&&(c.responseType=s),null!=u&&i.on("error",u).on("load",function(n){u(null,n)}),o.beforesend.call(i,c),c.send(null==r?null:r),i},i.abort=function(){return c.abort(),i},Xo.rebind(i,o,"on"),null==r?i:i.get(kt(r))}function kt(n){return 1===n.length?function(t,e){n(null==t?e:null)}:n}function Et(){var n=At(),t=Ct()-n;t>24?(isFinite(t)&&(clearTimeout(Wa),Wa=setTimeout(Et,t)),Ba=0):(Ba=1,Ga(Et))}function At(){var n=Date.now();for(Ja=Xa;Ja;)n>=Ja.t&&(Ja.f=Ja.c(n-Ja.t)),Ja=Ja.n;return n}function Ct(){for(var n,t=Xa,e=1/0;t;)t.f?t=n?n.n=t.n:Xa=t.n:(t.t8?function(n){return n/e}:function(n){return n*e},symbol:n}}function zt(n){var t=n.decimal,e=n.thousands,r=n.grouping,u=n.currency,i=r?function(n){for(var t=n.length,u=[],i=0,o=r[0];t>0&&o>0;)u.push(n.substring(t-=o,t+o)),o=r[i=(i+1)%r.length];return u.reverse().join(e)}:bt;return function(n){var e=Qa.exec(n),r=e[1]||" ",o=e[2]||">",a=e[3]||"",c=e[4]||"",s=e[5],l=+e[6],f=e[7],h=e[8],g=e[9],p=1,v="",d="",m=!1;switch(h&&(h=+h.substring(1)),(s||"0"===r&&"="===o)&&(s=r="0",o="=",f&&(l-=Math.floor((l-1)/4))),g){case"n":f=!0,g="g";break;case"%":p=100,d="%",g="f";break;case"p":p=100,d="%",g="r";break;case"b":case"o":case"x":case"X":"#"===c&&(v="0"+g.toLowerCase());case"c":case"d":m=!0,h=0;break;case"s":p=-1,g="r"}"$"===c&&(v=u[0],d=u[1]),"r"!=g||h||(g="g"),null!=h&&("g"==g?h=Math.max(1,Math.min(21,h)):("e"==g||"f"==g)&&(h=Math.max(0,Math.min(20,h)))),g=nc.get(g)||qt;var y=s&&f;return function(n){var e=d;if(m&&n%1)return"";var u=0>n||0===n&&0>1/n?(n=-n,"-"):a;if(0>p){var c=Xo.formatPrefix(n,h);n=c.scale(n),e=c.symbol+d}else n*=p;n=g(n,h);var x=n.lastIndexOf("."),M=0>x?n:n.substring(0,x),_=0>x?"":t+n.substring(x+1);!s&&f&&(M=i(M));var b=v.length+M.length+_.length+(y?0:u.length),w=l>b?new Array(b=l-b+1).join(r):"";return y&&(M=i(w+M)),u+=v,n=M+_,("<"===o?u+n+w:">"===o?w+u+n:"^"===o?w.substring(0,b>>=1)+u+n+w.substring(b):u+(y?n:w+n))+e}}}function qt(n){return n+""}function Tt(){this._=new Date(arguments.length>1?Date.UTC.apply(this,arguments):arguments[0])}function Rt(n,t,e){function r(t){var e=n(t),r=i(e,1);return r-t>t-e?e:r}function u(e){return t(e=n(new ec(e-1)),1),e}function i(n,e){return t(n=new ec(+n),e),n}function o(n,r,i){var o=u(n),a=[];if(i>1)for(;r>o;)e(o)%i||a.push(new Date(+o)),t(o,1);else for(;r>o;)a.push(new Date(+o)),t(o,1);return a}function a(n,t,e){try{ec=Tt;var r=new Tt;return r._=n,o(r,t,e)}finally{ec=Date}}n.floor=n,n.round=r,n.ceil=u,n.offset=i,n.range=o;var c=n.utc=Dt(n);return c.floor=c,c.round=Dt(r),c.ceil=Dt(u),c.offset=Dt(i),c.range=a,n}function Dt(n){return function(t,e){try{ec=Tt;var r=new Tt;return r._=t,n(r,e)._}finally{ec=Date}}}function Pt(n){function t(n){function t(t){for(var e,u,i,o=[],a=-1,c=0;++aa;){if(r>=s)return-1;if(u=t.charCodeAt(a++),37===u){if(o=t.charAt(a++),i=N[o in uc?t.charAt(a++):o],!i||(r=i(n,e,r))<0)return-1}else if(u!=e.charCodeAt(r++))return-1}return r}function r(n,t,e){b.lastIndex=0;var r=b.exec(t.substring(e));return r?(n.w=w.get(r[0].toLowerCase()),e+r[0].length):-1}function u(n,t,e){M.lastIndex=0;var r=M.exec(t.substring(e));return r?(n.w=_.get(r[0].toLowerCase()),e+r[0].length):-1}function i(n,t,e){E.lastIndex=0;var r=E.exec(t.substring(e));return r?(n.m=A.get(r[0].toLowerCase()),e+r[0].length):-1}function o(n,t,e){S.lastIndex=0;var r=S.exec(t.substring(e));return r?(n.m=k.get(r[0].toLowerCase()),e+r[0].length):-1}function a(n,t,r){return e(n,C.c.toString(),t,r)}function c(n,t,r){return e(n,C.x.toString(),t,r)}function s(n,t,r){return e(n,C.X.toString(),t,r)}function l(n,t,e){var r=x.get(t.substring(e,e+=2).toLowerCase());return null==r?-1:(n.p=r,e)}var f=n.dateTime,h=n.date,g=n.time,p=n.periods,v=n.days,d=n.shortDays,m=n.months,y=n.shortMonths;t.utc=function(n){function e(n){try{ec=Tt;var t=new ec;return t._=n,r(t)}finally{ec=Date}}var r=t(n);return e.parse=function(n){try{ec=Tt;var t=r.parse(n);return t&&t._}finally{ec=Date}},e.toString=r.toString,e},t.multi=t.utc.multi=ee;var x=Xo.map(),M=jt(v),_=Ht(v),b=jt(d),w=Ht(d),S=jt(m),k=Ht(m),E=jt(y),A=Ht(y);p.forEach(function(n,t){x.set(n.toLowerCase(),t)});var C={a:function(n){return d[n.getDay()]},A:function(n){return v[n.getDay()]},b:function(n){return y[n.getMonth()]},B:function(n){return m[n.getMonth()]},c:t(f),d:function(n,t){return Ut(n.getDate(),t,2)},e:function(n,t){return Ut(n.getDate(),t,2)},H:function(n,t){return Ut(n.getHours(),t,2)},I:function(n,t){return Ut(n.getHours()%12||12,t,2)},j:function(n,t){return Ut(1+tc.dayOfYear(n),t,3)},L:function(n,t){return Ut(n.getMilliseconds(),t,3)},m:function(n,t){return Ut(n.getMonth()+1,t,2)},M:function(n,t){return Ut(n.getMinutes(),t,2)},p:function(n){return p[+(n.getHours()>=12)]},S:function(n,t){return Ut(n.getSeconds(),t,2)},U:function(n,t){return Ut(tc.sundayOfYear(n),t,2)},w:function(n){return n.getDay()},W:function(n,t){return Ut(tc.mondayOfYear(n),t,2)},x:t(h),X:t(g),y:function(n,t){return Ut(n.getFullYear()%100,t,2)},Y:function(n,t){return Ut(n.getFullYear()%1e4,t,4)},Z:ne,"%":function(){return"%"}},N={a:r,A:u,b:i,B:o,c:a,d:Bt,e:Bt,H:Jt,I:Jt,j:Wt,L:Qt,m:$t,M:Gt,p:l,S:Kt,U:Ot,w:Ft,W:Yt,x:c,X:s,y:Zt,Y:It,Z:Vt,"%":te};return t}function Ut(n,t,e){var r=0>n?"-":"",u=(r?-n:n)+"",i=u.length;return r+(e>i?new Array(e-i+1).join(t)+u:u)}function jt(n){return new RegExp("^(?:"+n.map(Xo.requote).join("|")+")","i")}function Ht(n){for(var t=new u,e=-1,r=n.length;++e68?1900:2e3)}function $t(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.m=r[0]-1,e+r[0].length):-1}function Bt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.d=+r[0],e+r[0].length):-1}function Wt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+3));return r?(n.j=+r[0],e+r[0].length):-1}function Jt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.H=+r[0],e+r[0].length):-1}function Gt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.M=+r[0],e+r[0].length):-1}function Kt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.S=+r[0],e+r[0].length):-1}function Qt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+3));return r?(n.L=+r[0],e+r[0].length):-1}function ne(n){var t=n.getTimezoneOffset(),e=t>0?"-":"+",r=~~(oa(t)/60),u=oa(t)%60;return e+Ut(r,"0",2)+Ut(u,"0",2)}function te(n,t,e){oc.lastIndex=0;var r=oc.exec(t.substring(e,e+1));return r?e+r[0].length:-1}function ee(n){for(var t=n.length,e=-1;++e=0?1:-1,a=o*e,c=Math.cos(t),s=Math.sin(t),l=i*s,f=u*c+l*Math.cos(a),h=l*o*Math.sin(a);hc.add(Math.atan2(h,f)),r=n,u=c,i=s}var t,e,r,u,i;gc.point=function(o,a){gc.point=n,r=(t=o)*Na,u=Math.cos(a=(e=a)*Na/2+Sa/4),i=Math.sin(a)},gc.lineEnd=function(){n(t,e)}}function se(n){var t=n[0],e=n[1],r=Math.cos(e);return[r*Math.cos(t),r*Math.sin(t),Math.sin(e)]}function le(n,t){return n[0]*t[0]+n[1]*t[1]+n[2]*t[2]}function fe(n,t){return[n[1]*t[2]-n[2]*t[1],n[2]*t[0]-n[0]*t[2],n[0]*t[1]-n[1]*t[0]]}function he(n,t){n[0]+=t[0],n[1]+=t[1],n[2]+=t[2]}function ge(n,t){return[n[0]*t,n[1]*t,n[2]*t]}function pe(n){var t=Math.sqrt(n[0]*n[0]+n[1]*n[1]+n[2]*n[2]);n[0]/=t,n[1]/=t,n[2]/=t}function ve(n){return[Math.atan2(n[1],n[0]),X(n[2])]}function de(n,t){return oa(n[0]-t[0])a;++a)u.point((e=n[a])[0],e[1]);return u.lineEnd(),void 0}var c=new ke(e,n,null,!0),s=new ke(e,null,c,!1);c.o=s,i.push(c),o.push(s),c=new ke(r,n,null,!1),s=new ke(r,null,c,!0),c.o=s,i.push(c),o.push(s)}}),o.sort(t),Se(i),Se(o),i.length){for(var a=0,c=e,s=o.length;s>a;++a)o[a].e=c=!c;for(var l,f,h=i[0];;){for(var g=h,p=!0;g.v;)if((g=g.n)===h)return;l=g.z,u.lineStart();do{if(g.v=g.o.v=!0,g.e){if(p)for(var a=0,s=l.length;s>a;++a)u.point((f=l[a])[0],f[1]);else r(g.x,g.n.x,1,u);g=g.n}else{if(p){l=g.p.z;for(var a=l.length-1;a>=0;--a)u.point((f=l[a])[0],f[1])}else r(g.x,g.p.x,-1,u);g=g.p}g=g.o,l=g.z,p=!p}while(!g.v);u.lineEnd()}}}function Se(n){if(t=n.length){for(var t,e,r=0,u=n[0];++r1&&2&t&&e.push(e.pop().concat(e.shift())),g.push(e.filter(Ae))}}var g,p,v,d=t(i),m=u.invert(r[0],r[1]),y={point:o,lineStart:c,lineEnd:s,polygonStart:function(){y.point=l,y.lineStart=f,y.lineEnd=h,g=[],p=[],i.polygonStart()},polygonEnd:function(){y.point=o,y.lineStart=c,y.lineEnd=s,g=Xo.merge(g);var n=Le(m,p);g.length?we(g,Ne,n,e,i):n&&(i.lineStart(),e(null,null,1,i),i.lineEnd()),i.polygonEnd(),g=p=null},sphere:function(){i.polygonStart(),i.lineStart(),e(null,null,1,i),i.lineEnd(),i.polygonEnd()}},x=Ce(),M=t(x);return y}}function Ae(n){return n.length>1}function Ce(){var n,t=[];return{lineStart:function(){t.push(n=[])},point:function(t,e){n.push([t,e])},lineEnd:g,buffer:function(){var e=t;return t=[],n=null,e},rejoin:function(){t.length>1&&t.push(t.pop().concat(t.shift()))}}}function Ne(n,t){return((n=n.x)[0]<0?n[1]-Ea-Aa:Ea-n[1])-((t=t.x)[0]<0?t[1]-Ea-Aa:Ea-t[1])}function Le(n,t){var e=n[0],r=n[1],u=[Math.sin(e),-Math.cos(e),0],i=0,o=0;hc.reset();for(var a=0,c=t.length;c>a;++a){var s=t[a],l=s.length;if(l)for(var f=s[0],h=f[0],g=f[1]/2+Sa/4,p=Math.sin(g),v=Math.cos(g),d=1;;){d===l&&(d=0),n=s[d];var m=n[0],y=n[1]/2+Sa/4,x=Math.sin(y),M=Math.cos(y),_=m-h,b=_>=0?1:-1,w=b*_,S=w>Sa,k=p*x;if(hc.add(Math.atan2(k*b*Math.sin(w),v*M+k*Math.cos(w))),i+=S?_+b*ka:_,S^h>=e^m>=e){var E=fe(se(f),se(n));pe(E);var A=fe(u,E);pe(A);var C=(S^_>=0?-1:1)*X(A[2]);(r>C||r===C&&(E[0]||E[1]))&&(o+=S^_>=0?1:-1)}if(!d++)break;h=m,p=x,v=M,f=n}}return(-Aa>i||Aa>i&&0>hc)^1&o}function ze(n){var t,e=0/0,r=0/0,u=0/0;return{lineStart:function(){n.lineStart(),t=1},point:function(i,o){var a=i>0?Sa:-Sa,c=oa(i-e);oa(c-Sa)0?Ea:-Ea),n.point(u,r),n.lineEnd(),n.lineStart(),n.point(a,r),n.point(i,r),t=0):u!==a&&c>=Sa&&(oa(e-u)Aa?Math.atan((Math.sin(t)*(i=Math.cos(r))*Math.sin(e)-Math.sin(r)*(u=Math.cos(t))*Math.sin(n))/(u*i*o)):(t+r)/2}function Te(n,t,e,r){var u;if(null==n)u=e*Ea,r.point(-Sa,u),r.point(0,u),r.point(Sa,u),r.point(Sa,0),r.point(Sa,-u),r.point(0,-u),r.point(-Sa,-u),r.point(-Sa,0),r.point(-Sa,u);else if(oa(n[0]-t[0])>Aa){var i=n[0]i}function e(n){var e,i,c,s,l;return{lineStart:function(){s=c=!1,l=1},point:function(f,h){var g,p=[f,h],v=t(f,h),d=o?v?0:u(f,h):v?u(f+(0>f?Sa:-Sa),h):0;if(!e&&(s=c=v)&&n.lineStart(),v!==c&&(g=r(e,p),(de(e,g)||de(p,g))&&(p[0]+=Aa,p[1]+=Aa,v=t(p[0],p[1]))),v!==c)l=0,v?(n.lineStart(),g=r(p,e),n.point(g[0],g[1])):(g=r(e,p),n.point(g[0],g[1]),n.lineEnd()),e=g;else if(a&&e&&o^v){var m;d&i||!(m=r(p,e,!0))||(l=0,o?(n.lineStart(),n.point(m[0][0],m[0][1]),n.point(m[1][0],m[1][1]),n.lineEnd()):(n.point(m[1][0],m[1][1]),n.lineEnd(),n.lineStart(),n.point(m[0][0],m[0][1])))}!v||e&&de(e,p)||n.point(p[0],p[1]),e=p,c=v,i=d},lineEnd:function(){c&&n.lineEnd(),e=null},clean:function(){return l|(s&&c)<<1}}}function r(n,t,e){var r=se(n),u=se(t),o=[1,0,0],a=fe(r,u),c=le(a,a),s=a[0],l=c-s*s;if(!l)return!e&&n;var f=i*c/l,h=-i*s/l,g=fe(o,a),p=ge(o,f),v=ge(a,h);he(p,v);var d=g,m=le(p,d),y=le(d,d),x=m*m-y*(le(p,p)-1);if(!(0>x)){var M=Math.sqrt(x),_=ge(d,(-m-M)/y);if(he(_,p),_=ve(_),!e)return _;var b,w=n[0],S=t[0],k=n[1],E=t[1];w>S&&(b=w,w=S,S=b);var A=S-w,C=oa(A-Sa)A;if(!C&&k>E&&(b=k,k=E,E=b),N?C?k+E>0^_[1]<(oa(_[0]-w)Sa^(w<=_[0]&&_[0]<=S)){var L=ge(d,(-m+M)/y);return he(L,p),[_,ve(L)]}}}function u(t,e){var r=o?n:Sa-n,u=0;return-r>t?u|=1:t>r&&(u|=2),-r>e?u|=4:e>r&&(u|=8),u}var i=Math.cos(n),o=i>0,a=oa(i)>Aa,c=cr(n,6*Na);return Ee(t,e,c,o?[0,-n]:[-Sa,n-Sa])}function De(n,t,e,r){return function(u){var i,o=u.a,a=u.b,c=o.x,s=o.y,l=a.x,f=a.y,h=0,g=1,p=l-c,v=f-s;if(i=n-c,p||!(i>0)){if(i/=p,0>p){if(h>i)return;g>i&&(g=i)}else if(p>0){if(i>g)return;i>h&&(h=i)}if(i=e-c,p||!(0>i)){if(i/=p,0>p){if(i>g)return;i>h&&(h=i)}else if(p>0){if(h>i)return;g>i&&(g=i)}if(i=t-s,v||!(i>0)){if(i/=v,0>v){if(h>i)return;g>i&&(g=i)}else if(v>0){if(i>g)return;i>h&&(h=i)}if(i=r-s,v||!(0>i)){if(i/=v,0>v){if(i>g)return;i>h&&(h=i)}else if(v>0){if(h>i)return;g>i&&(g=i)}return h>0&&(u.a={x:c+h*p,y:s+h*v}),1>g&&(u.b={x:c+g*p,y:s+g*v}),u}}}}}}function Pe(n,t,e,r){function u(r,u){return oa(r[0]-n)0?0:3:oa(r[0]-e)0?2:1:oa(r[1]-t)0?1:0:u>0?3:2}function i(n,t){return o(n.x,t.x)}function o(n,t){var e=u(n,1),r=u(t,1);return e!==r?e-r:0===e?t[1]-n[1]:1===e?n[0]-t[0]:2===e?n[1]-t[1]:t[0]-n[0]}return function(a){function c(n){for(var t=0,e=d.length,r=n[1],u=0;e>u;++u)for(var i,o=1,a=d[u],c=a.length,s=a[0];c>o;++o)i=a[o],s[1]<=r?i[1]>r&&Z(s,i,n)>0&&++t:i[1]<=r&&Z(s,i,n)<0&&--t,s=i;return 0!==t}function s(i,a,c,s){var l=0,f=0;if(null==i||(l=u(i,c))!==(f=u(a,c))||o(i,a)<0^c>0){do s.point(0===l||3===l?n:e,l>1?r:t);while((l=(l+c+4)%4)!==f)}else s.point(a[0],a[1])}function l(u,i){return u>=n&&e>=u&&i>=t&&r>=i}function f(n,t){l(n,t)&&a.point(n,t)}function h(){N.point=p,d&&d.push(m=[]),S=!0,w=!1,_=b=0/0}function g(){v&&(p(y,x),M&&w&&A.rejoin(),v.push(A.buffer())),N.point=f,w&&a.lineEnd()}function p(n,t){n=Math.max(-Ac,Math.min(Ac,n)),t=Math.max(-Ac,Math.min(Ac,t));var e=l(n,t);if(d&&m.push([n,t]),S)y=n,x=t,M=e,S=!1,e&&(a.lineStart(),a.point(n,t));else if(e&&w)a.point(n,t);else{var r={a:{x:_,y:b},b:{x:n,y:t}};C(r)?(w||(a.lineStart(),a.point(r.a.x,r.a.y)),a.point(r.b.x,r.b.y),e||a.lineEnd(),k=!1):e&&(a.lineStart(),a.point(n,t),k=!1)}_=n,b=t,w=e}var v,d,m,y,x,M,_,b,w,S,k,E=a,A=Ce(),C=De(n,t,e,r),N={point:f,lineStart:h,lineEnd:g,polygonStart:function(){a=A,v=[],d=[],k=!0},polygonEnd:function(){a=E,v=Xo.merge(v);var t=c([n,r]),e=k&&t,u=v.length;(e||u)&&(a.polygonStart(),e&&(a.lineStart(),s(null,null,1,a),a.lineEnd()),u&&we(v,i,t,s,a),a.polygonEnd()),v=d=m=null}};return N}}function Ue(n,t){function e(e,r){return e=n(e,r),t(e[0],e[1])}return n.invert&&t.invert&&(e.invert=function(e,r){return e=t.invert(e,r),e&&n.invert(e[0],e[1])}),e}function je(n){var t=0,e=Sa/3,r=nr(n),u=r(t,e);return u.parallels=function(n){return arguments.length?r(t=n[0]*Sa/180,e=n[1]*Sa/180):[180*(t/Sa),180*(e/Sa)]},u}function He(n,t){function e(n,t){var e=Math.sqrt(i-2*u*Math.sin(t))/u;return[e*Math.sin(n*=u),o-e*Math.cos(n)]}var r=Math.sin(n),u=(r+Math.sin(t))/2,i=1+r*(2*u-r),o=Math.sqrt(i)/u;return e.invert=function(n,t){var e=o-t;return[Math.atan2(n,e)/u,X((i-(n*n+e*e)*u*u)/(2*u))]},e}function Fe(){function n(n,t){Nc+=u*n-r*t,r=n,u=t}var t,e,r,u;Rc.point=function(i,o){Rc.point=n,t=r=i,e=u=o},Rc.lineEnd=function(){n(t,e)}}function Oe(n,t){Lc>n&&(Lc=n),n>qc&&(qc=n),zc>t&&(zc=t),t>Tc&&(Tc=t)}function Ye(){function n(n,t){o.push("M",n,",",t,i)}function t(n,t){o.push("M",n,",",t),a.point=e}function e(n,t){o.push("L",n,",",t)}function r(){a.point=n}function u(){o.push("Z")}var i=Ie(4.5),o=[],a={point:n,lineStart:function(){a.point=t},lineEnd:r,polygonStart:function(){a.lineEnd=u},polygonEnd:function(){a.lineEnd=r,a.point=n},pointRadius:function(n){return i=Ie(n),a},result:function(){if(o.length){var n=o.join("");return o=[],n}}};return a}function Ie(n){return"m0,"+n+"a"+n+","+n+" 0 1,1 0,"+-2*n+"a"+n+","+n+" 0 1,1 0,"+2*n+"z"}function Ze(n,t){dc+=n,mc+=t,++yc}function Ve(){function n(n,r){var u=n-t,i=r-e,o=Math.sqrt(u*u+i*i);xc+=o*(t+n)/2,Mc+=o*(e+r)/2,_c+=o,Ze(t=n,e=r)}var t,e;Pc.point=function(r,u){Pc.point=n,Ze(t=r,e=u)}}function Xe(){Pc.point=Ze}function $e(){function n(n,t){var e=n-r,i=t-u,o=Math.sqrt(e*e+i*i);xc+=o*(r+n)/2,Mc+=o*(u+t)/2,_c+=o,o=u*n-r*t,bc+=o*(r+n),wc+=o*(u+t),Sc+=3*o,Ze(r=n,u=t)}var t,e,r,u;Pc.point=function(i,o){Pc.point=n,Ze(t=r=i,e=u=o)},Pc.lineEnd=function(){n(t,e)}}function Be(n){function t(t,e){n.moveTo(t,e),n.arc(t,e,o,0,ka)}function e(t,e){n.moveTo(t,e),a.point=r}function r(t,e){n.lineTo(t,e)}function u(){a.point=t}function i(){n.closePath()}var o=4.5,a={point:t,lineStart:function(){a.point=e},lineEnd:u,polygonStart:function(){a.lineEnd=i},polygonEnd:function(){a.lineEnd=u,a.point=t},pointRadius:function(n){return o=n,a},result:g};return a}function We(n){function t(n){return(a?r:e)(n)}function e(t){return Ke(t,function(e,r){e=n(e,r),t.point(e[0],e[1])})}function r(t){function e(e,r){e=n(e,r),t.point(e[0],e[1])}function r(){x=0/0,S.point=i,t.lineStart()}function i(e,r){var i=se([e,r]),o=n(e,r);u(x,M,y,_,b,w,x=o[0],M=o[1],y=e,_=i[0],b=i[1],w=i[2],a,t),t.point(x,M)}function o(){S.point=e,t.lineEnd()}function c(){r(),S.point=s,S.lineEnd=l}function s(n,t){i(f=n,h=t),g=x,p=M,v=_,d=b,m=w,S.point=i}function l(){u(x,M,y,_,b,w,g,p,f,v,d,m,a,t),S.lineEnd=o,o()}var f,h,g,p,v,d,m,y,x,M,_,b,w,S={point:e,lineStart:r,lineEnd:o,polygonStart:function(){t.polygonStart(),S.lineStart=c},polygonEnd:function(){t.polygonEnd(),S.lineStart=r}};return S}function u(t,e,r,a,c,s,l,f,h,g,p,v,d,m){var y=l-t,x=f-e,M=y*y+x*x;if(M>4*i&&d--){var _=a+g,b=c+p,w=s+v,S=Math.sqrt(_*_+b*b+w*w),k=Math.asin(w/=S),E=oa(oa(w)-1)i||oa((y*L+x*z)/M-.5)>.3||o>a*g+c*p+s*v)&&(u(t,e,r,a,c,s,C,N,E,_/=S,b/=S,w,d,m),m.point(C,N),u(C,N,E,_,b,w,l,f,h,g,p,v,d,m))}}var i=.5,o=Math.cos(30*Na),a=16;return t.precision=function(n){return arguments.length?(a=(i=n*n)>0&&16,t):Math.sqrt(i)},t}function Je(n){var t=We(function(t,e){return n([t*La,e*La])});return function(n){return tr(t(n))}}function Ge(n){this.stream=n}function Ke(n,t){return{point:t,sphere:function(){n.sphere()},lineStart:function(){n.lineStart()},lineEnd:function(){n.lineEnd()},polygonStart:function(){n.polygonStart()},polygonEnd:function(){n.polygonEnd()}}}function Qe(n){return nr(function(){return n})()}function nr(n){function t(n){return n=a(n[0]*Na,n[1]*Na),[n[0]*h+c,s-n[1]*h]}function e(n){return n=a.invert((n[0]-c)/h,(s-n[1])/h),n&&[n[0]*La,n[1]*La]}function r(){a=Ue(o=ur(m,y,x),i);var n=i(v,d);return c=g-n[0]*h,s=p+n[1]*h,u()}function u(){return l&&(l.valid=!1,l=null),t}var i,o,a,c,s,l,f=We(function(n,t){return n=i(n,t),[n[0]*h+c,s-n[1]*h]}),h=150,g=480,p=250,v=0,d=0,m=0,y=0,x=0,M=Ec,_=bt,b=null,w=null;return t.stream=function(n){return l&&(l.valid=!1),l=tr(M(o,f(_(n)))),l.valid=!0,l +},t.clipAngle=function(n){return arguments.length?(M=null==n?(b=n,Ec):Re((b=+n)*Na),u()):b},t.clipExtent=function(n){return arguments.length?(w=n,_=n?Pe(n[0][0],n[0][1],n[1][0],n[1][1]):bt,u()):w},t.scale=function(n){return arguments.length?(h=+n,r()):h},t.translate=function(n){return arguments.length?(g=+n[0],p=+n[1],r()):[g,p]},t.center=function(n){return arguments.length?(v=n[0]%360*Na,d=n[1]%360*Na,r()):[v*La,d*La]},t.rotate=function(n){return arguments.length?(m=n[0]%360*Na,y=n[1]%360*Na,x=n.length>2?n[2]%360*Na:0,r()):[m*La,y*La,x*La]},Xo.rebind(t,f,"precision"),function(){return i=n.apply(this,arguments),t.invert=i.invert&&e,r()}}function tr(n){return Ke(n,function(t,e){n.point(t*Na,e*Na)})}function er(n,t){return[n,t]}function rr(n,t){return[n>Sa?n-ka:-Sa>n?n+ka:n,t]}function ur(n,t,e){return n?t||e?Ue(or(n),ar(t,e)):or(n):t||e?ar(t,e):rr}function ir(n){return function(t,e){return t+=n,[t>Sa?t-ka:-Sa>t?t+ka:t,e]}}function or(n){var t=ir(n);return t.invert=ir(-n),t}function ar(n,t){function e(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,s=Math.sin(t),l=s*r+a*u;return[Math.atan2(c*i-l*o,a*r-s*u),X(l*i+c*o)]}var r=Math.cos(n),u=Math.sin(n),i=Math.cos(t),o=Math.sin(t);return e.invert=function(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,s=Math.sin(t),l=s*i-c*o;return[Math.atan2(c*i+s*o,a*r+l*u),X(l*r-a*u)]},e}function cr(n,t){var e=Math.cos(n),r=Math.sin(n);return function(u,i,o,a){var c=o*t;null!=u?(u=sr(e,u),i=sr(e,i),(o>0?i>u:u>i)&&(u+=o*ka)):(u=n+o*ka,i=n-.5*c);for(var s,l=u;o>0?l>i:i>l;l-=c)a.point((s=ve([e,-r*Math.cos(l),-r*Math.sin(l)]))[0],s[1])}}function sr(n,t){var e=se(t);e[0]-=n,pe(e);var r=V(-e[1]);return((-e[2]<0?-r:r)+2*Math.PI-Aa)%(2*Math.PI)}function lr(n,t,e){var r=Xo.range(n,t-Aa,e).concat(t);return function(n){return r.map(function(t){return[n,t]})}}function fr(n,t,e){var r=Xo.range(n,t-Aa,e).concat(t);return function(n){return r.map(function(t){return[t,n]})}}function hr(n){return n.source}function gr(n){return n.target}function pr(n,t,e,r){var u=Math.cos(t),i=Math.sin(t),o=Math.cos(r),a=Math.sin(r),c=u*Math.cos(n),s=u*Math.sin(n),l=o*Math.cos(e),f=o*Math.sin(e),h=2*Math.asin(Math.sqrt(J(r-t)+u*o*J(e-n))),g=1/Math.sin(h),p=h?function(n){var t=Math.sin(n*=h)*g,e=Math.sin(h-n)*g,r=e*c+t*l,u=e*s+t*f,o=e*i+t*a;return[Math.atan2(u,r)*La,Math.atan2(o,Math.sqrt(r*r+u*u))*La]}:function(){return[n*La,t*La]};return p.distance=h,p}function vr(){function n(n,u){var i=Math.sin(u*=Na),o=Math.cos(u),a=oa((n*=Na)-t),c=Math.cos(a);Uc+=Math.atan2(Math.sqrt((a=o*Math.sin(a))*a+(a=r*i-e*o*c)*a),e*i+r*o*c),t=n,e=i,r=o}var t,e,r;jc.point=function(u,i){t=u*Na,e=Math.sin(i*=Na),r=Math.cos(i),jc.point=n},jc.lineEnd=function(){jc.point=jc.lineEnd=g}}function dr(n,t){function e(t,e){var r=Math.cos(t),u=Math.cos(e),i=n(r*u);return[i*u*Math.sin(t),i*Math.sin(e)]}return e.invert=function(n,e){var r=Math.sqrt(n*n+e*e),u=t(r),i=Math.sin(u),o=Math.cos(u);return[Math.atan2(n*i,r*o),Math.asin(r&&e*i/r)]},e}function mr(n,t){function e(n,t){var e=oa(oa(t)-Ea)u;u++){for(;r>1&&Z(n[e[r-2]],n[e[r-1]],n[u])<=0;)--r;e[r++]=u}return e.slice(0,r)}function kr(n,t){return n[0]-t[0]||n[1]-t[1]}function Er(n,t,e){return(e[0]-t[0])*(n[1]-t[1])<(e[1]-t[1])*(n[0]-t[0])}function Ar(n,t,e,r){var u=n[0],i=e[0],o=t[0]-u,a=r[0]-i,c=n[1],s=e[1],l=t[1]-c,f=r[1]-s,h=(a*(c-s)-f*(u-i))/(f*o-a*l);return[u+h*o,c+h*l]}function Cr(n){var t=n[0],e=n[n.length-1];return!(t[0]-e[0]||t[1]-e[1])}function Nr(){Jr(this),this.edge=this.site=this.circle=null}function Lr(n){var t=Jc.pop()||new Nr;return t.site=n,t}function zr(n){Or(n),$c.remove(n),Jc.push(n),Jr(n)}function qr(n){var t=n.circle,e=t.x,r=t.cy,u={x:e,y:r},i=n.P,o=n.N,a=[n];zr(n);for(var c=i;c.circle&&oa(e-c.circle.x)l;++l)s=a[l],c=a[l-1],$r(s.edge,c.site,s.site,u);c=a[0],s=a[f-1],s.edge=Vr(c.site,s.site,null,u),Fr(c),Fr(s)}function Tr(n){for(var t,e,r,u,i=n.x,o=n.y,a=$c._;a;)if(r=Rr(a,o)-i,r>Aa)a=a.L;else{if(u=i-Dr(a,o),!(u>Aa)){r>-Aa?(t=a.P,e=a):u>-Aa?(t=a,e=a.N):t=e=a;break}if(!a.R){t=a;break}a=a.R}var c=Lr(n);if($c.insert(t,c),t||e){if(t===e)return Or(t),e=Lr(t.site),$c.insert(c,e),c.edge=e.edge=Vr(t.site,c.site),Fr(t),Fr(e),void 0;if(!e)return c.edge=Vr(t.site,c.site),void 0;Or(t),Or(e);var s=t.site,l=s.x,f=s.y,h=n.x-l,g=n.y-f,p=e.site,v=p.x-l,d=p.y-f,m=2*(h*d-g*v),y=h*h+g*g,x=v*v+d*d,M={x:(d*y-g*x)/m+l,y:(h*x-v*y)/m+f};$r(e.edge,s,p,M),c.edge=Vr(s,n,null,M),e.edge=Vr(n,p,null,M),Fr(t),Fr(e)}}function Rr(n,t){var e=n.site,r=e.x,u=e.y,i=u-t;if(!i)return r;var o=n.P;if(!o)return-1/0;e=o.site;var a=e.x,c=e.y,s=c-t;if(!s)return a;var l=a-r,f=1/i-1/s,h=l/s;return f?(-h+Math.sqrt(h*h-2*f*(l*l/(-2*s)-c+s/2+u-i/2)))/f+r:(r+a)/2}function Dr(n,t){var e=n.N;if(e)return Rr(e,t);var r=n.site;return r.y===t?r.x:1/0}function Pr(n){this.site=n,this.edges=[]}function Ur(n){for(var t,e,r,u,i,o,a,c,s,l,f=n[0][0],h=n[1][0],g=n[0][1],p=n[1][1],v=Xc,d=v.length;d--;)if(i=v[d],i&&i.prepare())for(a=i.edges,c=a.length,o=0;c>o;)l=a[o].end(),r=l.x,u=l.y,s=a[++o%c].start(),t=s.x,e=s.y,(oa(r-t)>Aa||oa(u-e)>Aa)&&(a.splice(o,0,new Br(Xr(i.site,l,oa(r-f)Aa?{x:f,y:oa(t-f)Aa?{x:oa(e-p)Aa?{x:h,y:oa(t-h)Aa?{x:oa(e-g)=-Ca)){var g=c*c+s*s,p=l*l+f*f,v=(f*g-s*p)/h,d=(c*p-l*g)/h,f=d+a,m=Gc.pop()||new Hr;m.arc=n,m.site=u,m.x=v+o,m.y=f+Math.sqrt(v*v+d*d),m.cy=f,n.circle=m;for(var y=null,x=Wc._;x;)if(m.yd||d>=a)return;if(h>p){if(i){if(i.y>=s)return}else i={x:d,y:c};e={x:d,y:s}}else{if(i){if(i.yr||r>1)if(h>p){if(i){if(i.y>=s)return}else i={x:(c-u)/r,y:c};e={x:(s-u)/r,y:s}}else{if(i){if(i.yg){if(i){if(i.x>=a)return}else i={x:o,y:r*o+u};e={x:a,y:r*a+u}}else{if(i){if(i.xr;++r)if(o=l[r],o.x==e[0]){if(o.i)if(null==s[o.i+1])for(s[o.i-1]+=o.x,s.splice(o.i,1),u=r+1;i>u;++u)l[u].i--;else for(s[o.i-1]+=o.x+s[o.i+1],s.splice(o.i,2),u=r+1;i>u;++u)l[u].i-=2;else if(null==s[o.i+1])s[o.i]=o.x;else for(s[o.i]=o.x+s[o.i+1],s.splice(o.i+1,1),u=r+1;i>u;++u)l[u].i--;l.splice(r,1),i--,r--}else o.x=su(parseFloat(e[0]),parseFloat(o.x));for(;i>r;)o=l.pop(),null==s[o.i+1]?s[o.i]=o.x:(s[o.i]=o.x+s[o.i+1],s.splice(o.i+1,1)),i--;return 1===s.length?null==s[0]?(o=l[0].x,function(n){return o(n)+""}):function(){return t}:function(n){for(r=0;i>r;++r)s[(o=l[r]).i]=o.x(n);return s.join("")}}function fu(n,t){for(var e,r=Xo.interpolators.length;--r>=0&&!(e=Xo.interpolators[r](n,t)););return e}function hu(n,t){var e,r=[],u=[],i=n.length,o=t.length,a=Math.min(n.length,t.length);for(e=0;a>e;++e)r.push(fu(n[e],t[e]));for(;i>e;++e)u[e]=n[e];for(;o>e;++e)u[e]=t[e];return function(n){for(e=0;a>e;++e)u[e]=r[e](n);return u}}function gu(n){return function(t){return 0>=t?0:t>=1?1:n(t)}}function pu(n){return function(t){return 1-n(1-t)}}function vu(n){return function(t){return.5*(.5>t?n(2*t):2-n(2-2*t))}}function du(n){return n*n}function mu(n){return n*n*n}function yu(n){if(0>=n)return 0;if(n>=1)return 1;var t=n*n,e=t*n;return 4*(.5>n?e:3*(n-t)+e-.75)}function xu(n){return function(t){return Math.pow(t,n)}}function Mu(n){return 1-Math.cos(n*Ea)}function _u(n){return Math.pow(2,10*(n-1))}function bu(n){return 1-Math.sqrt(1-n*n)}function wu(n,t){var e;return arguments.length<2&&(t=.45),arguments.length?e=t/ka*Math.asin(1/n):(n=1,e=t/4),function(r){return 1+n*Math.pow(2,-10*r)*Math.sin((r-e)*ka/t)}}function Su(n){return n||(n=1.70158),function(t){return t*t*((n+1)*t-n)}}function ku(n){return 1/2.75>n?7.5625*n*n:2/2.75>n?7.5625*(n-=1.5/2.75)*n+.75:2.5/2.75>n?7.5625*(n-=2.25/2.75)*n+.9375:7.5625*(n-=2.625/2.75)*n+.984375}function Eu(n,t){n=Xo.hcl(n),t=Xo.hcl(t);var e=n.h,r=n.c,u=n.l,i=t.h-e,o=t.c-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.c:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return rt(e+i*n,r+o*n,u+a*n)+""}}function Au(n,t){n=Xo.hsl(n),t=Xo.hsl(t);var e=n.h,r=n.s,u=n.l,i=t.h-e,o=t.s-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.s:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return nt(e+i*n,r+o*n,u+a*n)+""}}function Cu(n,t){n=Xo.lab(n),t=Xo.lab(t);var e=n.l,r=n.a,u=n.b,i=t.l-e,o=t.a-r,a=t.b-u;return function(n){return ot(e+i*n,r+o*n,u+a*n)+""}}function Nu(n,t){return t-=n,function(e){return Math.round(n+t*e)}}function Lu(n){var t=[n.a,n.b],e=[n.c,n.d],r=qu(t),u=zu(t,e),i=qu(Tu(e,t,-u))||0;t[0]*e[1]180?l+=360:l-s>180&&(s+=360),u.push({i:r.push(r.pop()+"rotate(",null,")")-2,x:su(s,l)})):l&&r.push(r.pop()+"rotate("+l+")"),f!=h?u.push({i:r.push(r.pop()+"skewX(",null,")")-2,x:su(f,h)}):h&&r.push(r.pop()+"skewX("+h+")"),g[0]!=p[0]||g[1]!=p[1]?(e=r.push(r.pop()+"scale(",null,",",null,")"),u.push({i:e-4,x:su(g[0],p[0])},{i:e-2,x:su(g[1],p[1])})):(1!=p[0]||1!=p[1])&&r.push(r.pop()+"scale("+p+")"),e=u.length,function(n){for(var t,i=-1;++ie;++e)(t=n[e][1])>u&&(r=e,u=t);return r}function ei(n){return n.reduce(ri,0)}function ri(n,t){return n+t[1]}function ui(n,t){return ii(n,Math.ceil(Math.log(t.length)/Math.LN2+1))}function ii(n,t){for(var e=-1,r=+n[0],u=(n[1]-r)/t,i=[];++e<=t;)i[e]=u*e+r;return i}function oi(n){return[Xo.min(n),Xo.max(n)]}function ai(n,t){return n.parent==t.parent?1:2}function ci(n){var t=n.children;return t&&t.length?t[0]:n._tree.thread}function si(n){var t,e=n.children;return e&&(t=e.length)?e[t-1]:n._tree.thread}function li(n,t){var e=n.children;if(e&&(u=e.length))for(var r,u,i=-1;++i0&&(n=r);return n}function fi(n,t){return n.x-t.x}function hi(n,t){return t.x-n.x}function gi(n,t){return n.depth-t.depth}function pi(n,t){function e(n,r){var u=n.children;if(u&&(o=u.length))for(var i,o,a=null,c=-1;++c=0;)t=u[i]._tree,t.prelim+=e,t.mod+=e,e+=t.shift+(r+=t.change)}function di(n,t,e){n=n._tree,t=t._tree;var r=e/(t.number-n.number);n.change+=r,t.change-=r,t.shift+=e,t.prelim+=e,t.mod+=e}function mi(n,t,e){return n._tree.ancestor.parent==t.parent?n._tree.ancestor:e}function yi(n,t){return n.value-t.value}function xi(n,t){var e=n._pack_next;n._pack_next=t,t._pack_prev=n,t._pack_next=e,e._pack_prev=t}function Mi(n,t){n._pack_next=t,t._pack_prev=n}function _i(n,t){var e=t.x-n.x,r=t.y-n.y,u=n.r+t.r;return.999*u*u>e*e+r*r}function bi(n){function t(n){l=Math.min(n.x-n.r,l),f=Math.max(n.x+n.r,f),h=Math.min(n.y-n.r,h),g=Math.max(n.y+n.r,g)}if((e=n.children)&&(s=e.length)){var e,r,u,i,o,a,c,s,l=1/0,f=-1/0,h=1/0,g=-1/0;if(e.forEach(wi),r=e[0],r.x=-r.r,r.y=0,t(r),s>1&&(u=e[1],u.x=u.r,u.y=0,t(u),s>2))for(i=e[2],Ei(r,u,i),t(i),xi(r,i),r._pack_prev=i,xi(i,u),u=r._pack_next,o=3;s>o;o++){Ei(r,u,i=e[o]);var p=0,v=1,d=1;for(a=u._pack_next;a!==u;a=a._pack_next,v++)if(_i(a,i)){p=1;break}if(1==p)for(c=r._pack_prev;c!==a._pack_prev&&!_i(c,i);c=c._pack_prev,d++);p?(d>v||v==d&&u.ro;o++)i=e[o],i.x-=m,i.y-=y,x=Math.max(x,i.r+Math.sqrt(i.x*i.x+i.y*i.y));n.r=x,e.forEach(Si)}}function wi(n){n._pack_next=n._pack_prev=n}function Si(n){delete n._pack_next,delete n._pack_prev}function ki(n,t,e,r){var u=n.children;if(n.x=t+=r*n.x,n.y=e+=r*n.y,n.r*=r,u)for(var i=-1,o=u.length;++iu&&(e+=u/2,u=0),0>i&&(r+=i/2,i=0),{x:e,y:r,dx:u,dy:i}}function Ti(n){var t=n[0],e=n[n.length-1];return e>t?[t,e]:[e,t]}function Ri(n){return n.rangeExtent?n.rangeExtent():Ti(n.range())}function Di(n,t,e,r){var u=e(n[0],n[1]),i=r(t[0],t[1]);return function(n){return i(u(n))}}function Pi(n,t){var e,r=0,u=n.length-1,i=n[r],o=n[u];return i>o&&(e=r,r=u,u=e,e=i,i=o,o=e),n[r]=t.floor(i),n[u]=t.ceil(o),n}function Ui(n){return n?{floor:function(t){return Math.floor(t/n)*n},ceil:function(t){return Math.ceil(t/n)*n}}:ls}function ji(n,t,e,r){var u=[],i=[],o=0,a=Math.min(n.length,t.length)-1;for(n[a]2?ji:Di,c=r?Pu:Du;return o=u(n,t,c,e),a=u(t,n,c,fu),i}function i(n){return o(n)}var o,a;return i.invert=function(n){return a(n)},i.domain=function(t){return arguments.length?(n=t.map(Number),u()):n},i.range=function(n){return arguments.length?(t=n,u()):t},i.rangeRound=function(n){return i.range(n).interpolate(Nu)},i.clamp=function(n){return arguments.length?(r=n,u()):r},i.interpolate=function(n){return arguments.length?(e=n,u()):e},i.ticks=function(t){return Ii(n,t)},i.tickFormat=function(t,e){return Zi(n,t,e)},i.nice=function(t){return Oi(n,t),u()},i.copy=function(){return Hi(n,t,e,r)},u()}function Fi(n,t){return Xo.rebind(n,t,"range","rangeRound","interpolate","clamp")}function Oi(n,t){return Pi(n,Ui(Yi(n,t)[2]))}function Yi(n,t){null==t&&(t=10);var e=Ti(n),r=e[1]-e[0],u=Math.pow(10,Math.floor(Math.log(r/t)/Math.LN10)),i=t/r*u;return.15>=i?u*=10:.35>=i?u*=5:.75>=i&&(u*=2),e[0]=Math.ceil(e[0]/u)*u,e[1]=Math.floor(e[1]/u)*u+.5*u,e[2]=u,e}function Ii(n,t){return Xo.range.apply(Xo,Yi(n,t))}function Zi(n,t,e){var r=Yi(n,t);return Xo.format(e?e.replace(Qa,function(n,t,e,u,i,o,a,c,s,l){return[t,e,u,i,o,a,c,s||"."+Xi(l,r),l].join("")}):",."+Vi(r[2])+"f")}function Vi(n){return-Math.floor(Math.log(n)/Math.LN10+.01)}function Xi(n,t){var e=Vi(t[2]);return n in fs?Math.abs(e-Vi(Math.max(Math.abs(t[0]),Math.abs(t[1]))))+ +("e"!==n):e-2*("%"===n)}function $i(n,t,e,r){function u(n){return(e?Math.log(0>n?0:n):-Math.log(n>0?0:-n))/Math.log(t)}function i(n){return e?Math.pow(t,n):-Math.pow(t,-n)}function o(t){return n(u(t))}return o.invert=function(t){return i(n.invert(t))},o.domain=function(t){return arguments.length?(e=t[0]>=0,n.domain((r=t.map(Number)).map(u)),o):r},o.base=function(e){return arguments.length?(t=+e,n.domain(r.map(u)),o):t},o.nice=function(){var t=Pi(r.map(u),e?Math:gs);return n.domain(t),r=t.map(i),o},o.ticks=function(){var n=Ti(r),o=[],a=n[0],c=n[1],s=Math.floor(u(a)),l=Math.ceil(u(c)),f=t%1?2:t;if(isFinite(l-s)){if(e){for(;l>s;s++)for(var h=1;f>h;h++)o.push(i(s)*h);o.push(i(s))}else for(o.push(i(s));s++0;h--)o.push(i(s)*h);for(s=0;o[s]c;l--);o=o.slice(s,l)}return o},o.tickFormat=function(n,t){if(!arguments.length)return hs;arguments.length<2?t=hs:"function"!=typeof t&&(t=Xo.format(t));var r,a=Math.max(.1,n/o.ticks().length),c=e?(r=1e-12,Math.ceil):(r=-1e-12,Math.floor);return function(n){return n/i(c(u(n)+r))<=a?t(n):""}},o.copy=function(){return $i(n.copy(),t,e,r)},Fi(o,n)}function Bi(n,t,e){function r(t){return n(u(t))}var u=Wi(t),i=Wi(1/t);return r.invert=function(t){return i(n.invert(t))},r.domain=function(t){return arguments.length?(n.domain((e=t.map(Number)).map(u)),r):e},r.ticks=function(n){return Ii(e,n)},r.tickFormat=function(n,t){return Zi(e,n,t)},r.nice=function(n){return r.domain(Oi(e,n))},r.exponent=function(o){return arguments.length?(u=Wi(t=o),i=Wi(1/t),n.domain(e.map(u)),r):t},r.copy=function(){return Bi(n.copy(),t,e)},Fi(r,n)}function Wi(n){return function(t){return 0>t?-Math.pow(-t,n):Math.pow(t,n)}}function Ji(n,t){function e(e){return o[((i.get(e)||"range"===t.t&&i.set(e,n.push(e)))-1)%o.length]}function r(t,e){return Xo.range(n.length).map(function(n){return t+e*n})}var i,o,a;return e.domain=function(r){if(!arguments.length)return n;n=[],i=new u;for(var o,a=-1,c=r.length;++ae?[0/0,0/0]:[e>0?u[e-1]:n[0],et?0/0:t/i+n,[t,t+1/i]},r.copy=function(){return Ki(n,t,e)},u()}function Qi(n,t){function e(e){return e>=e?t[Xo.bisect(n,e)]:void 0}return e.domain=function(t){return arguments.length?(n=t,e):n},e.range=function(n){return arguments.length?(t=n,e):t},e.invertExtent=function(e){return e=t.indexOf(e),[n[e-1],n[e]]},e.copy=function(){return Qi(n,t)},e}function no(n){function t(n){return+n}return t.invert=t,t.domain=t.range=function(e){return arguments.length?(n=e.map(t),t):n},t.ticks=function(t){return Ii(n,t)},t.tickFormat=function(t,e){return Zi(n,t,e)},t.copy=function(){return no(n)},t}function to(n){return n.innerRadius}function eo(n){return n.outerRadius}function ro(n){return n.startAngle}function uo(n){return n.endAngle}function io(n){function t(t){function o(){s.push("M",i(n(l),a))}for(var c,s=[],l=[],f=-1,h=t.length,g=_t(e),p=_t(r);++f1&&u.push("H",r[0]),u.join("")}function so(n){for(var t=0,e=n.length,r=n[0],u=[r[0],",",r[1]];++t1){a=t[1],i=n[c],c++,r+="C"+(u[0]+o[0])+","+(u[1]+o[1])+","+(i[0]-a[0])+","+(i[1]-a[1])+","+i[0]+","+i[1];for(var s=2;s9&&(u=3*t/Math.sqrt(u),o[a]=u*e,o[a+1]=u*r));for(a=-1;++a<=c;)u=(n[Math.min(c,a+1)][0]-n[Math.max(0,a-1)][0])/(6*(1+o[a]*o[a])),i.push([u||0,o[a]*u||0]);return i}function Eo(n){return n.length<3?oo(n):n[0]+po(n,ko(n))}function Ao(n){for(var t,e,r,u=-1,i=n.length;++ue?s():(i.active=e,o.event&&o.event.start.call(n,l,t),o.tween.forEach(function(e,r){(r=r.call(n,l,t))&&v.push(r)}),Xo.timer(function(){return p.c=c(r||1)?be:c,1},0,a),void 0)}function c(r){if(i.active!==e)return s();for(var u=r/g,a=f(u),c=v.length;c>0;)v[--c].call(n,a);return u>=1?(o.event&&o.event.end.call(n,l,t),s()):void 0}function s(){return--i.count?delete i[e]:delete n.__transition__,1}var l=n.__data__,f=o.ease,h=o.delay,g=o.duration,p=Ja,v=[];return p.t=h+a,r>=h?u(r-h):(p.c=u,void 0)},0,a)}}function Ho(n,t){n.attr("transform",function(n){return"translate("+t(n)+",0)"})}function Fo(n,t){n.attr("transform",function(n){return"translate(0,"+t(n)+")"})}function Oo(n){return n.toISOString()}function Yo(n,t,e){function r(t){return n(t)}function u(n,e){var r=n[1]-n[0],u=r/e,i=Xo.bisect(js,u); +return i==js.length?[t.year,Yi(n.map(function(n){return n/31536e6}),e)[2]]:i?t[u/js[i-1]1?{floor:function(t){for(;e(t=n.floor(t));)t=Io(t-1);return t},ceil:function(t){for(;e(t=n.ceil(t));)t=Io(+t+1);return t}}:n))},r.ticks=function(n,t){var e=Ti(r.domain()),i=null==n?u(e,10):"number"==typeof n?u(e,n):!n.range&&[{range:n},t];return i&&(n=i[0],t=i[1]),n.range(e[0],Io(+e[1]+1),1>t?1:t)},r.tickFormat=function(){return e},r.copy=function(){return Yo(n.copy(),t,e)},Fi(r,n)}function Io(n){return new Date(n)}function Zo(n){return JSON.parse(n.responseText)}function Vo(n){var t=Wo.createRange();return t.selectNode(Wo.body),t.createContextualFragment(n.responseText)}var Xo={version:"3.4.3"};Date.now||(Date.now=function(){return+new Date});var $o=[].slice,Bo=function(n){return $o.call(n)},Wo=document,Jo=Wo.documentElement,Go=window;try{Bo(Jo.childNodes)[0].nodeType}catch(Ko){Bo=function(n){for(var t=n.length,e=new Array(t);t--;)e[t]=n[t];return e}}try{Wo.createElement("div").style.setProperty("opacity",0,"")}catch(Qo){var na=Go.Element.prototype,ta=na.setAttribute,ea=na.setAttributeNS,ra=Go.CSSStyleDeclaration.prototype,ua=ra.setProperty;na.setAttribute=function(n,t){ta.call(this,n,t+"")},na.setAttributeNS=function(n,t,e){ea.call(this,n,t,e+"")},ra.setProperty=function(n,t,e){ua.call(this,n,t+"",e)}}Xo.ascending=function(n,t){return t>n?-1:n>t?1:n>=t?0:0/0},Xo.descending=function(n,t){return n>t?-1:t>n?1:t>=n?0:0/0},Xo.min=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ur&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ur&&(e=r)}return e},Xo.max=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ue&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ue&&(e=r)}return e},Xo.extent=function(n,t){var e,r,u,i=-1,o=n.length;if(1===arguments.length){for(;++i=e);)e=u=void 0;for(;++ir&&(e=r),r>u&&(u=r))}else{for(;++i=e);)e=void 0;for(;++ir&&(e=r),r>u&&(u=r))}return[e,u]},Xo.sum=function(n,t){var e,r=0,u=n.length,i=-1;if(1===arguments.length)for(;++i1&&(t=t.map(e)),t=t.filter(n),t.length?Xo.quantile(t.sort(Xo.ascending),.5):void 0},Xo.bisector=function(n){return{left:function(t,e,r,u){for(arguments.length<3&&(r=0),arguments.length<4&&(u=t.length);u>r;){var i=r+u>>>1;n.call(t,t[i],i)r;){var i=r+u>>>1;er?0:r);r>e;)i[e]=[t=u,u=n[++e]];return i},Xo.zip=function(){if(!(u=arguments.length))return[];for(var n=-1,e=Xo.min(arguments,t),r=new Array(e);++n=0;)for(r=n[u],t=r.length;--t>=0;)e[--o]=r[t];return e};var oa=Math.abs;Xo.range=function(n,t,r){if(arguments.length<3&&(r=1,arguments.length<2&&(t=n,n=0)),1/0===(t-n)/r)throw new Error("infinite range");var u,i=[],o=e(oa(r)),a=-1;if(n*=o,t*=o,r*=o,0>r)for(;(u=n+r*++a)>t;)i.push(u/o);else for(;(u=n+r*++a)=o.length)return r?r.call(i,a):e?a.sort(e):a;for(var s,l,f,h,g=-1,p=a.length,v=o[c++],d=new u;++g=o.length)return n;var r=[],u=a[e++];return n.forEach(function(n,u){r.push({key:n,values:t(u,e)})}),u?r.sort(function(n,t){return u(n.key,t.key)}):r}var e,r,i={},o=[],a=[];return i.map=function(t,e){return n(e,t,0)},i.entries=function(e){return t(n(Xo.map,e,0),0)},i.key=function(n){return o.push(n),i},i.sortKeys=function(n){return a[o.length-1]=n,i},i.sortValues=function(n){return e=n,i},i.rollup=function(n){return r=n,i},i},Xo.set=function(n){var t=new l;if(n)for(var e=0,r=n.length;r>e;++e)t.add(n[e]);return t},r(l,{has:i,add:function(n){return this[aa+n]=!0,n},remove:function(n){return n=aa+n,n in this&&delete this[n]},values:a,size:c,empty:s,forEach:function(n){for(var t in this)t.charCodeAt(0)===ca&&n.call(this,t.substring(1))}}),Xo.behavior={},Xo.rebind=function(n,t){for(var e,r=1,u=arguments.length;++r=0&&(r=n.substring(e+1),n=n.substring(0,e)),n)return arguments.length<2?this[n].on(r):this[n].on(r,t);if(2===arguments.length){if(null==t)for(n in this)this.hasOwnProperty(n)&&this[n].on(r,null);return this}},Xo.event=null,Xo.requote=function(n){return n.replace(la,"\\$&")};var la=/[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g,fa={}.__proto__?function(n,t){n.__proto__=t}:function(n,t){for(var e in t)n[e]=t[e]},ha=function(n,t){return t.querySelector(n)},ga=function(n,t){return t.querySelectorAll(n)},pa=Jo[h(Jo,"matchesSelector")],va=function(n,t){return pa.call(n,t)};"function"==typeof Sizzle&&(ha=function(n,t){return Sizzle(n,t)[0]||null},ga=function(n,t){return Sizzle.uniqueSort(Sizzle(n,t))},va=Sizzle.matchesSelector),Xo.selection=function(){return xa};var da=Xo.selection.prototype=[];da.select=function(n){var t,e,r,u,i=[];n=M(n);for(var o=-1,a=this.length;++o=0&&(e=n.substring(0,t),n=n.substring(t+1)),ma.hasOwnProperty(e)?{space:ma[e],local:n}:n}},da.attr=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node();return n=Xo.ns.qualify(n),n.local?e.getAttributeNS(n.space,n.local):e.getAttribute(n)}for(t in n)this.each(b(t,n[t]));return this}return this.each(b(n,t))},da.classed=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node(),r=(n=k(n)).length,u=-1;if(t=e.classList){for(;++ur){if("string"!=typeof n){2>r&&(t="");for(e in n)this.each(C(e,n[e],t));return this}if(2>r)return Go.getComputedStyle(this.node(),null).getPropertyValue(n);e=""}return this.each(C(n,t,e))},da.property=function(n,t){if(arguments.length<2){if("string"==typeof n)return this.node()[n];for(t in n)this.each(N(t,n[t]));return this}return this.each(N(n,t))},da.text=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.textContent=null==t?"":t}:null==n?function(){this.textContent=""}:function(){this.textContent=n}):this.node().textContent},da.html=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.innerHTML=null==t?"":t}:null==n?function(){this.innerHTML=""}:function(){this.innerHTML=n}):this.node().innerHTML},da.append=function(n){return n=L(n),this.select(function(){return this.appendChild(n.apply(this,arguments))})},da.insert=function(n,t){return n=L(n),t=M(t),this.select(function(){return this.insertBefore(n.apply(this,arguments),t.apply(this,arguments)||null)})},da.remove=function(){return this.each(function(){var n=this.parentNode;n&&n.removeChild(this)})},da.data=function(n,t){function e(n,e){var r,i,o,a=n.length,f=e.length,h=Math.min(a,f),g=new Array(f),p=new Array(f),v=new Array(a);if(t){var d,m=new u,y=new u,x=[];for(r=-1;++rr;++r)p[r]=z(e[r]);for(;a>r;++r)v[r]=n[r]}p.update=g,p.parentNode=g.parentNode=v.parentNode=n.parentNode,c.push(p),s.push(g),l.push(v)}var r,i,o=-1,a=this.length;if(!arguments.length){for(n=new Array(a=(r=this[0]).length);++oi;i++){u.push(t=[]),t.parentNode=(e=this[i]).parentNode;for(var a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a,i)&&t.push(r)}return x(u)},da.order=function(){for(var n=-1,t=this.length;++n=0;)(e=r[u])&&(i&&i!==e.nextSibling&&i.parentNode.insertBefore(e,i),i=e);return this},da.sort=function(n){n=T.apply(this,arguments);for(var t=-1,e=this.length;++tn;n++)for(var e=this[n],r=0,u=e.length;u>r;r++){var i=e[r];if(i)return i}return null},da.size=function(){var n=0;return this.each(function(){++n}),n};var ya=[];Xo.selection.enter=D,Xo.selection.enter.prototype=ya,ya.append=da.append,ya.empty=da.empty,ya.node=da.node,ya.call=da.call,ya.size=da.size,ya.select=function(n){for(var t,e,r,u,i,o=[],a=-1,c=this.length;++ar){if("string"!=typeof n){2>r&&(t=!1);for(e in n)this.each(j(e,n[e],t));return this}if(2>r)return(r=this.node()["__on"+n])&&r._;e=!1}return this.each(j(n,t,e))};var Ma=Xo.map({mouseenter:"mouseover",mouseleave:"mouseout"});Ma.forEach(function(n){"on"+n in Wo&&Ma.remove(n)});var _a="onselectstart"in Wo?null:h(Jo.style,"userSelect"),ba=0;Xo.mouse=function(n){return Y(n,m())};var wa=/WebKit/.test(Go.navigator.userAgent)?-1:0;Xo.touches=function(n,t){return arguments.length<2&&(t=m().touches),t?Bo(t).map(function(t){var e=Y(n,t);return e.identifier=t.identifier,e}):[]},Xo.behavior.drag=function(){function n(){this.on("mousedown.drag",o).on("touchstart.drag",a)}function t(){return Xo.event.changedTouches[0].identifier}function e(n,t){return Xo.touches(n).filter(function(n){return n.identifier===t})[0]}function r(n,t,e,r){return function(){function o(){var n=t(l,g),e=n[0]-v[0],r=n[1]-v[1];d|=e|r,v=n,f({type:"drag",x:n[0]+c[0],y:n[1]+c[1],dx:e,dy:r})}function a(){m.on(e+"."+p,null).on(r+"."+p,null),y(d&&Xo.event.target===h),f({type:"dragend"})}var c,s=this,l=s.parentNode,f=u.of(s,arguments),h=Xo.event.target,g=n(),p=null==g?"drag":"drag-"+g,v=t(l,g),d=0,m=Xo.select(Go).on(e+"."+p,o).on(r+"."+p,a),y=O();i?(c=i.apply(s,arguments),c=[c.x-v[0],c.y-v[1]]):c=[0,0],f({type:"dragstart"})}}var u=y(n,"drag","dragstart","dragend"),i=null,o=r(g,Xo.mouse,"mousemove","mouseup"),a=r(t,e,"touchmove","touchend");return n.origin=function(t){return arguments.length?(i=t,n):i},Xo.rebind(n,u,"on")};var Sa=Math.PI,ka=2*Sa,Ea=Sa/2,Aa=1e-6,Ca=Aa*Aa,Na=Sa/180,La=180/Sa,za=Math.SQRT2,qa=2,Ta=4;Xo.interpolateZoom=function(n,t){function e(n){var t=n*y;if(m){var e=B(v),o=i/(qa*h)*(e*W(za*t+v)-$(v));return[r+o*s,u+o*l,i*e/B(za*t+v)]}return[r+n*s,u+n*l,i*Math.exp(za*t)]}var r=n[0],u=n[1],i=n[2],o=t[0],a=t[1],c=t[2],s=o-r,l=a-u,f=s*s+l*l,h=Math.sqrt(f),g=(c*c-i*i+Ta*f)/(2*i*qa*h),p=(c*c-i*i-Ta*f)/(2*c*qa*h),v=Math.log(Math.sqrt(g*g+1)-g),d=Math.log(Math.sqrt(p*p+1)-p),m=d-v,y=(m||Math.log(c/i))/za;return e.duration=1e3*y,e},Xo.behavior.zoom=function(){function n(n){n.on(A,s).on(Pa+".zoom",f).on(C,h).on("dblclick.zoom",g).on(L,l)}function t(n){return[(n[0]-S.x)/S.k,(n[1]-S.y)/S.k]}function e(n){return[n[0]*S.k+S.x,n[1]*S.k+S.y]}function r(n){S.k=Math.max(E[0],Math.min(E[1],n))}function u(n,t){t=e(t),S.x+=n[0]-t[0],S.y+=n[1]-t[1]}function i(){_&&_.domain(M.range().map(function(n){return(n-S.x)/S.k}).map(M.invert)),w&&w.domain(b.range().map(function(n){return(n-S.y)/S.k}).map(b.invert))}function o(n){n({type:"zoomstart"})}function a(n){i(),n({type:"zoom",scale:S.k,translate:[S.x,S.y]})}function c(n){n({type:"zoomend"})}function s(){function n(){l=1,u(Xo.mouse(r),g),a(i)}function e(){f.on(C,Go===r?h:null).on(N,null),p(l&&Xo.event.target===s),c(i)}var r=this,i=z.of(r,arguments),s=Xo.event.target,l=0,f=Xo.select(Go).on(C,n).on(N,e),g=t(Xo.mouse(r)),p=O();U.call(r),o(i)}function l(){function n(){var n=Xo.touches(g);return h=S.k,n.forEach(function(n){n.identifier in v&&(v[n.identifier]=t(n))}),n}function e(){for(var t=Xo.event.changedTouches,e=0,i=t.length;i>e;++e)v[t[e].identifier]=null;var o=n(),c=Date.now();if(1===o.length){if(500>c-x){var s=o[0],l=v[s.identifier];r(2*S.k),u(s,l),d(),a(p)}x=c}else if(o.length>1){var s=o[0],f=o[1],h=s[0]-f[0],g=s[1]-f[1];m=h*h+g*g}}function i(){for(var n,t,e,i,o=Xo.touches(g),c=0,s=o.length;s>c;++c,i=null)if(e=o[c],i=v[e.identifier]){if(t)break;n=e,t=i}if(i){var l=(l=e[0]-n[0])*l+(l=e[1]-n[1])*l,f=m&&Math.sqrt(l/m);n=[(n[0]+e[0])/2,(n[1]+e[1])/2],t=[(t[0]+i[0])/2,(t[1]+i[1])/2],r(f*h)}x=null,u(n,t),a(p)}function f(){if(Xo.event.touches.length){for(var t=Xo.event.changedTouches,e=0,r=t.length;r>e;++e)delete v[t[e].identifier];for(var u in v)return void n()}b.on(M,null).on(_,null),w.on(A,s).on(L,l),k(),c(p)}var h,g=this,p=z.of(g,arguments),v={},m=0,y=Xo.event.changedTouches[0].identifier,M="touchmove.zoom-"+y,_="touchend.zoom-"+y,b=Xo.select(Go).on(M,i).on(_,f),w=Xo.select(g).on(A,null).on(L,e),k=O();U.call(g),e(),o(p)}function f(){var n=z.of(this,arguments);m?clearTimeout(m):(U.call(this),o(n)),m=setTimeout(function(){m=null,c(n)},50),d();var e=v||Xo.mouse(this);p||(p=t(e)),r(Math.pow(2,.002*Ra())*S.k),u(e,p),a(n)}function h(){p=null}function g(){var n=z.of(this,arguments),e=Xo.mouse(this),i=t(e),s=Math.log(S.k)/Math.LN2;o(n),r(Math.pow(2,Xo.event.shiftKey?Math.ceil(s)-1:Math.floor(s)+1)),u(e,i),a(n),c(n)}var p,v,m,x,M,_,b,w,S={x:0,y:0,k:1},k=[960,500],E=Da,A="mousedown.zoom",C="mousemove.zoom",N="mouseup.zoom",L="touchstart.zoom",z=y(n,"zoomstart","zoom","zoomend");return n.event=function(n){n.each(function(){var n=z.of(this,arguments),t=S;ks?Xo.select(this).transition().each("start.zoom",function(){S=this.__chart__||{x:0,y:0,k:1},o(n)}).tween("zoom:zoom",function(){var e=k[0],r=k[1],u=e/2,i=r/2,o=Xo.interpolateZoom([(u-S.x)/S.k,(i-S.y)/S.k,e/S.k],[(u-t.x)/t.k,(i-t.y)/t.k,e/t.k]);return function(t){var r=o(t),c=e/r[2];this.__chart__=S={x:u-r[0]*c,y:i-r[1]*c,k:c},a(n)}}).each("end.zoom",function(){c(n)}):(this.__chart__=S,o(n),a(n),c(n))})},n.translate=function(t){return arguments.length?(S={x:+t[0],y:+t[1],k:S.k},i(),n):[S.x,S.y]},n.scale=function(t){return arguments.length?(S={x:S.x,y:S.y,k:+t},i(),n):S.k},n.scaleExtent=function(t){return arguments.length?(E=null==t?Da:[+t[0],+t[1]],n):E},n.center=function(t){return arguments.length?(v=t&&[+t[0],+t[1]],n):v},n.size=function(t){return arguments.length?(k=t&&[+t[0],+t[1]],n):k},n.x=function(t){return arguments.length?(_=t,M=t.copy(),S={x:0,y:0,k:1},n):_},n.y=function(t){return arguments.length?(w=t,b=t.copy(),S={x:0,y:0,k:1},n):w},Xo.rebind(n,z,"on")};var Ra,Da=[0,1/0],Pa="onwheel"in Wo?(Ra=function(){return-Xo.event.deltaY*(Xo.event.deltaMode?120:1)},"wheel"):"onmousewheel"in Wo?(Ra=function(){return Xo.event.wheelDelta},"mousewheel"):(Ra=function(){return-Xo.event.detail},"MozMousePixelScroll");G.prototype.toString=function(){return this.rgb()+""},Xo.hsl=function(n,t,e){return 1===arguments.length?n instanceof Q?K(n.h,n.s,n.l):dt(""+n,mt,K):K(+n,+t,+e)};var Ua=Q.prototype=new G;Ua.brighter=function(n){return n=Math.pow(.7,arguments.length?n:1),K(this.h,this.s,this.l/n)},Ua.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),K(this.h,this.s,n*this.l)},Ua.rgb=function(){return nt(this.h,this.s,this.l)},Xo.hcl=function(n,t,e){return 1===arguments.length?n instanceof et?tt(n.h,n.c,n.l):n instanceof it?at(n.l,n.a,n.b):at((n=yt((n=Xo.rgb(n)).r,n.g,n.b)).l,n.a,n.b):tt(+n,+t,+e)};var ja=et.prototype=new G;ja.brighter=function(n){return tt(this.h,this.c,Math.min(100,this.l+Ha*(arguments.length?n:1)))},ja.darker=function(n){return tt(this.h,this.c,Math.max(0,this.l-Ha*(arguments.length?n:1)))},ja.rgb=function(){return rt(this.h,this.c,this.l).rgb()},Xo.lab=function(n,t,e){return 1===arguments.length?n instanceof it?ut(n.l,n.a,n.b):n instanceof et?rt(n.l,n.c,n.h):yt((n=Xo.rgb(n)).r,n.g,n.b):ut(+n,+t,+e)};var Ha=18,Fa=.95047,Oa=1,Ya=1.08883,Ia=it.prototype=new G;Ia.brighter=function(n){return ut(Math.min(100,this.l+Ha*(arguments.length?n:1)),this.a,this.b)},Ia.darker=function(n){return ut(Math.max(0,this.l-Ha*(arguments.length?n:1)),this.a,this.b)},Ia.rgb=function(){return ot(this.l,this.a,this.b)},Xo.rgb=function(n,t,e){return 1===arguments.length?n instanceof pt?gt(n.r,n.g,n.b):dt(""+n,gt,nt):gt(~~n,~~t,~~e)};var Za=pt.prototype=new G;Za.brighter=function(n){n=Math.pow(.7,arguments.length?n:1);var t=this.r,e=this.g,r=this.b,u=30;return t||e||r?(t&&u>t&&(t=u),e&&u>e&&(e=u),r&&u>r&&(r=u),gt(Math.min(255,~~(t/n)),Math.min(255,~~(e/n)),Math.min(255,~~(r/n)))):gt(u,u,u)},Za.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),gt(~~(n*this.r),~~(n*this.g),~~(n*this.b))},Za.hsl=function(){return mt(this.r,this.g,this.b)},Za.toString=function(){return"#"+vt(this.r)+vt(this.g)+vt(this.b)};var Va=Xo.map({aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074});Va.forEach(function(n,t){Va.set(n,ft(t))}),Xo.functor=_t,Xo.xhr=wt(bt),Xo.dsv=function(n,t){function e(n,e,i){arguments.length<3&&(i=e,e=null);var o=St(n,t,null==e?r:u(e),i);return o.row=function(n){return arguments.length?o.response(null==(e=n)?r:u(n)):e},o}function r(n){return e.parse(n.responseText)}function u(n){return function(t){return e.parse(t.responseText,n)}}function i(t){return t.map(o).join(n)}function o(n){return a.test(n)?'"'+n.replace(/\"/g,'""')+'"':n}var a=new RegExp('["'+n+"\n]"),c=n.charCodeAt(0);return e.parse=function(n,t){var r;return e.parseRows(n,function(n,e){if(r)return r(n,e-1);var u=new Function("d","return {"+n.map(function(n,t){return JSON.stringify(n)+": d["+t+"]"}).join(",")+"}");r=t?function(n,e){return t(u(n),e)}:u})},e.parseRows=function(n,t){function e(){if(l>=s)return o;if(u)return u=!1,i;var t=l;if(34===n.charCodeAt(t)){for(var e=t;e++l;){var r=n.charCodeAt(l++),a=1;if(10===r)u=!0;else if(13===r)u=!0,10===n.charCodeAt(l)&&(++l,++a);else if(r!==c)continue;return n.substring(t,l-a)}return n.substring(t)}for(var r,u,i={},o={},a=[],s=n.length,l=0,f=0;(r=e())!==o;){for(var h=[];r!==i&&r!==o;)h.push(r),r=e();(!t||(h=t(h,f++)))&&a.push(h)}return a},e.format=function(t){if(Array.isArray(t[0]))return e.formatRows(t);var r=new l,u=[];return t.forEach(function(n){for(var t in n)r.has(t)||u.push(r.add(t))}),[u.map(o).join(n)].concat(t.map(function(t){return u.map(function(n){return o(t[n])}).join(n)})).join("\n")},e.formatRows=function(n){return n.map(i).join("\n")},e},Xo.csv=Xo.dsv(",","text/csv"),Xo.tsv=Xo.dsv(" ","text/tab-separated-values");var Xa,$a,Ba,Wa,Ja,Ga=Go[h(Go,"requestAnimationFrame")]||function(n){setTimeout(n,17)};Xo.timer=function(n,t,e){var r=arguments.length;2>r&&(t=0),3>r&&(e=Date.now());var u=e+t,i={c:n,t:u,f:!1,n:null};$a?$a.n=i:Xa=i,$a=i,Ba||(Wa=clearTimeout(Wa),Ba=1,Ga(Et))},Xo.timer.flush=function(){At(),Ct()},Xo.round=function(n,t){return t?Math.round(n*(t=Math.pow(10,t)))/t:Math.round(n)};var Ka=["y","z","a","f","p","n","\xb5","m","","k","M","G","T","P","E","Z","Y"].map(Lt);Xo.formatPrefix=function(n,t){var e=0;return n&&(0>n&&(n*=-1),t&&(n=Xo.round(n,Nt(n,t))),e=1+Math.floor(1e-12+Math.log(n)/Math.LN10),e=Math.max(-24,Math.min(24,3*Math.floor((0>=e?e+1:e-1)/3)))),Ka[8+e/3]};var Qa=/(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i,nc=Xo.map({b:function(n){return n.toString(2)},c:function(n){return String.fromCharCode(n)},o:function(n){return n.toString(8)},x:function(n){return n.toString(16)},X:function(n){return n.toString(16).toUpperCase()},g:function(n,t){return n.toPrecision(t)},e:function(n,t){return n.toExponential(t)},f:function(n,t){return n.toFixed(t)},r:function(n,t){return(n=Xo.round(n,Nt(n,t))).toFixed(Math.max(0,Math.min(20,Nt(n*(1+1e-15),t))))}}),tc=Xo.time={},ec=Date;Tt.prototype={getDate:function(){return this._.getUTCDate()},getDay:function(){return this._.getUTCDay()},getFullYear:function(){return this._.getUTCFullYear()},getHours:function(){return this._.getUTCHours()},getMilliseconds:function(){return this._.getUTCMilliseconds()},getMinutes:function(){return this._.getUTCMinutes()},getMonth:function(){return this._.getUTCMonth()},getSeconds:function(){return this._.getUTCSeconds()},getTime:function(){return this._.getTime()},getTimezoneOffset:function(){return 0},valueOf:function(){return this._.valueOf()},setDate:function(){rc.setUTCDate.apply(this._,arguments)},setDay:function(){rc.setUTCDay.apply(this._,arguments)},setFullYear:function(){rc.setUTCFullYear.apply(this._,arguments)},setHours:function(){rc.setUTCHours.apply(this._,arguments)},setMilliseconds:function(){rc.setUTCMilliseconds.apply(this._,arguments)},setMinutes:function(){rc.setUTCMinutes.apply(this._,arguments)},setMonth:function(){rc.setUTCMonth.apply(this._,arguments)},setSeconds:function(){rc.setUTCSeconds.apply(this._,arguments)},setTime:function(){rc.setTime.apply(this._,arguments)}};var rc=Date.prototype;tc.year=Rt(function(n){return n=tc.day(n),n.setMonth(0,1),n},function(n,t){n.setFullYear(n.getFullYear()+t)},function(n){return n.getFullYear()}),tc.years=tc.year.range,tc.years.utc=tc.year.utc.range,tc.day=Rt(function(n){var t=new ec(2e3,0);return t.setFullYear(n.getFullYear(),n.getMonth(),n.getDate()),t},function(n,t){n.setDate(n.getDate()+t)},function(n){return n.getDate()-1}),tc.days=tc.day.range,tc.days.utc=tc.day.utc.range,tc.dayOfYear=function(n){var t=tc.year(n);return Math.floor((n-t-6e4*(n.getTimezoneOffset()-t.getTimezoneOffset()))/864e5)},["sunday","monday","tuesday","wednesday","thursday","friday","saturday"].forEach(function(n,t){t=7-t;var e=tc[n]=Rt(function(n){return(n=tc.day(n)).setDate(n.getDate()-(n.getDay()+t)%7),n},function(n,t){n.setDate(n.getDate()+7*Math.floor(t))},function(n){var e=tc.year(n).getDay();return Math.floor((tc.dayOfYear(n)+(e+t)%7)/7)-(e!==t)});tc[n+"s"]=e.range,tc[n+"s"].utc=e.utc.range,tc[n+"OfYear"]=function(n){var e=tc.year(n).getDay();return Math.floor((tc.dayOfYear(n)+(e+t)%7)/7)}}),tc.week=tc.sunday,tc.weeks=tc.sunday.range,tc.weeks.utc=tc.sunday.utc.range,tc.weekOfYear=tc.sundayOfYear;var uc={"-":"",_:" ",0:"0"},ic=/^\s*\d+/,oc=/^%/;Xo.locale=function(n){return{numberFormat:zt(n),timeFormat:Pt(n)}};var ac=Xo.locale({decimal:".",thousands:",",grouping:[3],currency:["$",""],dateTime:"%a %b %e %X %Y",date:"%m/%d/%Y",time:"%H:%M:%S",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});Xo.format=ac.numberFormat,Xo.geo={},re.prototype={s:0,t:0,add:function(n){ue(n,this.t,cc),ue(cc.s,this.s,this),this.s?this.t+=cc.t:this.s=cc.t},reset:function(){this.s=this.t=0},valueOf:function(){return this.s}};var cc=new re;Xo.geo.stream=function(n,t){n&&sc.hasOwnProperty(n.type)?sc[n.type](n,t):ie(n,t)};var sc={Feature:function(n,t){ie(n.geometry,t)},FeatureCollection:function(n,t){for(var e=n.features,r=-1,u=e.length;++rn?4*Sa+n:n,gc.lineStart=gc.lineEnd=gc.point=g}};Xo.geo.bounds=function(){function n(n,t){x.push(M=[l=n,h=n]),f>t&&(f=t),t>g&&(g=t)}function t(t,e){var r=se([t*Na,e*Na]);if(m){var u=fe(m,r),i=[u[1],-u[0],0],o=fe(i,u);pe(o),o=ve(o);var c=t-p,s=c>0?1:-1,v=o[0]*La*s,d=oa(c)>180;if(d^(v>s*p&&s*t>v)){var y=o[1]*La;y>g&&(g=y)}else if(v=(v+360)%360-180,d^(v>s*p&&s*t>v)){var y=-o[1]*La;f>y&&(f=y)}else f>e&&(f=e),e>g&&(g=e);d?p>t?a(l,t)>a(l,h)&&(h=t):a(t,h)>a(l,h)&&(l=t):h>=l?(l>t&&(l=t),t>h&&(h=t)):t>p?a(l,t)>a(l,h)&&(h=t):a(t,h)>a(l,h)&&(l=t)}else n(t,e);m=r,p=t}function e(){_.point=t}function r(){M[0]=l,M[1]=h,_.point=n,m=null}function u(n,e){if(m){var r=n-p;y+=oa(r)>180?r+(r>0?360:-360):r}else v=n,d=e;gc.point(n,e),t(n,e)}function i(){gc.lineStart()}function o(){u(v,d),gc.lineEnd(),oa(y)>Aa&&(l=-(h=180)),M[0]=l,M[1]=h,m=null}function a(n,t){return(t-=n)<0?t+360:t}function c(n,t){return n[0]-t[0]}function s(n,t){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:nhc?(l=-(h=180),f=-(g=90)):y>Aa?g=90:-Aa>y&&(f=-90),M[0]=l,M[1]=h +}};return function(n){g=h=-(l=f=1/0),x=[],Xo.geo.stream(n,_);var t=x.length;if(t){x.sort(c);for(var e,r=1,u=x[0],i=[u];t>r;++r)e=x[r],s(e[0],u)||s(e[1],u)?(a(u[0],e[1])>a(u[0],u[1])&&(u[1]=e[1]),a(e[0],u[1])>a(u[0],u[1])&&(u[0]=e[0])):i.push(u=e);for(var o,e,p=-1/0,t=i.length-1,r=0,u=i[t];t>=r;u=e,++r)e=i[r],(o=a(u[1],e[0]))>p&&(p=o,l=e[0],h=u[1])}return x=M=null,1/0===l||1/0===f?[[0/0,0/0],[0/0,0/0]]:[[l,f],[h,g]]}}(),Xo.geo.centroid=function(n){pc=vc=dc=mc=yc=xc=Mc=_c=bc=wc=Sc=0,Xo.geo.stream(n,kc);var t=bc,e=wc,r=Sc,u=t*t+e*e+r*r;return Ca>u&&(t=xc,e=Mc,r=_c,Aa>vc&&(t=dc,e=mc,r=yc),u=t*t+e*e+r*r,Ca>u)?[0/0,0/0]:[Math.atan2(e,t)*La,X(r/Math.sqrt(u))*La]};var pc,vc,dc,mc,yc,xc,Mc,_c,bc,wc,Sc,kc={sphere:g,point:me,lineStart:xe,lineEnd:Me,polygonStart:function(){kc.lineStart=_e},polygonEnd:function(){kc.lineStart=xe}},Ec=Ee(be,ze,Te,[-Sa,-Sa/2]),Ac=1e9;Xo.geo.clipExtent=function(){var n,t,e,r,u,i,o={stream:function(n){return u&&(u.valid=!1),u=i(n),u.valid=!0,u},extent:function(a){return arguments.length?(i=Pe(n=+a[0][0],t=+a[0][1],e=+a[1][0],r=+a[1][1]),u&&(u.valid=!1,u=null),o):[[n,t],[e,r]]}};return o.extent([[0,0],[960,500]])},(Xo.geo.conicEqualArea=function(){return je(He)}).raw=He,Xo.geo.albers=function(){return Xo.geo.conicEqualArea().rotate([96,0]).center([-.6,38.7]).parallels([29.5,45.5]).scale(1070)},Xo.geo.albersUsa=function(){function n(n){var i=n[0],o=n[1];return t=null,e(i,o),t||(r(i,o),t)||u(i,o),t}var t,e,r,u,i=Xo.geo.albers(),o=Xo.geo.conicEqualArea().rotate([154,0]).center([-2,58.5]).parallels([55,65]),a=Xo.geo.conicEqualArea().rotate([157,0]).center([-3,19.9]).parallels([8,18]),c={point:function(n,e){t=[n,e]}};return n.invert=function(n){var t=i.scale(),e=i.translate(),r=(n[0]-e[0])/t,u=(n[1]-e[1])/t;return(u>=.12&&.234>u&&r>=-.425&&-.214>r?o:u>=.166&&.234>u&&r>=-.214&&-.115>r?a:i).invert(n)},n.stream=function(n){var t=i.stream(n),e=o.stream(n),r=a.stream(n);return{point:function(n,u){t.point(n,u),e.point(n,u),r.point(n,u)},sphere:function(){t.sphere(),e.sphere(),r.sphere()},lineStart:function(){t.lineStart(),e.lineStart(),r.lineStart()},lineEnd:function(){t.lineEnd(),e.lineEnd(),r.lineEnd()},polygonStart:function(){t.polygonStart(),e.polygonStart(),r.polygonStart()},polygonEnd:function(){t.polygonEnd(),e.polygonEnd(),r.polygonEnd()}}},n.precision=function(t){return arguments.length?(i.precision(t),o.precision(t),a.precision(t),n):i.precision()},n.scale=function(t){return arguments.length?(i.scale(t),o.scale(.35*t),a.scale(t),n.translate(i.translate())):i.scale()},n.translate=function(t){if(!arguments.length)return i.translate();var s=i.scale(),l=+t[0],f=+t[1];return e=i.translate(t).clipExtent([[l-.455*s,f-.238*s],[l+.455*s,f+.238*s]]).stream(c).point,r=o.translate([l-.307*s,f+.201*s]).clipExtent([[l-.425*s+Aa,f+.12*s+Aa],[l-.214*s-Aa,f+.234*s-Aa]]).stream(c).point,u=a.translate([l-.205*s,f+.212*s]).clipExtent([[l-.214*s+Aa,f+.166*s+Aa],[l-.115*s-Aa,f+.234*s-Aa]]).stream(c).point,n},n.scale(1070)};var Cc,Nc,Lc,zc,qc,Tc,Rc={point:g,lineStart:g,lineEnd:g,polygonStart:function(){Nc=0,Rc.lineStart=Fe},polygonEnd:function(){Rc.lineStart=Rc.lineEnd=Rc.point=g,Cc+=oa(Nc/2)}},Dc={point:Oe,lineStart:g,lineEnd:g,polygonStart:g,polygonEnd:g},Pc={point:Ze,lineStart:Ve,lineEnd:Xe,polygonStart:function(){Pc.lineStart=$e},polygonEnd:function(){Pc.point=Ze,Pc.lineStart=Ve,Pc.lineEnd=Xe}};Xo.geo.path=function(){function n(n){return n&&("function"==typeof a&&i.pointRadius(+a.apply(this,arguments)),o&&o.valid||(o=u(i)),Xo.geo.stream(n,o)),i.result()}function t(){return o=null,n}var e,r,u,i,o,a=4.5;return n.area=function(n){return Cc=0,Xo.geo.stream(n,u(Rc)),Cc},n.centroid=function(n){return dc=mc=yc=xc=Mc=_c=bc=wc=Sc=0,Xo.geo.stream(n,u(Pc)),Sc?[bc/Sc,wc/Sc]:_c?[xc/_c,Mc/_c]:yc?[dc/yc,mc/yc]:[0/0,0/0]},n.bounds=function(n){return qc=Tc=-(Lc=zc=1/0),Xo.geo.stream(n,u(Dc)),[[Lc,zc],[qc,Tc]]},n.projection=function(n){return arguments.length?(u=(e=n)?n.stream||Je(n):bt,t()):e},n.context=function(n){return arguments.length?(i=null==(r=n)?new Ye:new Be(n),"function"!=typeof a&&i.pointRadius(a),t()):r},n.pointRadius=function(t){return arguments.length?(a="function"==typeof t?t:(i.pointRadius(+t),+t),n):a},n.projection(Xo.geo.albersUsa()).context(null)},Xo.geo.transform=function(n){return{stream:function(t){var e=new Ge(t);for(var r in n)e[r]=n[r];return e}}},Ge.prototype={point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}},Xo.geo.projection=Qe,Xo.geo.projectionMutator=nr,(Xo.geo.equirectangular=function(){return Qe(er)}).raw=er.invert=er,Xo.geo.rotation=function(n){function t(t){return t=n(t[0]*Na,t[1]*Na),t[0]*=La,t[1]*=La,t}return n=ur(n[0]%360*Na,n[1]*Na,n.length>2?n[2]*Na:0),t.invert=function(t){return t=n.invert(t[0]*Na,t[1]*Na),t[0]*=La,t[1]*=La,t},t},rr.invert=er,Xo.geo.circle=function(){function n(){var n="function"==typeof r?r.apply(this,arguments):r,t=ur(-n[0]*Na,-n[1]*Na,0).invert,u=[];return e(null,null,1,{point:function(n,e){u.push(n=t(n,e)),n[0]*=La,n[1]*=La}}),{type:"Polygon",coordinates:[u]}}var t,e,r=[0,0],u=6;return n.origin=function(t){return arguments.length?(r=t,n):r},n.angle=function(r){return arguments.length?(e=cr((t=+r)*Na,u*Na),n):t},n.precision=function(r){return arguments.length?(e=cr(t*Na,(u=+r)*Na),n):u},n.angle(90)},Xo.geo.distance=function(n,t){var e,r=(t[0]-n[0])*Na,u=n[1]*Na,i=t[1]*Na,o=Math.sin(r),a=Math.cos(r),c=Math.sin(u),s=Math.cos(u),l=Math.sin(i),f=Math.cos(i);return Math.atan2(Math.sqrt((e=f*o)*e+(e=s*l-c*f*a)*e),c*l+s*f*a)},Xo.geo.graticule=function(){function n(){return{type:"MultiLineString",coordinates:t()}}function t(){return Xo.range(Math.ceil(i/d)*d,u,d).map(h).concat(Xo.range(Math.ceil(s/m)*m,c,m).map(g)).concat(Xo.range(Math.ceil(r/p)*p,e,p).filter(function(n){return oa(n%d)>Aa}).map(l)).concat(Xo.range(Math.ceil(a/v)*v,o,v).filter(function(n){return oa(n%m)>Aa}).map(f))}var e,r,u,i,o,a,c,s,l,f,h,g,p=10,v=p,d=90,m=360,y=2.5;return n.lines=function(){return t().map(function(n){return{type:"LineString",coordinates:n}})},n.outline=function(){return{type:"Polygon",coordinates:[h(i).concat(g(c).slice(1),h(u).reverse().slice(1),g(s).reverse().slice(1))]}},n.extent=function(t){return arguments.length?n.majorExtent(t).minorExtent(t):n.minorExtent()},n.majorExtent=function(t){return arguments.length?(i=+t[0][0],u=+t[1][0],s=+t[0][1],c=+t[1][1],i>u&&(t=i,i=u,u=t),s>c&&(t=s,s=c,c=t),n.precision(y)):[[i,s],[u,c]]},n.minorExtent=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],a=+t[0][1],o=+t[1][1],r>e&&(t=r,r=e,e=t),a>o&&(t=a,a=o,o=t),n.precision(y)):[[r,a],[e,o]]},n.step=function(t){return arguments.length?n.majorStep(t).minorStep(t):n.minorStep()},n.majorStep=function(t){return arguments.length?(d=+t[0],m=+t[1],n):[d,m]},n.minorStep=function(t){return arguments.length?(p=+t[0],v=+t[1],n):[p,v]},n.precision=function(t){return arguments.length?(y=+t,l=lr(a,o,90),f=fr(r,e,y),h=lr(s,c,90),g=fr(i,u,y),n):y},n.majorExtent([[-180,-90+Aa],[180,90-Aa]]).minorExtent([[-180,-80-Aa],[180,80+Aa]])},Xo.geo.greatArc=function(){function n(){return{type:"LineString",coordinates:[t||r.apply(this,arguments),e||u.apply(this,arguments)]}}var t,e,r=hr,u=gr;return n.distance=function(){return Xo.geo.distance(t||r.apply(this,arguments),e||u.apply(this,arguments))},n.source=function(e){return arguments.length?(r=e,t="function"==typeof e?null:e,n):r},n.target=function(t){return arguments.length?(u=t,e="function"==typeof t?null:t,n):u},n.precision=function(){return arguments.length?n:0},n},Xo.geo.interpolate=function(n,t){return pr(n[0]*Na,n[1]*Na,t[0]*Na,t[1]*Na)},Xo.geo.length=function(n){return Uc=0,Xo.geo.stream(n,jc),Uc};var Uc,jc={sphere:g,point:g,lineStart:vr,lineEnd:g,polygonStart:g,polygonEnd:g},Hc=dr(function(n){return Math.sqrt(2/(1+n))},function(n){return 2*Math.asin(n/2)});(Xo.geo.azimuthalEqualArea=function(){return Qe(Hc)}).raw=Hc;var Fc=dr(function(n){var t=Math.acos(n);return t&&t/Math.sin(t)},bt);(Xo.geo.azimuthalEquidistant=function(){return Qe(Fc)}).raw=Fc,(Xo.geo.conicConformal=function(){return je(mr)}).raw=mr,(Xo.geo.conicEquidistant=function(){return je(yr)}).raw=yr;var Oc=dr(function(n){return 1/n},Math.atan);(Xo.geo.gnomonic=function(){return Qe(Oc)}).raw=Oc,xr.invert=function(n,t){return[n,2*Math.atan(Math.exp(t))-Ea]},(Xo.geo.mercator=function(){return Mr(xr)}).raw=xr;var Yc=dr(function(){return 1},Math.asin);(Xo.geo.orthographic=function(){return Qe(Yc)}).raw=Yc;var Ic=dr(function(n){return 1/(1+n)},function(n){return 2*Math.atan(n)});(Xo.geo.stereographic=function(){return Qe(Ic)}).raw=Ic,_r.invert=function(n,t){return[-t,2*Math.atan(Math.exp(n))-Ea]},(Xo.geo.transverseMercator=function(){var n=Mr(_r),t=n.center,e=n.rotate;return n.center=function(n){return n?t([-n[1],n[0]]):(n=t(),[-n[1],n[0]])},n.rotate=function(n){return n?e([n[0],n[1],n.length>2?n[2]+90:90]):(n=e(),[n[0],n[1],n[2]-90])},n.rotate([0,0])}).raw=_r,Xo.geom={},Xo.geom.hull=function(n){function t(n){if(n.length<3)return[];var t,u=_t(e),i=_t(r),o=n.length,a=[],c=[];for(t=0;o>t;t++)a.push([+u.call(this,n[t],t),+i.call(this,n[t],t),t]);for(a.sort(kr),t=0;o>t;t++)c.push([a[t][0],-a[t][1]]);var s=Sr(a),l=Sr(c),f=l[0]===s[0],h=l[l.length-1]===s[s.length-1],g=[];for(t=s.length-1;t>=0;--t)g.push(n[a[s[t]][2]]);for(t=+f;t=r&&s.x<=i&&s.y>=u&&s.y<=o?[[r,o],[i,o],[i,u],[r,u]]:[];l.point=n[a]}),t}function e(n){return n.map(function(n,t){return{x:Math.round(i(n,t)/Aa)*Aa,y:Math.round(o(n,t)/Aa)*Aa,i:t}})}var r=br,u=wr,i=r,o=u,a=Kc;return n?t(n):(t.links=function(n){return nu(e(n)).edges.filter(function(n){return n.l&&n.r}).map(function(t){return{source:n[t.l.i],target:n[t.r.i]}})},t.triangles=function(n){var t=[];return nu(e(n)).cells.forEach(function(e,r){for(var u,i,o=e.site,a=e.edges.sort(jr),c=-1,s=a.length,l=a[s-1].edge,f=l.l===o?l.r:l.l;++c=s,h=r>=l,g=(h<<1)+f;n.leaf=!1,n=n.nodes[g]||(n.nodes[g]=iu()),f?u=s:a=s,h?o=l:c=l,i(n,t,e,r,u,o,a,c)}var l,f,h,g,p,v,d,m,y,x=_t(a),M=_t(c);if(null!=t)v=t,d=e,m=r,y=u;else if(m=y=-(v=d=1/0),f=[],h=[],p=n.length,o)for(g=0;p>g;++g)l=n[g],l.xm&&(m=l.x),l.y>y&&(y=l.y),f.push(l.x),h.push(l.y);else for(g=0;p>g;++g){var _=+x(l=n[g],g),b=+M(l,g);v>_&&(v=_),d>b&&(d=b),_>m&&(m=_),b>y&&(y=b),f.push(_),h.push(b)}var w=m-v,S=y-d;w>S?y=d+w:m=v+S;var k=iu();if(k.add=function(n){i(k,n,+x(n,++g),+M(n,g),v,d,m,y)},k.visit=function(n){ou(n,k,v,d,m,y)},g=-1,null==t){for(;++g=0?n.substring(0,t):n,r=t>=0?n.substring(t+1):"in";return e=ts.get(e)||ns,r=es.get(r)||bt,gu(r(e.apply(null,$o.call(arguments,1))))},Xo.interpolateHcl=Eu,Xo.interpolateHsl=Au,Xo.interpolateLab=Cu,Xo.interpolateRound=Nu,Xo.transform=function(n){var t=Wo.createElementNS(Xo.ns.prefix.svg,"g");return(Xo.transform=function(n){if(null!=n){t.setAttribute("transform",n);var e=t.transform.baseVal.consolidate()}return new Lu(e?e.matrix:rs)})(n)},Lu.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var rs={a:1,b:0,c:0,d:1,e:0,f:0};Xo.interpolateTransform=Ru,Xo.layout={},Xo.layout.bundle=function(){return function(n){for(var t=[],e=-1,r=n.length;++ea*a/d){if(p>c){var s=t.charge/c;n.px-=i*s,n.py-=o*s}return!0}if(t.point&&c&&p>c){var s=t.pointCharge/c;n.px-=i*s,n.py-=o*s}}return!t.charge}}function t(n){n.px=Xo.event.x,n.py=Xo.event.y,a.resume()}var e,r,u,i,o,a={},c=Xo.dispatch("start","tick","end"),s=[1,1],l=.9,f=us,h=is,g=-30,p=os,v=.1,d=.64,m=[],y=[];return a.tick=function(){if((r*=.99)<.005)return c.end({type:"end",alpha:r=0}),!0;var t,e,a,f,h,p,d,x,M,_=m.length,b=y.length;for(e=0;b>e;++e)a=y[e],f=a.source,h=a.target,x=h.x-f.x,M=h.y-f.y,(p=x*x+M*M)&&(p=r*i[e]*((p=Math.sqrt(p))-u[e])/p,x*=p,M*=p,h.x-=x*(d=f.weight/(h.weight+f.weight)),h.y-=M*d,f.x+=x*(d=1-d),f.y+=M*d);if((d=r*v)&&(x=s[0]/2,M=s[1]/2,e=-1,d))for(;++e<_;)a=m[e],a.x+=(x-a.x)*d,a.y+=(M-a.y)*d;if(g)for(Zu(t=Xo.geom.quadtree(m),r,o),e=-1;++e<_;)(a=m[e]).fixed||t.visit(n(a));for(e=-1;++e<_;)a=m[e],a.fixed?(a.x=a.px,a.y=a.py):(a.x-=(a.px-(a.px=a.x))*l,a.y-=(a.py-(a.py=a.y))*l);c.tick({type:"tick",alpha:r})},a.nodes=function(n){return arguments.length?(m=n,a):m},a.links=function(n){return arguments.length?(y=n,a):y},a.size=function(n){return arguments.length?(s=n,a):s},a.linkDistance=function(n){return arguments.length?(f="function"==typeof n?n:+n,a):f},a.distance=a.linkDistance,a.linkStrength=function(n){return arguments.length?(h="function"==typeof n?n:+n,a):h},a.friction=function(n){return arguments.length?(l=+n,a):l},a.charge=function(n){return arguments.length?(g="function"==typeof n?n:+n,a):g},a.chargeDistance=function(n){return arguments.length?(p=n*n,a):Math.sqrt(p)},a.gravity=function(n){return arguments.length?(v=+n,a):v},a.theta=function(n){return arguments.length?(d=n*n,a):Math.sqrt(d)},a.alpha=function(n){return arguments.length?(n=+n,r?r=n>0?n:0:n>0&&(c.start({type:"start",alpha:r=n}),Xo.timer(a.tick)),a):r},a.start=function(){function n(n,r){if(!e){for(e=new Array(c),a=0;c>a;++a)e[a]=[];for(a=0;s>a;++a){var u=y[a];e[u.source.index].push(u.target),e[u.target.index].push(u.source)}}for(var i,o=e[t],a=-1,s=o.length;++at;++t)(r=m[t]).index=t,r.weight=0;for(t=0;l>t;++t)r=y[t],"number"==typeof r.source&&(r.source=m[r.source]),"number"==typeof r.target&&(r.target=m[r.target]),++r.source.weight,++r.target.weight;for(t=0;c>t;++t)r=m[t],isNaN(r.x)&&(r.x=n("x",p)),isNaN(r.y)&&(r.y=n("y",v)),isNaN(r.px)&&(r.px=r.x),isNaN(r.py)&&(r.py=r.y);if(u=[],"function"==typeof f)for(t=0;l>t;++t)u[t]=+f.call(this,y[t],t);else for(t=0;l>t;++t)u[t]=f;if(i=[],"function"==typeof h)for(t=0;l>t;++t)i[t]=+h.call(this,y[t],t);else for(t=0;l>t;++t)i[t]=h;if(o=[],"function"==typeof g)for(t=0;c>t;++t)o[t]=+g.call(this,m[t],t);else for(t=0;c>t;++t)o[t]=g;return a.resume()},a.resume=function(){return a.alpha(.1)},a.stop=function(){return a.alpha(0)},a.drag=function(){return e||(e=Xo.behavior.drag().origin(bt).on("dragstart.force",Fu).on("drag.force",t).on("dragend.force",Ou)),arguments.length?(this.on("mouseover.force",Yu).on("mouseout.force",Iu).call(e),void 0):e},Xo.rebind(a,c,"on")};var us=20,is=1,os=1/0;Xo.layout.hierarchy=function(){function n(t,o,a){var c=u.call(e,t,o);if(t.depth=o,a.push(t),c&&(s=c.length)){for(var s,l,f=-1,h=t.children=new Array(s),g=0,p=o+1;++fg;++g)for(u.call(n,s[0][g],p=v[g],l[0][g][1]),h=1;d>h;++h)u.call(n,s[h][g],p+=l[h-1][g][1],l[h][g][1]);return a}var t=bt,e=Qu,r=ni,u=Ku,i=Ju,o=Gu;return n.values=function(e){return arguments.length?(t=e,n):t},n.order=function(t){return arguments.length?(e="function"==typeof t?t:cs.get(t)||Qu,n):e},n.offset=function(t){return arguments.length?(r="function"==typeof t?t:ss.get(t)||ni,n):r},n.x=function(t){return arguments.length?(i=t,n):i},n.y=function(t){return arguments.length?(o=t,n):o},n.out=function(t){return arguments.length?(u=t,n):u},n};var cs=Xo.map({"inside-out":function(n){var t,e,r=n.length,u=n.map(ti),i=n.map(ei),o=Xo.range(r).sort(function(n,t){return u[n]-u[t]}),a=0,c=0,s=[],l=[];for(t=0;r>t;++t)e=o[t],c>a?(a+=i[e],s.push(e)):(c+=i[e],l.push(e));return l.reverse().concat(s)},reverse:function(n){return Xo.range(n.length).reverse()},"default":Qu}),ss=Xo.map({silhouette:function(n){var t,e,r,u=n.length,i=n[0].length,o=[],a=0,c=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];r>a&&(a=r),o.push(r)}for(e=0;i>e;++e)c[e]=(a-o[e])/2;return c},wiggle:function(n){var t,e,r,u,i,o,a,c,s,l=n.length,f=n[0],h=f.length,g=[];for(g[0]=c=s=0,e=1;h>e;++e){for(t=0,u=0;l>t;++t)u+=n[t][e][1];for(t=0,i=0,a=f[e][0]-f[e-1][0];l>t;++t){for(r=0,o=(n[t][e][1]-n[t][e-1][1])/(2*a);t>r;++r)o+=(n[r][e][1]-n[r][e-1][1])/a;i+=o*n[t][e][1]}g[e]=c-=u?i/u*a:0,s>c&&(s=c)}for(e=0;h>e;++e)g[e]-=s;return g},expand:function(n){var t,e,r,u=n.length,i=n[0].length,o=1/u,a=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];if(r)for(t=0;u>t;t++)n[t][e][1]/=r;else for(t=0;u>t;t++)n[t][e][1]=o}for(e=0;i>e;++e)a[e]=0;return a},zero:ni});Xo.layout.histogram=function(){function n(n,i){for(var o,a,c=[],s=n.map(e,this),l=r.call(this,s,i),f=u.call(this,l,s,i),i=-1,h=s.length,g=f.length-1,p=t?1:1/h;++i0)for(i=-1;++i=l[0]&&a<=l[1]&&(o=c[Xo.bisect(f,a,1,g)-1],o.y+=p,o.push(n[i]));return c}var t=!0,e=Number,r=oi,u=ui;return n.value=function(t){return arguments.length?(e=t,n):e},n.range=function(t){return arguments.length?(r=_t(t),n):r},n.bins=function(t){return arguments.length?(u="number"==typeof t?function(n){return ii(n,t)}:_t(t),n):u},n.frequency=function(e){return arguments.length?(t=!!e,n):t},n},Xo.layout.tree=function(){function n(n,i){function o(n,t){var r=n.children,u=n._tree;if(r&&(i=r.length)){for(var i,a,s,l=r[0],f=l,h=-1;++h0&&(di(mi(a,n,r),n,u),s+=u,l+=u),f+=a._tree.mod,s+=i._tree.mod,h+=c._tree.mod,l+=o._tree.mod;a&&!si(o)&&(o._tree.thread=a,o._tree.mod+=f-l),i&&!ci(c)&&(c._tree.thread=i,c._tree.mod+=s-h,r=n)}return r}var s=t.call(this,n,i),l=s[0];pi(l,function(n,t){n._tree={ancestor:n,prelim:0,mod:0,change:0,shift:0,number:t?t._tree.number+1:0}}),o(l),a(l,-l._tree.prelim);var f=li(l,hi),h=li(l,fi),g=li(l,gi),p=f.x-e(f,h)/2,v=h.x+e(h,f)/2,d=g.depth||1;return pi(l,u?function(n){n.x*=r[0],n.y=n.depth*r[1],delete n._tree}:function(n){n.x=(n.x-p)/(v-p)*r[0],n.y=n.depth/d*r[1],delete n._tree}),s}var t=Xo.layout.hierarchy().sort(null).value(null),e=ai,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},Vu(n,t)},Xo.layout.pack=function(){function n(n,i){var o=e.call(this,n,i),a=o[0],c=u[0],s=u[1],l=null==t?Math.sqrt:"function"==typeof t?t:function(){return t};if(a.x=a.y=0,pi(a,function(n){n.r=+l(n.value)}),pi(a,bi),r){var f=r*(t?1:Math.max(2*a.r/c,2*a.r/s))/2;pi(a,function(n){n.r+=f}),pi(a,bi),pi(a,function(n){n.r-=f})}return ki(a,c/2,s/2,t?1:1/Math.max(2*a.r/c,2*a.r/s)),o}var t,e=Xo.layout.hierarchy().sort(yi),r=0,u=[1,1];return n.size=function(t){return arguments.length?(u=t,n):u},n.radius=function(e){return arguments.length?(t=null==e||"function"==typeof e?e:+e,n):t},n.padding=function(t){return arguments.length?(r=+t,n):r},Vu(n,e)},Xo.layout.cluster=function(){function n(n,i){var o,a=t.call(this,n,i),c=a[0],s=0;pi(c,function(n){var t=n.children;t&&t.length?(n.x=Ci(t),n.y=Ai(t)):(n.x=o?s+=e(n,o):0,n.y=0,o=n)});var l=Ni(c),f=Li(c),h=l.x-e(l,f)/2,g=f.x+e(f,l)/2;return pi(c,u?function(n){n.x=(n.x-c.x)*r[0],n.y=(c.y-n.y)*r[1]}:function(n){n.x=(n.x-h)/(g-h)*r[0],n.y=(1-(c.y?n.y/c.y:1))*r[1]}),a}var t=Xo.layout.hierarchy().sort(null).value(null),e=ai,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},Vu(n,t)},Xo.layout.treemap=function(){function n(n,t){for(var e,r,u=-1,i=n.length;++ut?0:t),e.area=isNaN(r)||0>=r?0:r}function t(e){var i=e.children;if(i&&i.length){var o,a,c,s=f(e),l=[],h=i.slice(),p=1/0,v="slice"===g?s.dx:"dice"===g?s.dy:"slice-dice"===g?1&e.depth?s.dy:s.dx:Math.min(s.dx,s.dy);for(n(h,s.dx*s.dy/e.value),l.area=0;(c=h.length)>0;)l.push(o=h[c-1]),l.area+=o.area,"squarify"!==g||(a=r(l,v))<=p?(h.pop(),p=a):(l.area-=l.pop().area,u(l,v,s,!1),v=Math.min(s.dx,s.dy),l.length=l.area=0,p=1/0);l.length&&(u(l,v,s,!0),l.length=l.area=0),i.forEach(t)}}function e(t){var r=t.children;if(r&&r.length){var i,o=f(t),a=r.slice(),c=[];for(n(a,o.dx*o.dy/t.value),c.area=0;i=a.pop();)c.push(i),c.area+=i.area,null!=i.z&&(u(c,i.z?o.dx:o.dy,o,!a.length),c.length=c.area=0);r.forEach(e)}}function r(n,t){for(var e,r=n.area,u=0,i=1/0,o=-1,a=n.length;++oe&&(i=e),e>u&&(u=e));return r*=r,t*=t,r?Math.max(t*u*p/r,r/(t*i*p)):1/0}function u(n,t,e,r){var u,i=-1,o=n.length,a=e.x,s=e.y,l=t?c(n.area/t):0;if(t==e.dx){for((r||l>e.dy)&&(l=e.dy);++ie.dx)&&(l=e.dx);++ie&&(t=1),1>e&&(n=0),function(){var e,r,u;do e=2*Math.random()-1,r=2*Math.random()-1,u=e*e+r*r;while(!u||u>1);return n+t*e*Math.sqrt(-2*Math.log(u)/u)}},logNormal:function(){var n=Xo.random.normal.apply(Xo,arguments);return function(){return Math.exp(n())}},bates:function(n){var t=Xo.random.irwinHall(n);return function(){return t()/n}},irwinHall:function(n){return function(){for(var t=0,e=0;n>e;e++)t+=Math.random();return t}}},Xo.scale={};var ls={floor:bt,ceil:bt};Xo.scale.linear=function(){return Hi([0,1],[0,1],fu,!1)};var fs={s:1,g:1,p:1,r:1,e:1};Xo.scale.log=function(){return $i(Xo.scale.linear().domain([0,1]),10,!0,[1,10])};var hs=Xo.format(".0e"),gs={floor:function(n){return-Math.ceil(-n)},ceil:function(n){return-Math.floor(-n)}};Xo.scale.pow=function(){return Bi(Xo.scale.linear(),1,[0,1])},Xo.scale.sqrt=function(){return Xo.scale.pow().exponent(.5)},Xo.scale.ordinal=function(){return Ji([],{t:"range",a:[[]]})},Xo.scale.category10=function(){return Xo.scale.ordinal().range(ps)},Xo.scale.category20=function(){return Xo.scale.ordinal().range(vs)},Xo.scale.category20b=function(){return Xo.scale.ordinal().range(ds)},Xo.scale.category20c=function(){return Xo.scale.ordinal().range(ms)};var ps=[2062260,16744206,2924588,14034728,9725885,9197131,14907330,8355711,12369186,1556175].map(ht),vs=[2062260,11454440,16744206,16759672,2924588,10018698,14034728,16750742,9725885,12955861,9197131,12885140,14907330,16234194,8355711,13092807,12369186,14408589,1556175,10410725].map(ht),ds=[3750777,5395619,7040719,10264286,6519097,9216594,11915115,13556636,9202993,12426809,15186514,15190932,8666169,11356490,14049643,15177372,8077683,10834324,13528509,14589654].map(ht),ms=[3244733,7057110,10406625,13032431,15095053,16616764,16625259,16634018,3253076,7652470,10607003,13101504,7695281,10394312,12369372,14342891,6513507,9868950,12434877,14277081].map(ht);Xo.scale.quantile=function(){return Gi([],[]) +},Xo.scale.quantize=function(){return Ki(0,1,[0,1])},Xo.scale.threshold=function(){return Qi([.5],[0,1])},Xo.scale.identity=function(){return no([0,1])},Xo.svg={},Xo.svg.arc=function(){function n(){var n=t.apply(this,arguments),i=e.apply(this,arguments),o=r.apply(this,arguments)+ys,a=u.apply(this,arguments)+ys,c=(o>a&&(c=o,o=a,a=c),a-o),s=Sa>c?"0":"1",l=Math.cos(o),f=Math.sin(o),h=Math.cos(a),g=Math.sin(a);return c>=xs?n?"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"M0,"+n+"A"+n+","+n+" 0 1,0 0,"+-n+"A"+n+","+n+" 0 1,0 0,"+n+"Z":"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"Z":n?"M"+i*l+","+i*f+"A"+i+","+i+" 0 "+s+",1 "+i*h+","+i*g+"L"+n*h+","+n*g+"A"+n+","+n+" 0 "+s+",0 "+n*l+","+n*f+"Z":"M"+i*l+","+i*f+"A"+i+","+i+" 0 "+s+",1 "+i*h+","+i*g+"L0,0"+"Z"}var t=to,e=eo,r=ro,u=uo;return n.innerRadius=function(e){return arguments.length?(t=_t(e),n):t},n.outerRadius=function(t){return arguments.length?(e=_t(t),n):e},n.startAngle=function(t){return arguments.length?(r=_t(t),n):r},n.endAngle=function(t){return arguments.length?(u=_t(t),n):u},n.centroid=function(){var n=(t.apply(this,arguments)+e.apply(this,arguments))/2,i=(r.apply(this,arguments)+u.apply(this,arguments))/2+ys;return[Math.cos(i)*n,Math.sin(i)*n]},n};var ys=-Ea,xs=ka-Aa;Xo.svg.line=function(){return io(bt)};var Ms=Xo.map({linear:oo,"linear-closed":ao,step:co,"step-before":so,"step-after":lo,basis:mo,"basis-open":yo,"basis-closed":xo,bundle:Mo,cardinal:go,"cardinal-open":fo,"cardinal-closed":ho,monotone:Eo});Ms.forEach(function(n,t){t.key=n,t.closed=/-closed$/.test(n)});var _s=[0,2/3,1/3,0],bs=[0,1/3,2/3,0],ws=[0,1/6,2/3,1/6];Xo.svg.line.radial=function(){var n=io(Ao);return n.radius=n.x,delete n.x,n.angle=n.y,delete n.y,n},so.reverse=lo,lo.reverse=so,Xo.svg.area=function(){return Co(bt)},Xo.svg.area.radial=function(){var n=Co(Ao);return n.radius=n.x,delete n.x,n.innerRadius=n.x0,delete n.x0,n.outerRadius=n.x1,delete n.x1,n.angle=n.y,delete n.y,n.startAngle=n.y0,delete n.y0,n.endAngle=n.y1,delete n.y1,n},Xo.svg.chord=function(){function n(n,a){var c=t(this,i,n,a),s=t(this,o,n,a);return"M"+c.p0+r(c.r,c.p1,c.a1-c.a0)+(e(c,s)?u(c.r,c.p1,c.r,c.p0):u(c.r,c.p1,s.r,s.p0)+r(s.r,s.p1,s.a1-s.a0)+u(s.r,s.p1,c.r,c.p0))+"Z"}function t(n,t,e,r){var u=t.call(n,e,r),i=a.call(n,u,r),o=c.call(n,u,r)+ys,l=s.call(n,u,r)+ys;return{r:i,a0:o,a1:l,p0:[i*Math.cos(o),i*Math.sin(o)],p1:[i*Math.cos(l),i*Math.sin(l)]}}function e(n,t){return n.a0==t.a0&&n.a1==t.a1}function r(n,t,e){return"A"+n+","+n+" 0 "+ +(e>Sa)+",1 "+t}function u(n,t,e,r){return"Q 0,0 "+r}var i=hr,o=gr,a=No,c=ro,s=uo;return n.radius=function(t){return arguments.length?(a=_t(t),n):a},n.source=function(t){return arguments.length?(i=_t(t),n):i},n.target=function(t){return arguments.length?(o=_t(t),n):o},n.startAngle=function(t){return arguments.length?(c=_t(t),n):c},n.endAngle=function(t){return arguments.length?(s=_t(t),n):s},n},Xo.svg.diagonal=function(){function n(n,u){var i=t.call(this,n,u),o=e.call(this,n,u),a=(i.y+o.y)/2,c=[i,{x:i.x,y:a},{x:o.x,y:a},o];return c=c.map(r),"M"+c[0]+"C"+c[1]+" "+c[2]+" "+c[3]}var t=hr,e=gr,r=Lo;return n.source=function(e){return arguments.length?(t=_t(e),n):t},n.target=function(t){return arguments.length?(e=_t(t),n):e},n.projection=function(t){return arguments.length?(r=t,n):r},n},Xo.svg.diagonal.radial=function(){var n=Xo.svg.diagonal(),t=Lo,e=n.projection;return n.projection=function(n){return arguments.length?e(zo(t=n)):t},n},Xo.svg.symbol=function(){function n(n,r){return(Ss.get(t.call(this,n,r))||Ro)(e.call(this,n,r))}var t=To,e=qo;return n.type=function(e){return arguments.length?(t=_t(e),n):t},n.size=function(t){return arguments.length?(e=_t(t),n):e},n};var Ss=Xo.map({circle:Ro,cross:function(n){var t=Math.sqrt(n/5)/2;return"M"+-3*t+","+-t+"H"+-t+"V"+-3*t+"H"+t+"V"+-t+"H"+3*t+"V"+t+"H"+t+"V"+3*t+"H"+-t+"V"+t+"H"+-3*t+"Z"},diamond:function(n){var t=Math.sqrt(n/(2*Cs)),e=t*Cs;return"M0,"+-t+"L"+e+",0"+" 0,"+t+" "+-e+",0"+"Z"},square:function(n){var t=Math.sqrt(n)/2;return"M"+-t+","+-t+"L"+t+","+-t+" "+t+","+t+" "+-t+","+t+"Z"},"triangle-down":function(n){var t=Math.sqrt(n/As),e=t*As/2;return"M0,"+e+"L"+t+","+-e+" "+-t+","+-e+"Z"},"triangle-up":function(n){var t=Math.sqrt(n/As),e=t*As/2;return"M0,"+-e+"L"+t+","+e+" "+-t+","+e+"Z"}});Xo.svg.symbolTypes=Ss.keys();var ks,Es,As=Math.sqrt(3),Cs=Math.tan(30*Na),Ns=[],Ls=0;Ns.call=da.call,Ns.empty=da.empty,Ns.node=da.node,Ns.size=da.size,Xo.transition=function(n){return arguments.length?ks?n.transition():n:xa.transition()},Xo.transition.prototype=Ns,Ns.select=function(n){var t,e,r,u=this.id,i=[];n=M(n);for(var o=-1,a=this.length;++oi;i++){u.push(t=[]);for(var e=this[i],a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a,i)&&t.push(r)}return Do(u,this.id)},Ns.tween=function(n,t){var e=this.id;return arguments.length<2?this.node().__transition__[e].tween.get(n):R(this,null==t?function(t){t.__transition__[e].tween.remove(n)}:function(r){r.__transition__[e].tween.set(n,t)})},Ns.attr=function(n,t){function e(){this.removeAttribute(a)}function r(){this.removeAttributeNS(a.space,a.local)}function u(n){return null==n?e:(n+="",function(){var t,e=this.getAttribute(a);return e!==n&&(t=o(e,n),function(n){this.setAttribute(a,t(n))})})}function i(n){return null==n?r:(n+="",function(){var t,e=this.getAttributeNS(a.space,a.local);return e!==n&&(t=o(e,n),function(n){this.setAttributeNS(a.space,a.local,t(n))})})}if(arguments.length<2){for(t in n)this.attr(t,n[t]);return this}var o="transform"==n?Ru:fu,a=Xo.ns.qualify(n);return Po(this,"attr."+n,t,a.local?i:u)},Ns.attrTween=function(n,t){function e(n,e){var r=t.call(this,n,e,this.getAttribute(u));return r&&function(n){this.setAttribute(u,r(n))}}function r(n,e){var r=t.call(this,n,e,this.getAttributeNS(u.space,u.local));return r&&function(n){this.setAttributeNS(u.space,u.local,r(n))}}var u=Xo.ns.qualify(n);return this.tween("attr."+n,u.local?r:e)},Ns.style=function(n,t,e){function r(){this.style.removeProperty(n)}function u(t){return null==t?r:(t+="",function(){var r,u=Go.getComputedStyle(this,null).getPropertyValue(n);return u!==t&&(r=fu(u,t),function(t){this.style.setProperty(n,r(t),e)})})}var i=arguments.length;if(3>i){if("string"!=typeof n){2>i&&(t="");for(e in n)this.style(e,n[e],t);return this}e=""}return Po(this,"style."+n,t,u)},Ns.styleTween=function(n,t,e){function r(r,u){var i=t.call(this,r,u,Go.getComputedStyle(this,null).getPropertyValue(n));return i&&function(t){this.style.setProperty(n,i(t),e)}}return arguments.length<3&&(e=""),this.tween("style."+n,r)},Ns.text=function(n){return Po(this,"text",n,Uo)},Ns.remove=function(){return this.each("end.transition",function(){var n;this.__transition__.count<2&&(n=this.parentNode)&&n.removeChild(this)})},Ns.ease=function(n){var t=this.id;return arguments.length<1?this.node().__transition__[t].ease:("function"!=typeof n&&(n=Xo.ease.apply(Xo,arguments)),R(this,function(e){e.__transition__[t].ease=n}))},Ns.delay=function(n){var t=this.id;return R(this,"function"==typeof n?function(e,r,u){e.__transition__[t].delay=+n.call(e,e.__data__,r,u)}:(n=+n,function(e){e.__transition__[t].delay=n}))},Ns.duration=function(n){var t=this.id;return R(this,"function"==typeof n?function(e,r,u){e.__transition__[t].duration=Math.max(1,n.call(e,e.__data__,r,u))}:(n=Math.max(1,n),function(e){e.__transition__[t].duration=n}))},Ns.each=function(n,t){var e=this.id;if(arguments.length<2){var r=Es,u=ks;ks=e,R(this,function(t,r,u){Es=t.__transition__[e],n.call(t,t.__data__,r,u)}),Es=r,ks=u}else R(this,function(r){var u=r.__transition__[e];(u.event||(u.event=Xo.dispatch("start","end"))).on(n,t)});return this},Ns.transition=function(){for(var n,t,e,r,u=this.id,i=++Ls,o=[],a=0,c=this.length;c>a;a++){o.push(n=[]);for(var t=this[a],s=0,l=t.length;l>s;s++)(e=t[s])&&(r=Object.create(e.__transition__[u]),r.delay+=r.duration,jo(e,s,i,r)),n.push(e)}return Do(o,i)},Xo.svg.axis=function(){function n(n){n.each(function(){var n,s=Xo.select(this),l=this.__chart__||e,f=this.__chart__=e.copy(),h=null==c?f.ticks?f.ticks.apply(f,a):f.domain():c,g=null==t?f.tickFormat?f.tickFormat.apply(f,a):bt:t,p=s.selectAll(".tick").data(h,f),v=p.enter().insert("g",".domain").attr("class","tick").style("opacity",Aa),d=Xo.transition(p.exit()).style("opacity",Aa).remove(),m=Xo.transition(p).style("opacity",1),y=Ri(f),x=s.selectAll(".domain").data([0]),M=(x.enter().append("path").attr("class","domain"),Xo.transition(x));v.append("line"),v.append("text");var _=v.select("line"),b=m.select("line"),w=p.select("text").text(g),S=v.select("text"),k=m.select("text");switch(r){case"bottom":n=Ho,_.attr("y2",u),S.attr("y",Math.max(u,0)+o),b.attr("x2",0).attr("y2",u),k.attr("x",0).attr("y",Math.max(u,0)+o),w.attr("dy",".71em").style("text-anchor","middle"),M.attr("d","M"+y[0]+","+i+"V0H"+y[1]+"V"+i);break;case"top":n=Ho,_.attr("y2",-u),S.attr("y",-(Math.max(u,0)+o)),b.attr("x2",0).attr("y2",-u),k.attr("x",0).attr("y",-(Math.max(u,0)+o)),w.attr("dy","0em").style("text-anchor","middle"),M.attr("d","M"+y[0]+","+-i+"V0H"+y[1]+"V"+-i);break;case"left":n=Fo,_.attr("x2",-u),S.attr("x",-(Math.max(u,0)+o)),b.attr("x2",-u).attr("y2",0),k.attr("x",-(Math.max(u,0)+o)).attr("y",0),w.attr("dy",".32em").style("text-anchor","end"),M.attr("d","M"+-i+","+y[0]+"H0V"+y[1]+"H"+-i);break;case"right":n=Fo,_.attr("x2",u),S.attr("x",Math.max(u,0)+o),b.attr("x2",u).attr("y2",0),k.attr("x",Math.max(u,0)+o).attr("y",0),w.attr("dy",".32em").style("text-anchor","start"),M.attr("d","M"+i+","+y[0]+"H0V"+y[1]+"H"+i)}if(f.rangeBand){var E=f,A=E.rangeBand()/2;l=f=function(n){return E(n)+A}}else l.rangeBand?l=f:d.call(n,f);v.call(n,l),m.call(n,f)})}var t,e=Xo.scale.linear(),r=zs,u=6,i=6,o=3,a=[10],c=null;return n.scale=function(t){return arguments.length?(e=t,n):e},n.orient=function(t){return arguments.length?(r=t in qs?t+"":zs,n):r},n.ticks=function(){return arguments.length?(a=arguments,n):a},n.tickValues=function(t){return arguments.length?(c=t,n):c},n.tickFormat=function(e){return arguments.length?(t=e,n):t},n.tickSize=function(t){var e=arguments.length;return e?(u=+t,i=+arguments[e-1],n):u},n.innerTickSize=function(t){return arguments.length?(u=+t,n):u},n.outerTickSize=function(t){return arguments.length?(i=+t,n):i},n.tickPadding=function(t){return arguments.length?(o=+t,n):o},n.tickSubdivide=function(){return arguments.length&&n},n};var zs="bottom",qs={top:1,right:1,bottom:1,left:1};Xo.svg.brush=function(){function n(i){i.each(function(){var i=Xo.select(this).style("pointer-events","all").style("-webkit-tap-highlight-color","rgba(0,0,0,0)").on("mousedown.brush",u).on("touchstart.brush",u),o=i.selectAll(".background").data([0]);o.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),i.selectAll(".extent").data([0]).enter().append("rect").attr("class","extent").style("cursor","move");var a=i.selectAll(".resize").data(p,bt);a.exit().remove(),a.enter().append("g").attr("class",function(n){return"resize "+n}).style("cursor",function(n){return Ts[n]}).append("rect").attr("x",function(n){return/[ew]$/.test(n)?-3:null}).attr("y",function(n){return/^[ns]/.test(n)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),a.style("display",n.empty()?"none":null);var l,f=Xo.transition(i),h=Xo.transition(o);c&&(l=Ri(c),h.attr("x",l[0]).attr("width",l[1]-l[0]),e(f)),s&&(l=Ri(s),h.attr("y",l[0]).attr("height",l[1]-l[0]),r(f)),t(f)})}function t(n){n.selectAll(".resize").attr("transform",function(n){return"translate("+l[+/e$/.test(n)]+","+f[+/^s/.test(n)]+")"})}function e(n){n.select(".extent").attr("x",l[0]),n.selectAll(".extent,.n>rect,.s>rect").attr("width",l[1]-l[0])}function r(n){n.select(".extent").attr("y",f[0]),n.selectAll(".extent,.e>rect,.w>rect").attr("height",f[1]-f[0])}function u(){function u(){32==Xo.event.keyCode&&(C||(x=null,L[0]-=l[1],L[1]-=f[1],C=2),d())}function p(){32==Xo.event.keyCode&&2==C&&(L[0]+=l[1],L[1]+=f[1],C=0,d())}function v(){var n=Xo.mouse(_),u=!1;M&&(n[0]+=M[0],n[1]+=M[1]),C||(Xo.event.altKey?(x||(x=[(l[0]+l[1])/2,(f[0]+f[1])/2]),L[0]=l[+(n[0]p?(u=r,r=p):u=p),v[0]!=r||v[1]!=u?(e?o=null:i=null,v[0]=r,v[1]=u,!0):void 0}function y(){v(),S.style("pointer-events","all").selectAll(".resize").style("display",n.empty()?"none":null),Xo.select("body").style("cursor",null),z.on("mousemove.brush",null).on("mouseup.brush",null).on("touchmove.brush",null).on("touchend.brush",null).on("keydown.brush",null).on("keyup.brush",null),N(),w({type:"brushend"})}var x,M,_=this,b=Xo.select(Xo.event.target),w=a.of(_,arguments),S=Xo.select(_),k=b.datum(),E=!/^(n|s)$/.test(k)&&c,A=!/^(e|w)$/.test(k)&&s,C=b.classed("extent"),N=O(),L=Xo.mouse(_),z=Xo.select(Go).on("keydown.brush",u).on("keyup.brush",p);if(Xo.event.changedTouches?z.on("touchmove.brush",v).on("touchend.brush",y):z.on("mousemove.brush",v).on("mouseup.brush",y),S.interrupt().selectAll("*").interrupt(),C)L[0]=l[0]-L[0],L[1]=f[0]-L[1];else if(k){var q=+/w$/.test(k),T=+/^n/.test(k);M=[l[1-q]-L[0],f[1-T]-L[1]],L[0]=l[q],L[1]=f[T]}else Xo.event.altKey&&(x=L.slice());S.style("pointer-events","none").selectAll(".resize").style("display",null),Xo.select("body").style("cursor",b.style("cursor")),w({type:"brushstart"}),v()}var i,o,a=y(n,"brushstart","brush","brushend"),c=null,s=null,l=[0,0],f=[0,0],h=!0,g=!0,p=Rs[0];return n.event=function(n){n.each(function(){var n=a.of(this,arguments),t={x:l,y:f,i:i,j:o},e=this.__chart__||t;this.__chart__=t,ks?Xo.select(this).transition().each("start.brush",function(){i=e.i,o=e.j,l=e.x,f=e.y,n({type:"brushstart"})}).tween("brush:brush",function(){var e=hu(l,t.x),r=hu(f,t.y);return i=o=null,function(u){l=t.x=e(u),f=t.y=r(u),n({type:"brush",mode:"resize"})}}).each("end.brush",function(){i=t.i,o=t.j,n({type:"brush",mode:"resize"}),n({type:"brushend"})}):(n({type:"brushstart"}),n({type:"brush",mode:"resize"}),n({type:"brushend"}))})},n.x=function(t){return arguments.length?(c=t,p=Rs[!c<<1|!s],n):c},n.y=function(t){return arguments.length?(s=t,p=Rs[!c<<1|!s],n):s},n.clamp=function(t){return arguments.length?(c&&s?(h=!!t[0],g=!!t[1]):c?h=!!t:s&&(g=!!t),n):c&&s?[h,g]:c?h:s?g:null},n.extent=function(t){var e,r,u,a,h;return arguments.length?(c&&(e=t[0],r=t[1],s&&(e=e[0],r=r[0]),i=[e,r],c.invert&&(e=c(e),r=c(r)),e>r&&(h=e,e=r,r=h),(e!=l[0]||r!=l[1])&&(l=[e,r])),s&&(u=t[0],a=t[1],c&&(u=u[1],a=a[1]),o=[u,a],s.invert&&(u=s(u),a=s(a)),u>a&&(h=u,u=a,a=h),(u!=f[0]||a!=f[1])&&(f=[u,a])),n):(c&&(i?(e=i[0],r=i[1]):(e=l[0],r=l[1],c.invert&&(e=c.invert(e),r=c.invert(r)),e>r&&(h=e,e=r,r=h))),s&&(o?(u=o[0],a=o[1]):(u=f[0],a=f[1],s.invert&&(u=s.invert(u),a=s.invert(a)),u>a&&(h=u,u=a,a=h))),c&&s?[[e,u],[r,a]]:c?[e,r]:s&&[u,a])},n.clear=function(){return n.empty()||(l=[0,0],f=[0,0],i=o=null),n},n.empty=function(){return!!c&&l[0]==l[1]||!!s&&f[0]==f[1]},Xo.rebind(n,a,"on")};var Ts={n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},Rs=[["n","e","s","w","nw","ne","se","sw"],["e","w"],["n","s"],[]],Ds=tc.format=ac.timeFormat,Ps=Ds.utc,Us=Ps("%Y-%m-%dT%H:%M:%S.%LZ");Ds.iso=Date.prototype.toISOString&&+new Date("2000-01-01T00:00:00.000Z")?Oo:Us,Oo.parse=function(n){var t=new Date(n);return isNaN(t)?null:t},Oo.toString=Us.toString,tc.second=Rt(function(n){return new ec(1e3*Math.floor(n/1e3))},function(n,t){n.setTime(n.getTime()+1e3*Math.floor(t))},function(n){return n.getSeconds()}),tc.seconds=tc.second.range,tc.seconds.utc=tc.second.utc.range,tc.minute=Rt(function(n){return new ec(6e4*Math.floor(n/6e4))},function(n,t){n.setTime(n.getTime()+6e4*Math.floor(t))},function(n){return n.getMinutes()}),tc.minutes=tc.minute.range,tc.minutes.utc=tc.minute.utc.range,tc.hour=Rt(function(n){var t=n.getTimezoneOffset()/60;return new ec(36e5*(Math.floor(n/36e5-t)+t))},function(n,t){n.setTime(n.getTime()+36e5*Math.floor(t))},function(n){return n.getHours()}),tc.hours=tc.hour.range,tc.hours.utc=tc.hour.utc.range,tc.month=Rt(function(n){return n=tc.day(n),n.setDate(1),n},function(n,t){n.setMonth(n.getMonth()+t)},function(n){return n.getMonth()}),tc.months=tc.month.range,tc.months.utc=tc.month.utc.range;var js=[1e3,5e3,15e3,3e4,6e4,3e5,9e5,18e5,36e5,108e5,216e5,432e5,864e5,1728e5,6048e5,2592e6,7776e6,31536e6],Hs=[[tc.second,1],[tc.second,5],[tc.second,15],[tc.second,30],[tc.minute,1],[tc.minute,5],[tc.minute,15],[tc.minute,30],[tc.hour,1],[tc.hour,3],[tc.hour,6],[tc.hour,12],[tc.day,1],[tc.day,2],[tc.week,1],[tc.month,1],[tc.month,3],[tc.year,1]],Fs=Ds.multi([[".%L",function(n){return n.getMilliseconds()}],[":%S",function(n){return n.getSeconds()}],["%I:%M",function(n){return n.getMinutes()}],["%I %p",function(n){return n.getHours()}],["%a %d",function(n){return n.getDay()&&1!=n.getDate()}],["%b %d",function(n){return 1!=n.getDate()}],["%B",function(n){return n.getMonth()}],["%Y",be]]),Os={range:function(n,t,e){return Xo.range(+n,+t,e).map(Io)},floor:bt,ceil:bt};Hs.year=tc.year,tc.scale=function(){return Yo(Xo.scale.linear(),Hs,Fs)};var Ys=Hs.map(function(n){return[n[0].utc,n[1]]}),Is=Ps.multi([[".%L",function(n){return n.getUTCMilliseconds()}],[":%S",function(n){return n.getUTCSeconds()}],["%I:%M",function(n){return n.getUTCMinutes()}],["%I %p",function(n){return n.getUTCHours()}],["%a %d",function(n){return n.getUTCDay()&&1!=n.getUTCDate()}],["%b %d",function(n){return 1!=n.getUTCDate()}],["%B",function(n){return n.getUTCMonth()}],["%Y",be]]);Ys.year=tc.year.utc,tc.scale.utc=function(){return Yo(Xo.scale.linear(),Ys,Is)},Xo.text=wt(function(n){return n.responseText}),Xo.json=function(n,t){return St(n,"application/json",Zo,t)},Xo.html=function(n,t){return St(n,"text/html",Vo,t)},Xo.xml=wt(function(n){return n.responseXML}),"function"==typeof define&&define.amd?define(Xo):"object"==typeof module&&module.exports?module.exports=Xo:this.d3=Xo}(); \ No newline at end of file diff --git a/assets/libs/documentScroll.js b/assets/libs/documentScroll.js new file mode 100755 index 000000000..c780c289e --- /dev/null +++ b/assets/libs/documentScroll.js @@ -0,0 +1,39 @@ +/** + * Объект с информацией о прокрутке в документе + * @return {object} + * top: сколько пикселей прокручено сверху, верхняя граница видимой части + * bottom: top + высота окна, то есть нижняя граница видимой части пикселей низ, + * height: полная высота страницы + */ +function getDocumentScroll() { + return { + top: getDocumentScrollTop(), + bottom: getDocumentScrollBottom(), + height: getDocumentScrollHeight() + }; +} + + +function getDocumentScrollTop() { + var html = document.documentElement; + var body = document.body; + + var scrollTop = html.scrollTop || body && body.scrollTop || 0; + scrollTop -= html.clientTop; // IE<8 + + return scrollTop; +} + +function getDocumentScrollHeight() { + var scrollHeight = document.documentElement.scrollHeight; + var clientHeight = document.documentElement.clientHeight; + + scrollHeight = Math.max(scrollHeight, clientHeight); + + return scrollHeight; +} + +function getDocumentScrollBottom() { + return getDocumentScrollTop() + document.documentElement.clientHeight; +} + diff --git a/assets/libs/domtree.js b/assets/libs/domtree.js new file mode 100755 index 000000000..721490a3f --- /dev/null +++ b/assets/libs/domtree.js @@ -0,0 +1,241 @@ +// ebook-converter removes CSS which styles SVG +// that's why I style here in JS + +function drawHtmlTree(json, nodeTarget, w, h) { + + if (typeof nodeTarget == 'string') { + nodeTarget = document.querySelectorAll(nodeTarget); + nodeTarget = nodeTarget[nodeTarget.length - 1]; + } + + w = w || 960; + h = h || 800; + + var i = 0, + barHeight = 30, + barWidth = 250, + barMargin = 2.5, + barRadius = 4, + duration = 400, + root; + + var tree, diagonal, vis; + + function update(source) { + // Compute the flattened node list. TODO use d3.layout.hierarchy. + var nodes = tree.nodes(root); + // Compute the "layout". + nodes.forEach(function(n, i) { + n.x = i * barHeight; + }); + + // Update the nodes… + var node = vis.selectAll("g.node") + .data(nodes, function(d) { + return d.id || (d.id = ++i); + }); + + var nodeEnter = node.enter().append("svg:g") + .attr("class", "node") + .attr("transform", function(d) { + return "translate(" + (source.y0) + "," + (source.x0) + ")"; + }) + .style("opacity", 1e-6); + + // Enter any new nodes at the parent's previous position. + nodeEnter.append("svg:rect") + .attr("y", function () { return -barHeight / 2 + barMargin; }) + .attr("x", -5) + .attr("rx",barRadius) + .attr("ry",barRadius) + .attr("height", barHeight-barMargin*2) + .attr("width", barWidth) + .style("fill", color) + .style("cursor", "pointer") + .on("click", click); + + + nodeEnter.append("svg:text") + .attr("dy", 4.5) + .attr("dx", 3.5) + .style('fill', 'black') + .style("pointer-events", "none"); + + + nodeEnter.append("svg:text") + .attr("dy", 4.5) + .attr("dx", function(d) { + return d.content ? 5.5 : 16.5; + }) + .style('font', '14px Consolas, monospace') + .style('fill', '#333') + .style("pointer-events", "none") + .text(function(d) { + var text = d.name; + if (d.content) { + if (/^\s*$/.test(d.content)) { + text += " " + d.content.replace(/\n/g, "↵").replace(/ /g, '␣'); + } else { + text += " " + d.content; + } + } + return text; + }); + + // Transition nodes to their new position. + nodeEnter.transition() + .duration(duration) + .attr("transform", function(d, i) { + return "translate(" + (d.y) + "," + (d.x) + ")"; + }) + .style("opacity", 1); + + node.transition() + .duration(duration) + .attr("transform", function(d) { + return "translate(" + d.y + "," + (d.x) + ")"; + }) + .style("opacity", 1) + .select("text") + .text(function(d) { + if (d.content) return ""; + if (d._children) { + return "▸ "; + } else { + return "▾ "; + } + }); + + // Transition exiting nodes to the parent's new position. + node.exit().transition() + .duration(duration) + .attr("transform", function(d) { + return "translate(" + source.y + "," + source.x + ")"; + }) + .style("opacity", 1e-6) + .remove(); + + // Update the links… + var link = vis.selectAll("path.link") + .data(tree.links(nodes), function(d) { + return d.target.id; + }); + + // Enter any new links at the parent's previous position. + link.enter().insert("svg:path", "g") + .attr("class", "link") + .style('fill', 'none') + .style('stroke', '#BEC3C7') + .style('stroke-width', '1px') + .attr("d", function(d) { + var o = { + x: source.x0, + y: source.y0 + }; + return diagonal({ + source: o, + target: o + }); + }) + .transition() + .duration(duration) + .attr("d", diagonal); + + // Transition links to their new position. + link.transition() + .duration(duration) + .attr("d", diagonal); + + // Transition exiting nodes to the parent's new position. + link.exit().transition() + .duration(duration) + .attr("d", function(d) { + var o = { + x: source.x, + y: source.y + }; + return diagonal({ + source: o, + target: o + }); + }) + .remove(); + + // Stash the old positions for transition. + nodes.forEach(function(d) { + d.x0 = d.x; + d.y0 = d.y; + }); + } + + // Toggle children on click. + + function click(d) { + if (d.children) { + d._children = d.children; + d.children = null; + } else { + d.children = d._children; + d._children = null; + } + update(d); + } + + function color(d) { + return d.nodeType == 1 ? "#CEE0F4" : + d.nodeType == 3 ? '#FFDE99' : '#CFCE95'; + } + + function drawTree(json) { + + tree = d3.layout.tree() + .size([h, 100]); + + diagonal = function(d){ + var deltaX = 7; + var deltaY = 0; + var points = [ + "M", [d.source.y+deltaX, d.source.x+deltaY].join(","), + "L", [d.source.y+deltaX, d.target.x+deltaY].join(","), + "L", [d.target.y+deltaX, d.target.x+deltaY].join(","), + ]; + return points.join(""); + }; + + + vis = d3.select(nodeTarget).append("svg:svg") + .attr("width", w) + .attr("height", h) + .append("svg:g") + .attr("transform", "translate(20,30)"); + + json.x0 = 0; + json.y0 = 0; + update(root = json); + } + + nodeTarget.innerHTML = ""; + + drawTree(json); + +} + + +function node2json(node) { + var obj = { + name: node.nodeName, + nodeType: node.nodeType + }; + + if (node.nodeType != 1) { + obj.content = node.data; + return obj; + } + + obj.children = []; + for(var i=0; i + * Build: `lodash modern -o ./dist/lodash.js` + * Copyright 2012-2013 The Dojo Foundation + * Based on Underscore.js 1.5.2 + * Copyright 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Available under MIT license + */ +;(function() { + + /** Used as a safe reference for `undefined` in pre ES5 environments */ + var undefined; + + /** Used to pool arrays and objects used internally */ + var arrayPool = [], + objectPool = []; + + /** Used to generate unique IDs */ + var idCounter = 0; + + /** Used to prefix keys to avoid issues with `__proto__` and properties on `Object.prototype` */ + var keyPrefix = +new Date + ''; + + /** Used as the size when optimizations are enabled for large arrays */ + var largeArraySize = 75; + + /** Used as the max size of the `arrayPool` and `objectPool` */ + var maxPoolSize = 40; + + /** Used to detect and test whitespace */ + var whitespace = ( + // whitespace + ' \t\x0B\f\xA0\ufeff' + + + // line terminators + '\n\r\u2028\u2029' + + + // unicode category "Zs" space separators + '\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000' + ); + + /** Used to match empty string literals in compiled template source */ + var reEmptyStringLeading = /\b__p \+= '';/g, + reEmptyStringMiddle = /\b(__p \+=) '' \+/g, + reEmptyStringTrailing = /(__e\(.*?\)|\b__t\)) \+\n'';/g; + + /** + * Used to match ES6 template delimiters + * http://people.mozilla.org/~jorendorff/es6-draft.html#sec-literals-string-literals + */ + var reEsTemplate = /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g; + + /** Used to match regexp flags from their coerced string values */ + var reFlags = /\w*$/; + + /** Used to detected named functions */ + var reFuncName = /^\s*function[ \n\r\t]+\w/; + + /** Used to match "interpolate" template delimiters */ + var reInterpolate = /<%=([\s\S]+?)%>/g; + + /** Used to match leading whitespace and zeros to be removed */ + var reLeadingSpacesAndZeros = RegExp('^[' + whitespace + ']*0+(?=.$)'); + + /** Used to ensure capturing order of template delimiters */ + var reNoMatch = /($^)/; + + /** Used to detect functions containing a `this` reference */ + var reThis = /\bthis\b/; + + /** Used to match unescaped characters in compiled string literals */ + var reUnescapedString = /['\n\r\t\u2028\u2029\\]/g; + + /** Used to assign default `context` object properties */ + var contextProps = [ + 'Array', 'Boolean', 'Date', 'Function', 'Math', 'Number', 'Object', + 'RegExp', 'String', '_', 'attachEvent', 'clearTimeout', 'isFinite', 'isNaN', + 'parseInt', 'setTimeout' + ]; + + /** Used to make template sourceURLs easier to identify */ + var templateCounter = 0; + + /** `Object#toString` result shortcuts */ + var argsClass = '[object Arguments]', + arrayClass = '[object Array]', + boolClass = '[object Boolean]', + dateClass = '[object Date]', + funcClass = '[object Function]', + numberClass = '[object Number]', + objectClass = '[object Object]', + regexpClass = '[object RegExp]', + stringClass = '[object String]'; + + /** Used to identify object classifications that `_.clone` supports */ + var cloneableClasses = {}; + cloneableClasses[funcClass] = false; + cloneableClasses[argsClass] = cloneableClasses[arrayClass] = + cloneableClasses[boolClass] = cloneableClasses[dateClass] = + cloneableClasses[numberClass] = cloneableClasses[objectClass] = + cloneableClasses[regexpClass] = cloneableClasses[stringClass] = true; + + /** Used as an internal `_.debounce` options object */ + var debounceOptions = { + 'leading': false, + 'maxWait': 0, + 'trailing': false + }; + + /** Used as the property descriptor for `__bindData__` */ + var descriptor = { + 'configurable': false, + 'enumerable': false, + 'value': null, + 'writable': false + }; + + /** Used to determine if values are of the language type Object */ + var objectTypes = { + 'boolean': false, + 'function': true, + 'object': true, + 'number': false, + 'string': false, + 'undefined': false + }; + + /** Used to escape characters for inclusion in compiled string literals */ + var stringEscapes = { + '\\': '\\', + "'": "'", + '\n': 'n', + '\r': 'r', + '\t': 't', + '\u2028': 'u2028', + '\u2029': 'u2029' + }; + + /** Used as a reference to the global object */ + var root = (objectTypes[typeof window] && window) || this; + + /** Detect free variable `exports` */ + var freeExports = objectTypes[typeof exports] && exports && !exports.nodeType && exports; + + /** Detect free variable `module` */ + var freeModule = objectTypes[typeof module] && module && !module.nodeType && module; + + /** Detect the popular CommonJS extension `module.exports` */ + var moduleExports = freeModule && freeModule.exports === freeExports && freeExports; + + /** Detect free variable `global` from Node.js or Browserified code and use it as `root` */ + var freeGlobal = objectTypes[typeof global] && global; + if (freeGlobal && (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal)) { + root = freeGlobal; + } + + /*--------------------------------------------------------------------------*/ + + /** + * The base implementation of `_.indexOf` without support for binary searches + * or `fromIndex` constraints. + * + * @private + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {number} [fromIndex=0] The index to search from. + * @returns {number} Returns the index of the matched value or `-1`. + */ + function baseIndexOf(array, value, fromIndex) { + var index = (fromIndex || 0) - 1, + length = array ? array.length : 0; + + while (++index < length) { + if (array[index] === value) { + return index; + } + } + return -1; + } + + /** + * An implementation of `_.contains` for cache objects that mimics the return + * signature of `_.indexOf` by returning `0` if the value is found, else `-1`. + * + * @private + * @param {Object} cache The cache object to inspect. + * @param {*} value The value to search for. + * @returns {number} Returns `0` if `value` is found, else `-1`. + */ + function cacheIndexOf(cache, value) { + var type = typeof value; + cache = cache.cache; + + if (type == 'boolean' || value == null) { + return cache[value] ? 0 : -1; + } + if (type != 'number' && type != 'string') { + type = 'object'; + } + var key = type == 'number' ? value : keyPrefix + value; + cache = (cache = cache[type]) && cache[key]; + + return type == 'object' + ? (cache && baseIndexOf(cache, value) > -1 ? 0 : -1) + : (cache ? 0 : -1); + } + + /** + * Adds a given value to the corresponding cache object. + * + * @private + * @param {*} value The value to add to the cache. + */ + function cachePush(value) { + var cache = this.cache, + type = typeof value; + + if (type == 'boolean' || value == null) { + cache[value] = true; + } else { + if (type != 'number' && type != 'string') { + type = 'object'; + } + var key = type == 'number' ? value : keyPrefix + value, + typeCache = cache[type] || (cache[type] = {}); + + if (type == 'object') { + (typeCache[key] || (typeCache[key] = [])).push(value); + } else { + typeCache[key] = true; + } + } + } + + /** + * Used by `_.max` and `_.min` as the default callback when a given + * collection is a string value. + * + * @private + * @param {string} value The character to inspect. + * @returns {number} Returns the code unit of given character. + */ + function charAtCallback(value) { + return value.charCodeAt(0); + } + + /** + * Used by `sortBy` to compare transformed `collection` elements, stable sorting + * them in ascending order. + * + * @private + * @param {Object} a The object to compare to `b`. + * @param {Object} b The object to compare to `a`. + * @returns {number} Returns the sort order indicator of `1` or `-1`. + */ + function compareAscending(a, b) { + var ac = a.criteria, + bc = b.criteria, + index = -1, + length = ac.length; + + while (++index < length) { + var value = ac[index], + other = bc[index]; + + if (value !== other) { + if (value > other || typeof value == 'undefined') { + return 1; + } + if (value < other || typeof other == 'undefined') { + return -1; + } + } + } + // Fixes an `Array#sort` bug in the JS engine embedded in Adobe applications + // that causes it, under certain circumstances, to return the same value for + // `a` and `b`. See https://github.com/jashkenas/underscore/pull/1247 + // + // This also ensures a stable sort in V8 and other engines. + // See http://code.google.com/p/v8/issues/detail?id=90 + return a.index - b.index; + } + + /** + * Creates a cache object to optimize linear searches of large arrays. + * + * @private + * @param {Array} [array=[]] The array to search. + * @returns {null|Object} Returns the cache object or `null` if caching should not be used. + */ + function createCache(array) { + var index = -1, + length = array.length, + first = array[0], + mid = array[(length / 2) | 0], + last = array[length - 1]; + + if (first && typeof first == 'object' && + mid && typeof mid == 'object' && last && typeof last == 'object') { + return false; + } + var cache = getObject(); + cache['false'] = cache['null'] = cache['true'] = cache['undefined'] = false; + + var result = getObject(); + result.array = array; + result.cache = cache; + result.push = cachePush; + + while (++index < length) { + result.push(array[index]); + } + return result; + } + + /** + * Used by `template` to escape characters for inclusion in compiled + * string literals. + * + * @private + * @param {string} match The matched character to escape. + * @returns {string} Returns the escaped character. + */ + function escapeStringChar(match) { + return '\\' + stringEscapes[match]; + } + + /** + * Gets an array from the array pool or creates a new one if the pool is empty. + * + * @private + * @returns {Array} The array from the pool. + */ + function getArray() { + return arrayPool.pop() || []; + } + + /** + * Gets an object from the object pool or creates a new one if the pool is empty. + * + * @private + * @returns {Object} The object from the pool. + */ + function getObject() { + return objectPool.pop() || { + 'array': null, + 'cache': null, + 'criteria': null, + 'false': false, + 'index': 0, + 'null': false, + 'number': null, + 'object': null, + 'push': null, + 'string': null, + 'true': false, + 'undefined': false, + 'value': null + }; + } + + /** + * Releases the given array back to the array pool. + * + * @private + * @param {Array} [array] The array to release. + */ + function releaseArray(array) { + array.length = 0; + if (arrayPool.length < maxPoolSize) { + arrayPool.push(array); + } + } + + /** + * Releases the given object back to the object pool. + * + * @private + * @param {Object} [object] The object to release. + */ + function releaseObject(object) { + var cache = object.cache; + if (cache) { + releaseObject(cache); + } + object.array = object.cache = object.criteria = object.object = object.number = object.string = object.value = null; + if (objectPool.length < maxPoolSize) { + objectPool.push(object); + } + } + + /** + * Slices the `collection` from the `start` index up to, but not including, + * the `end` index. + * + * Note: This function is used instead of `Array#slice` to support node lists + * in IE < 9 and to ensure dense arrays are returned. + * + * @private + * @param {Array|Object|string} collection The collection to slice. + * @param {number} start The start index. + * @param {number} end The end index. + * @returns {Array} Returns the new array. + */ + function slice(array, start, end) { + start || (start = 0); + if (typeof end == 'undefined') { + end = array ? array.length : 0; + } + var index = -1, + length = end - start || 0, + result = Array(length < 0 ? 0 : length); + + while (++index < length) { + result[index] = array[start + index]; + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Create a new `lodash` function using the given context object. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Object} [context=root] The context object. + * @returns {Function} Returns the `lodash` function. + */ + function runInContext(context) { + // Avoid issues with some ES3 environments that attempt to use values, named + // after built-in constructors like `Object`, for the creation of literals. + // ES5 clears this up by stating that literals must use built-in constructors. + // See http://es5.github.io/#x11.1.5. + context = context ? _.defaults(root.Object(), context, _.pick(root, contextProps)) : root; + + /** Native constructor references */ + var Array = context.Array, + Boolean = context.Boolean, + Date = context.Date, + Function = context.Function, + Math = context.Math, + Number = context.Number, + Object = context.Object, + RegExp = context.RegExp, + String = context.String, + TypeError = context.TypeError; + + /** + * Used for `Array` method references. + * + * Normally `Array.prototype` would suffice, however, using an array literal + * avoids issues in Narwhal. + */ + var arrayRef = []; + + /** Used for native method references */ + var objectProto = Object.prototype; + + /** Used to restore the original `_` reference in `noConflict` */ + var oldDash = context._; + + /** Used to resolve the internal [[Class]] of values */ + var toString = objectProto.toString; + + /** Used to detect if a method is native */ + var reNative = RegExp('^' + + String(toString) + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/toString| for [^\]]+/g, '.*?') + '$' + ); + + /** Native method shortcuts */ + var ceil = Math.ceil, + clearTimeout = context.clearTimeout, + floor = Math.floor, + fnToString = Function.prototype.toString, + getPrototypeOf = isNative(getPrototypeOf = Object.getPrototypeOf) && getPrototypeOf, + hasOwnProperty = objectProto.hasOwnProperty, + push = arrayRef.push, + setTimeout = context.setTimeout, + splice = arrayRef.splice, + unshift = arrayRef.unshift; + + /** Used to set meta data on functions */ + var defineProperty = (function() { + // IE 8 only accepts DOM elements + try { + var o = {}, + func = isNative(func = Object.defineProperty) && func, + result = func(o, o, o) && func; + } catch(e) { } + return result; + }()); + + /* Native method shortcuts for methods with the same name as other `lodash` methods */ + var nativeCreate = isNative(nativeCreate = Object.create) && nativeCreate, + nativeIsArray = isNative(nativeIsArray = Array.isArray) && nativeIsArray, + nativeIsFinite = context.isFinite, + nativeIsNaN = context.isNaN, + nativeKeys = isNative(nativeKeys = Object.keys) && nativeKeys, + nativeMax = Math.max, + nativeMin = Math.min, + nativeParseInt = context.parseInt, + nativeRandom = Math.random; + + /** Used to lookup a built-in constructor by [[Class]] */ + var ctorByClass = {}; + ctorByClass[arrayClass] = Array; + ctorByClass[boolClass] = Boolean; + ctorByClass[dateClass] = Date; + ctorByClass[funcClass] = Function; + ctorByClass[objectClass] = Object; + ctorByClass[numberClass] = Number; + ctorByClass[regexpClass] = RegExp; + ctorByClass[stringClass] = String; + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a `lodash` object which wraps the given value to enable intuitive + * method chaining. + * + * In addition to Lo-Dash methods, wrappers also have the following `Array` methods: + * `concat`, `join`, `pop`, `push`, `reverse`, `shift`, `slice`, `sort`, `splice`, + * and `unshift` + * + * Chaining is supported in custom builds as long as the `value` method is + * implicitly or explicitly included in the build. + * + * The chainable wrapper functions are: + * `after`, `assign`, `bind`, `bindAll`, `bindKey`, `chain`, `compact`, + * `compose`, `concat`, `countBy`, `create`, `createCallback`, `curry`, + * `debounce`, `defaults`, `defer`, `delay`, `difference`, `filter`, `flatten`, + * `forEach`, `forEachRight`, `forIn`, `forInRight`, `forOwn`, `forOwnRight`, + * `functions`, `groupBy`, `indexBy`, `initial`, `intersection`, `invert`, + * `invoke`, `keys`, `map`, `max`, `memoize`, `merge`, `min`, `object`, `omit`, + * `once`, `pairs`, `partial`, `partialRight`, `pick`, `pluck`, `pull`, `push`, + * `range`, `reject`, `remove`, `rest`, `reverse`, `shuffle`, `slice`, `sort`, + * `sortBy`, `splice`, `tap`, `throttle`, `times`, `toArray`, `transform`, + * `union`, `uniq`, `unshift`, `unzip`, `values`, `where`, `without`, `wrap`, + * and `zip` + * + * The non-chainable wrapper functions are: + * `clone`, `cloneDeep`, `contains`, `escape`, `every`, `find`, `findIndex`, + * `findKey`, `findLast`, `findLastIndex`, `findLastKey`, `has`, `identity`, + * `indexOf`, `isArguments`, `isArray`, `isBoolean`, `isDate`, `isElement`, + * `isEmpty`, `isEqual`, `isFinite`, `isFunction`, `isNaN`, `isNull`, `isNumber`, + * `isObject`, `isPlainObject`, `isRegExp`, `isString`, `isUndefined`, `join`, + * `lastIndexOf`, `mixin`, `noConflict`, `parseInt`, `pop`, `random`, `reduce`, + * `reduceRight`, `result`, `shift`, `size`, `some`, `sortedIndex`, `runInContext`, + * `template`, `unescape`, `uniqueId`, and `value` + * + * The wrapper functions `first` and `last` return wrapped values when `n` is + * provided, otherwise they return unwrapped values. + * + * Explicit chaining can be enabled by using the `_.chain` method. + * + * @name _ + * @constructor + * @category Chaining + * @param {*} value The value to wrap in a `lodash` instance. + * @returns {Object} Returns a `lodash` instance. + * @example + * + * var wrapped = _([1, 2, 3]); + * + * // returns an unwrapped value + * wrapped.reduce(function(sum, num) { + * return sum + num; + * }); + * // => 6 + * + * // returns a wrapped value + * var squares = wrapped.map(function(num) { + * return num * num; + * }); + * + * _.isArray(squares); + * // => false + * + * _.isArray(squares.value()); + * // => true + */ + function lodash(value) { + // don't wrap if already wrapped, even if wrapped by a different `lodash` constructor + return (value && typeof value == 'object' && !isArray(value) && hasOwnProperty.call(value, '__wrapped__')) + ? value + : new lodashWrapper(value); + } + + /** + * A fast path for creating `lodash` wrapper objects. + * + * @private + * @param {*} value The value to wrap in a `lodash` instance. + * @param {boolean} chainAll A flag to enable chaining for all methods + * @returns {Object} Returns a `lodash` instance. + */ + function lodashWrapper(value, chainAll) { + this.__chain__ = !!chainAll; + this.__wrapped__ = value; + } + // ensure `new lodashWrapper` is an instance of `lodash` + lodashWrapper.prototype = lodash.prototype; + + /** + * An object used to flag environments features. + * + * @static + * @memberOf _ + * @type Object + */ + var support = lodash.support = {}; + + /** + * Detect if functions can be decompiled by `Function#toString` + * (all but PS3 and older Opera mobile browsers & avoided in Windows 8 apps). + * + * @memberOf _.support + * @type boolean + */ + support.funcDecomp = !isNative(context.WinRTError) && reThis.test(runInContext); + + /** + * Detect if `Function#name` is supported (all but IE). + * + * @memberOf _.support + * @type boolean + */ + support.funcNames = typeof Function.name == 'string'; + + /** + * By default, the template delimiters used by Lo-Dash are similar to those in + * embedded Ruby (ERB). Change the following template settings to use alternative + * delimiters. + * + * @static + * @memberOf _ + * @type Object + */ + lodash.templateSettings = { + + /** + * Used to detect `data` property values to be HTML-escaped. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'escape': /<%-([\s\S]+?)%>/g, + + /** + * Used to detect code to be evaluated. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'evaluate': /<%([\s\S]+?)%>/g, + + /** + * Used to detect `data` property values to inject. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'interpolate': reInterpolate, + + /** + * Used to reference the data object in the template text. + * + * @memberOf _.templateSettings + * @type string + */ + 'variable': '', + + /** + * Used to import variables into the compiled template. + * + * @memberOf _.templateSettings + * @type Object + */ + 'imports': { + + /** + * A reference to the `lodash` function. + * + * @memberOf _.templateSettings.imports + * @type Function + */ + '_': lodash + } + }; + + /*--------------------------------------------------------------------------*/ + + /** + * The base implementation of `_.bind` that creates the bound function and + * sets its meta data. + * + * @private + * @param {Array} bindData The bind data array. + * @returns {Function} Returns the new bound function. + */ + function baseBind(bindData) { + var func = bindData[0], + partialArgs = bindData[2], + thisArg = bindData[4]; + + function bound() { + // `Function#bind` spec + // http://es5.github.io/#x15.3.4.5 + if (partialArgs) { + // avoid `arguments` object deoptimizations by using `slice` instead + // of `Array.prototype.slice.call` and not assigning `arguments` to a + // variable as a ternary expression + var args = slice(partialArgs); + push.apply(args, arguments); + } + // mimic the constructor's `return` behavior + // http://es5.github.io/#x13.2.2 + if (this instanceof bound) { + // ensure `new bound` is an instance of `func` + var thisBinding = baseCreate(func.prototype), + result = func.apply(thisBinding, args || arguments); + return isObject(result) ? result : thisBinding; + } + return func.apply(thisArg, args || arguments); + } + setBindData(bound, bindData); + return bound; + } + + /** + * The base implementation of `_.clone` without argument juggling or support + * for `thisArg` binding. + * + * @private + * @param {*} value The value to clone. + * @param {boolean} [isDeep=false] Specify a deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {Array} [stackA=[]] Tracks traversed source objects. + * @param {Array} [stackB=[]] Associates clones with source counterparts. + * @returns {*} Returns the cloned value. + */ + function baseClone(value, isDeep, callback, stackA, stackB) { + if (callback) { + var result = callback(value); + if (typeof result != 'undefined') { + return result; + } + } + // inspect [[Class]] + var isObj = isObject(value); + if (isObj) { + var className = toString.call(value); + if (!cloneableClasses[className]) { + return value; + } + var ctor = ctorByClass[className]; + switch (className) { + case boolClass: + case dateClass: + return new ctor(+value); + + case numberClass: + case stringClass: + return new ctor(value); + + case regexpClass: + result = ctor(value.source, reFlags.exec(value)); + result.lastIndex = value.lastIndex; + return result; + } + } else { + return value; + } + var isArr = isArray(value); + if (isDeep) { + // check for circular references and return corresponding clone + var initedStack = !stackA; + stackA || (stackA = getArray()); + stackB || (stackB = getArray()); + + var length = stackA.length; + while (length--) { + if (stackA[length] == value) { + return stackB[length]; + } + } + result = isArr ? ctor(value.length) : {}; + } + else { + result = isArr ? slice(value) : assign({}, value); + } + // add array properties assigned by `RegExp#exec` + if (isArr) { + if (hasOwnProperty.call(value, 'index')) { + result.index = value.index; + } + if (hasOwnProperty.call(value, 'input')) { + result.input = value.input; + } + } + // exit for shallow clone + if (!isDeep) { + return result; + } + // add the source value to the stack of traversed objects + // and associate it with its clone + stackA.push(value); + stackB.push(result); + + // recursively populate clone (susceptible to call stack limits) + (isArr ? forEach : forOwn)(value, function(objValue, key) { + result[key] = baseClone(objValue, isDeep, callback, stackA, stackB); + }); + + if (initedStack) { + releaseArray(stackA); + releaseArray(stackB); + } + return result; + } + + /** + * The base implementation of `_.create` without support for assigning + * properties to the created object. + * + * @private + * @param {Object} prototype The object to inherit from. + * @returns {Object} Returns the new object. + */ + function baseCreate(prototype, properties) { + return isObject(prototype) ? nativeCreate(prototype) : {}; + } + // fallback for browsers without `Object.create` + if (!nativeCreate) { + baseCreate = (function() { + function Object() {} + return function(prototype) { + if (isObject(prototype)) { + Object.prototype = prototype; + var result = new Object; + Object.prototype = null; + } + return result || context.Object(); + }; + }()); + } + + /** + * The base implementation of `_.createCallback` without support for creating + * "_.pluck" or "_.where" style callbacks. + * + * @private + * @param {*} [func=identity] The value to convert to a callback. + * @param {*} [thisArg] The `this` binding of the created callback. + * @param {number} [argCount] The number of arguments the callback accepts. + * @returns {Function} Returns a callback function. + */ + function baseCreateCallback(func, thisArg, argCount) { + if (typeof func != 'function') { + return identity; + } + // exit early for no `thisArg` or already bound by `Function#bind` + if (typeof thisArg == 'undefined' || !('prototype' in func)) { + return func; + } + var bindData = func.__bindData__; + if (typeof bindData == 'undefined') { + if (support.funcNames) { + bindData = !func.name; + } + bindData = bindData || !support.funcDecomp; + if (!bindData) { + var source = fnToString.call(func); + if (!support.funcNames) { + bindData = !reFuncName.test(source); + } + if (!bindData) { + // checks if `func` references the `this` keyword and stores the result + bindData = reThis.test(source); + setBindData(func, bindData); + } + } + } + // exit early if there are no `this` references or `func` is bound + if (bindData === false || (bindData !== true && bindData[1] & 1)) { + return func; + } + switch (argCount) { + case 1: return function(value) { + return func.call(thisArg, value); + }; + case 2: return function(a, b) { + return func.call(thisArg, a, b); + }; + case 3: return function(value, index, collection) { + return func.call(thisArg, value, index, collection); + }; + case 4: return function(accumulator, value, index, collection) { + return func.call(thisArg, accumulator, value, index, collection); + }; + } + return bind(func, thisArg); + } + + /** + * The base implementation of `createWrapper` that creates the wrapper and + * sets its meta data. + * + * @private + * @param {Array} bindData The bind data array. + * @returns {Function} Returns the new function. + */ + function baseCreateWrapper(bindData) { + var func = bindData[0], + bitmask = bindData[1], + partialArgs = bindData[2], + partialRightArgs = bindData[3], + thisArg = bindData[4], + arity = bindData[5]; + + var isBind = bitmask & 1, + isBindKey = bitmask & 2, + isCurry = bitmask & 4, + isCurryBound = bitmask & 8, + key = func; + + function bound() { + var thisBinding = isBind ? thisArg : this; + if (partialArgs) { + var args = slice(partialArgs); + push.apply(args, arguments); + } + if (partialRightArgs || isCurry) { + args || (args = slice(arguments)); + if (partialRightArgs) { + push.apply(args, partialRightArgs); + } + if (isCurry && args.length < arity) { + bitmask |= 16 & ~32; + return baseCreateWrapper([func, (isCurryBound ? bitmask : bitmask & ~3), args, null, thisArg, arity]); + } + } + args || (args = arguments); + if (isBindKey) { + func = thisBinding[key]; + } + if (this instanceof bound) { + thisBinding = baseCreate(func.prototype); + var result = func.apply(thisBinding, args); + return isObject(result) ? result : thisBinding; + } + return func.apply(thisBinding, args); + } + setBindData(bound, bindData); + return bound; + } + + /** + * The base implementation of `_.difference` that accepts a single array + * of values to exclude. + * + * @private + * @param {Array} array The array to process. + * @param {Array} [values] The array of values to exclude. + * @returns {Array} Returns a new array of filtered values. + */ + function baseDifference(array, values) { + var index = -1, + indexOf = getIndexOf(), + length = array ? array.length : 0, + isLarge = length >= largeArraySize && indexOf === baseIndexOf, + result = []; + + if (isLarge) { + var cache = createCache(values); + if (cache) { + indexOf = cacheIndexOf; + values = cache; + } else { + isLarge = false; + } + } + while (++index < length) { + var value = array[index]; + if (indexOf(values, value) < 0) { + result.push(value); + } + } + if (isLarge) { + releaseObject(values); + } + return result; + } + + /** + * The base implementation of `_.flatten` without support for callback + * shorthands or `thisArg` binding. + * + * @private + * @param {Array} array The array to flatten. + * @param {boolean} [isShallow=false] A flag to restrict flattening to a single level. + * @param {boolean} [isStrict=false] A flag to restrict flattening to arrays and `arguments` objects. + * @param {number} [fromIndex=0] The index to start from. + * @returns {Array} Returns a new flattened array. + */ + function baseFlatten(array, isShallow, isStrict, fromIndex) { + var index = (fromIndex || 0) - 1, + length = array ? array.length : 0, + result = []; + + while (++index < length) { + var value = array[index]; + + if (value && typeof value == 'object' && typeof value.length == 'number' + && (isArray(value) || isArguments(value))) { + // recursively flatten arrays (susceptible to call stack limits) + if (!isShallow) { + value = baseFlatten(value, isShallow, isStrict); + } + var valIndex = -1, + valLength = value.length, + resIndex = result.length; + + result.length += valLength; + while (++valIndex < valLength) { + result[resIndex++] = value[valIndex]; + } + } else if (!isStrict) { + result.push(value); + } + } + return result; + } + + /** + * The base implementation of `_.isEqual`, without support for `thisArg` binding, + * that allows partial "_.where" style comparisons. + * + * @private + * @param {*} a The value to compare. + * @param {*} b The other value to compare. + * @param {Function} [callback] The function to customize comparing values. + * @param {Function} [isWhere=false] A flag to indicate performing partial comparisons. + * @param {Array} [stackA=[]] Tracks traversed `a` objects. + * @param {Array} [stackB=[]] Tracks traversed `b` objects. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + */ + function baseIsEqual(a, b, callback, isWhere, stackA, stackB) { + // used to indicate that when comparing objects, `a` has at least the properties of `b` + if (callback) { + var result = callback(a, b); + if (typeof result != 'undefined') { + return !!result; + } + } + // exit early for identical values + if (a === b) { + // treat `+0` vs. `-0` as not equal + return a !== 0 || (1 / a == 1 / b); + } + var type = typeof a, + otherType = typeof b; + + // exit early for unlike primitive values + if (a === a && + !(a && objectTypes[type]) && + !(b && objectTypes[otherType])) { + return false; + } + // exit early for `null` and `undefined` avoiding ES3's Function#call behavior + // http://es5.github.io/#x15.3.4.4 + if (a == null || b == null) { + return a === b; + } + // compare [[Class]] names + var className = toString.call(a), + otherClass = toString.call(b); + + if (className == argsClass) { + className = objectClass; + } + if (otherClass == argsClass) { + otherClass = objectClass; + } + if (className != otherClass) { + return false; + } + switch (className) { + case boolClass: + case dateClass: + // coerce dates and booleans to numbers, dates to milliseconds and booleans + // to `1` or `0` treating invalid dates coerced to `NaN` as not equal + return +a == +b; + + case numberClass: + // treat `NaN` vs. `NaN` as equal + return (a != +a) + ? b != +b + // but treat `+0` vs. `-0` as not equal + : (a == 0 ? (1 / a == 1 / b) : a == +b); + + case regexpClass: + case stringClass: + // coerce regexes to strings (http://es5.github.io/#x15.10.6.4) + // treat string primitives and their corresponding object instances as equal + return a == String(b); + } + var isArr = className == arrayClass; + if (!isArr) { + // unwrap any `lodash` wrapped values + var aWrapped = hasOwnProperty.call(a, '__wrapped__'), + bWrapped = hasOwnProperty.call(b, '__wrapped__'); + + if (aWrapped || bWrapped) { + return baseIsEqual(aWrapped ? a.__wrapped__ : a, bWrapped ? b.__wrapped__ : b, callback, isWhere, stackA, stackB); + } + // exit for functions and DOM nodes + if (className != objectClass) { + return false; + } + // in older versions of Opera, `arguments` objects have `Array` constructors + var ctorA = a.constructor, + ctorB = b.constructor; + + // non `Object` object instances with different constructors are not equal + if (ctorA != ctorB && + !(isFunction(ctorA) && ctorA instanceof ctorA && isFunction(ctorB) && ctorB instanceof ctorB) && + ('constructor' in a && 'constructor' in b) + ) { + return false; + } + } + // assume cyclic structures are equal + // the algorithm for detecting cyclic structures is adapted from ES 5.1 + // section 15.12.3, abstract operation `JO` (http://es5.github.io/#x15.12.3) + var initedStack = !stackA; + stackA || (stackA = getArray()); + stackB || (stackB = getArray()); + + var length = stackA.length; + while (length--) { + if (stackA[length] == a) { + return stackB[length] == b; + } + } + var size = 0; + result = true; + + // add `a` and `b` to the stack of traversed objects + stackA.push(a); + stackB.push(b); + + // recursively compare objects and arrays (susceptible to call stack limits) + if (isArr) { + // compare lengths to determine if a deep comparison is necessary + length = a.length; + size = b.length; + result = size == length; + + if (result || isWhere) { + // deep compare the contents, ignoring non-numeric properties + while (size--) { + var index = length, + value = b[size]; + + if (isWhere) { + while (index--) { + if ((result = baseIsEqual(a[index], value, callback, isWhere, stackA, stackB))) { + break; + } + } + } else if (!(result = baseIsEqual(a[size], value, callback, isWhere, stackA, stackB))) { + break; + } + } + } + } + else { + // deep compare objects using `forIn`, instead of `forOwn`, to avoid `Object.keys` + // which, in this case, is more costly + forIn(b, function(value, key, b) { + if (hasOwnProperty.call(b, key)) { + // count the number of properties. + size++; + // deep compare each property value. + return (result = hasOwnProperty.call(a, key) && baseIsEqual(a[key], value, callback, isWhere, stackA, stackB)); + } + }); + + if (result && !isWhere) { + // ensure both objects have the same number of properties + forIn(a, function(value, key, a) { + if (hasOwnProperty.call(a, key)) { + // `size` will be `-1` if `a` has more properties than `b` + return (result = --size > -1); + } + }); + } + } + stackA.pop(); + stackB.pop(); + + if (initedStack) { + releaseArray(stackA); + releaseArray(stackB); + } + return result; + } + + /** + * The base implementation of `_.merge` without argument juggling or support + * for `thisArg` binding. + * + * @private + * @param {Object} object The destination object. + * @param {Object} source The source object. + * @param {Function} [callback] The function to customize merging properties. + * @param {Array} [stackA=[]] Tracks traversed source objects. + * @param {Array} [stackB=[]] Associates values with source counterparts. + */ + function baseMerge(object, source, callback, stackA, stackB) { + (isArray(source) ? forEach : forOwn)(source, function(source, key) { + var found, + isArr, + result = source, + value = object[key]; + + if (source && ((isArr = isArray(source)) || isPlainObject(source))) { + // avoid merging previously merged cyclic sources + var stackLength = stackA.length; + while (stackLength--) { + if ((found = stackA[stackLength] == source)) { + value = stackB[stackLength]; + break; + } + } + if (!found) { + var isShallow; + if (callback) { + result = callback(value, source); + if ((isShallow = typeof result != 'undefined')) { + value = result; + } + } + if (!isShallow) { + value = isArr + ? (isArray(value) ? value : []) + : (isPlainObject(value) ? value : {}); + } + // add `source` and associated `value` to the stack of traversed objects + stackA.push(source); + stackB.push(value); + + // recursively merge objects and arrays (susceptible to call stack limits) + if (!isShallow) { + baseMerge(value, source, callback, stackA, stackB); + } + } + } + else { + if (callback) { + result = callback(value, source); + if (typeof result == 'undefined') { + result = source; + } + } + if (typeof result != 'undefined') { + value = result; + } + } + object[key] = value; + }); + } + + /** + * The base implementation of `_.random` without argument juggling or support + * for returning floating-point numbers. + * + * @private + * @param {number} min The minimum possible value. + * @param {number} max The maximum possible value. + * @returns {number} Returns a random number. + */ + function baseRandom(min, max) { + return min + floor(nativeRandom() * (max - min + 1)); + } + + /** + * The base implementation of `_.uniq` without support for callback shorthands + * or `thisArg` binding. + * + * @private + * @param {Array} array The array to process. + * @param {boolean} [isSorted=false] A flag to indicate that `array` is sorted. + * @param {Function} [callback] The function called per iteration. + * @returns {Array} Returns a duplicate-value-free array. + */ + function baseUniq(array, isSorted, callback) { + var index = -1, + indexOf = getIndexOf(), + length = array ? array.length : 0, + result = []; + + var isLarge = !isSorted && length >= largeArraySize && indexOf === baseIndexOf, + seen = (callback || isLarge) ? getArray() : result; + + if (isLarge) { + var cache = createCache(seen); + indexOf = cacheIndexOf; + seen = cache; + } + while (++index < length) { + var value = array[index], + computed = callback ? callback(value, index, array) : value; + + if (isSorted + ? !index || seen[seen.length - 1] !== computed + : indexOf(seen, computed) < 0 + ) { + if (callback || isLarge) { + seen.push(computed); + } + result.push(value); + } + } + if (isLarge) { + releaseArray(seen.array); + releaseObject(seen); + } else if (callback) { + releaseArray(seen); + } + return result; + } + + /** + * Creates a function that aggregates a collection, creating an object composed + * of keys generated from the results of running each element of the collection + * through a callback. The given `setter` function sets the keys and values + * of the composed object. + * + * @private + * @param {Function} setter The setter function. + * @returns {Function} Returns the new aggregator function. + */ + function createAggregator(setter) { + return function(collection, callback, thisArg) { + var result = {}; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + setter(result, value, callback(value, index, collection), collection); + } + } else { + forOwn(collection, function(value, key, collection) { + setter(result, value, callback(value, key, collection), collection); + }); + } + return result; + }; + } + + /** + * Creates a function that, when called, either curries or invokes `func` + * with an optional `this` binding and partially applied arguments. + * + * @private + * @param {Function|string} func The function or method name to reference. + * @param {number} bitmask The bitmask of method flags to compose. + * The bitmask may be composed of the following flags: + * 1 - `_.bind` + * 2 - `_.bindKey` + * 4 - `_.curry` + * 8 - `_.curry` (bound) + * 16 - `_.partial` + * 32 - `_.partialRight` + * @param {Array} [partialArgs] An array of arguments to prepend to those + * provided to the new function. + * @param {Array} [partialRightArgs] An array of arguments to append to those + * provided to the new function. + * @param {*} [thisArg] The `this` binding of `func`. + * @param {number} [arity] The arity of `func`. + * @returns {Function} Returns the new function. + */ + function createWrapper(func, bitmask, partialArgs, partialRightArgs, thisArg, arity) { + var isBind = bitmask & 1, + isBindKey = bitmask & 2, + isCurry = bitmask & 4, + isCurryBound = bitmask & 8, + isPartial = bitmask & 16, + isPartialRight = bitmask & 32; + + if (!isBindKey && !isFunction(func)) { + throw new TypeError; + } + if (isPartial && !partialArgs.length) { + bitmask &= ~16; + isPartial = partialArgs = false; + } + if (isPartialRight && !partialRightArgs.length) { + bitmask &= ~32; + isPartialRight = partialRightArgs = false; + } + var bindData = func && func.__bindData__; + if (bindData && bindData !== true) { + // clone `bindData` + bindData = slice(bindData); + if (bindData[2]) { + bindData[2] = slice(bindData[2]); + } + if (bindData[3]) { + bindData[3] = slice(bindData[3]); + } + // set `thisBinding` is not previously bound + if (isBind && !(bindData[1] & 1)) { + bindData[4] = thisArg; + } + // set if previously bound but not currently (subsequent curried functions) + if (!isBind && bindData[1] & 1) { + bitmask |= 8; + } + // set curried arity if not yet set + if (isCurry && !(bindData[1] & 4)) { + bindData[5] = arity; + } + // append partial left arguments + if (isPartial) { + push.apply(bindData[2] || (bindData[2] = []), partialArgs); + } + // append partial right arguments + if (isPartialRight) { + unshift.apply(bindData[3] || (bindData[3] = []), partialRightArgs); + } + // merge flags + bindData[1] |= bitmask; + return createWrapper.apply(null, bindData); + } + // fast path for `_.bind` + var creater = (bitmask == 1 || bitmask === 17) ? baseBind : baseCreateWrapper; + return creater([func, bitmask, partialArgs, partialRightArgs, thisArg, arity]); + } + + /** + * Used by `escape` to convert characters to HTML entities. + * + * @private + * @param {string} match The matched character to escape. + * @returns {string} Returns the escaped character. + */ + function escapeHtmlChar(match) { + return htmlEscapes[match]; + } + + /** + * Gets the appropriate "indexOf" function. If the `_.indexOf` method is + * customized, this method returns the custom method, otherwise it returns + * the `baseIndexOf` function. + * + * @private + * @returns {Function} Returns the "indexOf" function. + */ + function getIndexOf() { + var result = (result = lodash.indexOf) === indexOf ? baseIndexOf : result; + return result; + } + + /** + * Checks if `value` is a native function. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a native function, else `false`. + */ + function isNative(value) { + return typeof value == 'function' && reNative.test(value); + } + + /** + * Sets `this` binding data on a given function. + * + * @private + * @param {Function} func The function to set data on. + * @param {Array} value The data array to set. + */ + var setBindData = !defineProperty ? noop : function(func, value) { + descriptor.value = value; + defineProperty(func, '__bindData__', descriptor); + }; + + /** + * A fallback implementation of `isPlainObject` which checks if a given value + * is an object created by the `Object` constructor, assuming objects created + * by the `Object` constructor have no inherited enumerable properties and that + * there are no `Object.prototype` extensions. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + */ + function shimIsPlainObject(value) { + var ctor, + result; + + // avoid non Object objects, `arguments` objects, and DOM elements + if (!(value && toString.call(value) == objectClass) || + (ctor = value.constructor, isFunction(ctor) && !(ctor instanceof ctor))) { + return false; + } + // In most environments an object's own properties are iterated before + // its inherited properties. If the last iterated property is an object's + // own property then there are no inherited enumerable properties. + forIn(value, function(value, key) { + result = key; + }); + return typeof result == 'undefined' || hasOwnProperty.call(value, result); + } + + /** + * Used by `unescape` to convert HTML entities to characters. + * + * @private + * @param {string} match The matched character to unescape. + * @returns {string} Returns the unescaped character. + */ + function unescapeHtmlChar(match) { + return htmlUnescapes[match]; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Checks if `value` is an `arguments` object. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an `arguments` object, else `false`. + * @example + * + * (function() { return _.isArguments(arguments); })(1, 2, 3); + * // => true + * + * _.isArguments([1, 2, 3]); + * // => false + */ + function isArguments(value) { + return value && typeof value == 'object' && typeof value.length == 'number' && + toString.call(value) == argsClass || false; + } + + /** + * Checks if `value` is an array. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an array, else `false`. + * @example + * + * (function() { return _.isArray(arguments); })(); + * // => false + * + * _.isArray([1, 2, 3]); + * // => true + */ + var isArray = nativeIsArray || function(value) { + return value && typeof value == 'object' && typeof value.length == 'number' && + toString.call(value) == arrayClass || false; + }; + + /** + * A fallback implementation of `Object.keys` which produces an array of the + * given object's own enumerable property names. + * + * @private + * @type Function + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names. + */ + var shimKeys = function(object) { + var index, iterable = object, result = []; + if (!iterable) return result; + if (!(objectTypes[typeof object])) return result; + for (index in iterable) { + if (hasOwnProperty.call(iterable, index)) { + result.push(index); + } + } + return result + }; + + /** + * Creates an array composed of the own enumerable property names of an object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names. + * @example + * + * _.keys({ 'one': 1, 'two': 2, 'three': 3 }); + * // => ['one', 'two', 'three'] (property order is not guaranteed across environments) + */ + var keys = !nativeKeys ? shimKeys : function(object) { + if (!isObject(object)) { + return []; + } + return nativeKeys(object); + }; + + /** + * Used to convert characters to HTML entities: + * + * Though the `>` character is escaped for symmetry, characters like `>` and `/` + * don't require escaping in HTML and have no special meaning unless they're part + * of a tag or an unquoted attribute value. + * http://mathiasbynens.be/notes/ambiguous-ampersands (under "semi-related fun fact") + */ + var htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + /** Used to convert HTML entities to characters */ + var htmlUnescapes = invert(htmlEscapes); + + /** Used to match HTML entities and HTML characters */ + var reEscapedHtml = RegExp('(' + keys(htmlUnescapes).join('|') + ')', 'g'), + reUnescapedHtml = RegExp('[' + keys(htmlEscapes).join('') + ']', 'g'); + + /*--------------------------------------------------------------------------*/ + + /** + * Assigns own enumerable properties of source object(s) to the destination + * object. Subsequent sources will overwrite property assignments of previous + * sources. If a callback is provided it will be executed to produce the + * assigned values. The callback is bound to `thisArg` and invoked with two + * arguments; (objectValue, sourceValue). + * + * @static + * @memberOf _ + * @type Function + * @alias extend + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param {Function} [callback] The function to customize assigning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the destination object. + * @example + * + * _.assign({ 'name': 'fred' }, { 'employer': 'slate' }); + * // => { 'name': 'fred', 'employer': 'slate' } + * + * var defaults = _.partialRight(_.assign, function(a, b) { + * return typeof a == 'undefined' ? b : a; + * }); + * + * var object = { 'name': 'barney' }; + * defaults(object, { 'name': 'fred', 'employer': 'slate' }); + * // => { 'name': 'barney', 'employer': 'slate' } + */ + var assign = function(object, source, guard) { + var index, iterable = object, result = iterable; + if (!iterable) return result; + var args = arguments, + argsIndex = 0, + argsLength = typeof guard == 'number' ? 2 : args.length; + if (argsLength > 3 && typeof args[argsLength - 2] == 'function') { + var callback = baseCreateCallback(args[--argsLength - 1], args[argsLength--], 2); + } else if (argsLength > 2 && typeof args[argsLength - 1] == 'function') { + callback = args[--argsLength]; + } + while (++argsIndex < argsLength) { + iterable = args[argsIndex]; + if (iterable && objectTypes[typeof iterable]) { + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + result[index] = callback ? callback(result[index], iterable[index]) : iterable[index]; + } + } + } + return result + }; + + /** + * Creates a clone of `value`. If `isDeep` is `true` nested objects will also + * be cloned, otherwise they will be assigned by reference. If a callback + * is provided it will be executed to produce the cloned values. If the + * callback returns `undefined` cloning will be handled by the method instead. + * The callback is bound to `thisArg` and invoked with one argument; (value). + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to clone. + * @param {boolean} [isDeep=false] Specify a deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the cloned value. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * var shallow = _.clone(characters); + * shallow[0] === characters[0]; + * // => true + * + * var deep = _.clone(characters, true); + * deep[0] === characters[0]; + * // => false + * + * _.mixin({ + * 'clone': _.partialRight(_.clone, function(value) { + * return _.isElement(value) ? value.cloneNode(false) : undefined; + * }) + * }); + * + * var clone = _.clone(document.body); + * clone.childNodes.length; + * // => 0 + */ + function clone(value, isDeep, callback, thisArg) { + // allows working with "Collections" methods without using their `index` + // and `collection` arguments for `isDeep` and `callback` + if (typeof isDeep != 'boolean' && isDeep != null) { + thisArg = callback; + callback = isDeep; + isDeep = false; + } + return baseClone(value, isDeep, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 1)); + } + + /** + * Creates a deep clone of `value`. If a callback is provided it will be + * executed to produce the cloned values. If the callback returns `undefined` + * cloning will be handled by the method instead. The callback is bound to + * `thisArg` and invoked with one argument; (value). + * + * Note: This method is loosely based on the structured clone algorithm. Functions + * and DOM nodes are **not** cloned. The enumerable properties of `arguments` objects and + * objects created by constructors other than `Object` are cloned to plain `Object` objects. + * See http://www.w3.org/TR/html5/infrastructure.html#internal-structured-cloning-algorithm. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the deep cloned value. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * var deep = _.cloneDeep(characters); + * deep[0] === characters[0]; + * // => false + * + * var view = { + * 'label': 'docs', + * 'node': element + * }; + * + * var clone = _.cloneDeep(view, function(value) { + * return _.isElement(value) ? value.cloneNode(true) : undefined; + * }); + * + * clone.node == view.node; + * // => false + */ + function cloneDeep(value, callback, thisArg) { + return baseClone(value, true, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 1)); + } + + /** + * Creates an object that inherits from the given `prototype` object. If a + * `properties` object is provided its own enumerable properties are assigned + * to the created object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} prototype The object to inherit from. + * @param {Object} [properties] The properties to assign to the object. + * @returns {Object} Returns the new object. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * function Circle() { + * Shape.call(this); + * } + * + * Circle.prototype = _.create(Shape.prototype, { 'constructor': Circle }); + * + * var circle = new Circle; + * circle instanceof Circle; + * // => true + * + * circle instanceof Shape; + * // => true + */ + function create(prototype, properties) { + var result = baseCreate(prototype); + return properties ? assign(result, properties) : result; + } + + /** + * Assigns own enumerable properties of source object(s) to the destination + * object for all destination properties that resolve to `undefined`. Once a + * property is set, additional defaults of the same property will be ignored. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param- {Object} [guard] Allows working with `_.reduce` without using its + * `key` and `object` arguments as sources. + * @returns {Object} Returns the destination object. + * @example + * + * var object = { 'name': 'barney' }; + * _.defaults(object, { 'name': 'fred', 'employer': 'slate' }); + * // => { 'name': 'barney', 'employer': 'slate' } + */ + var defaults = function(object, source, guard) { + var index, iterable = object, result = iterable; + if (!iterable) return result; + var args = arguments, + argsIndex = 0, + argsLength = typeof guard == 'number' ? 2 : args.length; + while (++argsIndex < argsLength) { + iterable = args[argsIndex]; + if (iterable && objectTypes[typeof iterable]) { + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + if (typeof result[index] == 'undefined') result[index] = iterable[index]; + } + } + } + return result + }; + + /** + * This method is like `_.findIndex` except that it returns the key of the + * first element that passes the callback check, instead of the element itself. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to search. + * @param {Function|Object|string} [callback=identity] The function called per + * iteration. If a property name or object is provided it will be used to + * create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {string|undefined} Returns the key of the found element, else `undefined`. + * @example + * + * var characters = { + * 'barney': { 'age': 36, 'blocked': false }, + * 'fred': { 'age': 40, 'blocked': true }, + * 'pebbles': { 'age': 1, 'blocked': false } + * }; + * + * _.findKey(characters, function(chr) { + * return chr.age < 40; + * }); + * // => 'barney' (property order is not guaranteed across environments) + * + * // using "_.where" callback shorthand + * _.findKey(characters, { 'age': 1 }); + * // => 'pebbles' + * + * // using "_.pluck" callback shorthand + * _.findKey(characters, 'blocked'); + * // => 'fred' + */ + function findKey(object, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forOwn(object, function(value, key, object) { + if (callback(value, key, object)) { + result = key; + return false; + } + }); + return result; + } + + /** + * This method is like `_.findKey` except that it iterates over elements + * of a `collection` in the opposite order. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to search. + * @param {Function|Object|string} [callback=identity] The function called per + * iteration. If a property name or object is provided it will be used to + * create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {string|undefined} Returns the key of the found element, else `undefined`. + * @example + * + * var characters = { + * 'barney': { 'age': 36, 'blocked': true }, + * 'fred': { 'age': 40, 'blocked': false }, + * 'pebbles': { 'age': 1, 'blocked': true } + * }; + * + * _.findLastKey(characters, function(chr) { + * return chr.age < 40; + * }); + * // => returns `pebbles`, assuming `_.findKey` returns `barney` + * + * // using "_.where" callback shorthand + * _.findLastKey(characters, { 'age': 40 }); + * // => 'fred' + * + * // using "_.pluck" callback shorthand + * _.findLastKey(characters, 'blocked'); + * // => 'pebbles' + */ + function findLastKey(object, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forOwnRight(object, function(value, key, object) { + if (callback(value, key, object)) { + result = key; + return false; + } + }); + return result; + } + + /** + * Iterates over own and inherited enumerable properties of an object, + * executing the callback for each property. The callback is bound to `thisArg` + * and invoked with three arguments; (value, key, object). Callbacks may exit + * iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * Shape.prototype.move = function(x, y) { + * this.x += x; + * this.y += y; + * }; + * + * _.forIn(new Shape, function(value, key) { + * console.log(key); + * }); + * // => logs 'x', 'y', and 'move' (property order is not guaranteed across environments) + */ + var forIn = function(collection, callback, thisArg) { + var index, iterable = collection, result = iterable; + if (!iterable) return result; + if (!objectTypes[typeof iterable]) return result; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + for (index in iterable) { + if (callback(iterable[index], index, collection) === false) return result; + } + return result + }; + + /** + * This method is like `_.forIn` except that it iterates over elements + * of a `collection` in the opposite order. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * Shape.prototype.move = function(x, y) { + * this.x += x; + * this.y += y; + * }; + * + * _.forInRight(new Shape, function(value, key) { + * console.log(key); + * }); + * // => logs 'move', 'y', and 'x' assuming `_.forIn ` logs 'x', 'y', and 'move' + */ + function forInRight(object, callback, thisArg) { + var pairs = []; + + forIn(object, function(value, key) { + pairs.push(key, value); + }); + + var length = pairs.length; + callback = baseCreateCallback(callback, thisArg, 3); + while (length--) { + if (callback(pairs[length--], pairs[length], object) === false) { + break; + } + } + return object; + } + + /** + * Iterates over own enumerable properties of an object, executing the callback + * for each property. The callback is bound to `thisArg` and invoked with three + * arguments; (value, key, object). Callbacks may exit iteration early by + * explicitly returning `false`. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * _.forOwn({ '0': 'zero', '1': 'one', 'length': 2 }, function(num, key) { + * console.log(key); + * }); + * // => logs '0', '1', and 'length' (property order is not guaranteed across environments) + */ + var forOwn = function(collection, callback, thisArg) { + var index, iterable = collection, result = iterable; + if (!iterable) return result; + if (!objectTypes[typeof iterable]) return result; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + if (callback(iterable[index], index, collection) === false) return result; + } + return result + }; + + /** + * This method is like `_.forOwn` except that it iterates over elements + * of a `collection` in the opposite order. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * _.forOwnRight({ '0': 'zero', '1': 'one', 'length': 2 }, function(num, key) { + * console.log(key); + * }); + * // => logs 'length', '1', and '0' assuming `_.forOwn` logs '0', '1', and 'length' + */ + function forOwnRight(object, callback, thisArg) { + var props = keys(object), + length = props.length; + + callback = baseCreateCallback(callback, thisArg, 3); + while (length--) { + var key = props[length]; + if (callback(object[key], key, object) === false) { + break; + } + } + return object; + } + + /** + * Creates a sorted array of property names of all enumerable properties, + * own and inherited, of `object` that have function values. + * + * @static + * @memberOf _ + * @alias methods + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names that have function values. + * @example + * + * _.functions(_); + * // => ['all', 'any', 'bind', 'bindAll', 'clone', 'compact', 'compose', ...] + */ + function functions(object) { + var result = []; + forIn(object, function(value, key) { + if (isFunction(value)) { + result.push(key); + } + }); + return result.sort(); + } + + /** + * Checks if the specified property name exists as a direct property of `object`, + * instead of an inherited property. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @param {string} key The name of the property to check. + * @returns {boolean} Returns `true` if key is a direct property, else `false`. + * @example + * + * _.has({ 'a': 1, 'b': 2, 'c': 3 }, 'b'); + * // => true + */ + function has(object, key) { + return object ? hasOwnProperty.call(object, key) : false; + } + + /** + * Creates an object composed of the inverted keys and values of the given object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to invert. + * @returns {Object} Returns the created inverted object. + * @example + * + * _.invert({ 'first': 'fred', 'second': 'barney' }); + * // => { 'fred': 'first', 'barney': 'second' } + */ + function invert(object) { + var index = -1, + props = keys(object), + length = props.length, + result = {}; + + while (++index < length) { + var key = props[index]; + result[object[key]] = key; + } + return result; + } + + /** + * Checks if `value` is a boolean value. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a boolean value, else `false`. + * @example + * + * _.isBoolean(null); + * // => false + */ + function isBoolean(value) { + return value === true || value === false || + value && typeof value == 'object' && toString.call(value) == boolClass || false; + } + + /** + * Checks if `value` is a date. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a date, else `false`. + * @example + * + * _.isDate(new Date); + * // => true + */ + function isDate(value) { + return value && typeof value == 'object' && toString.call(value) == dateClass || false; + } + + /** + * Checks if `value` is a DOM element. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a DOM element, else `false`. + * @example + * + * _.isElement(document.body); + * // => true + */ + function isElement(value) { + return value && value.nodeType === 1 || false; + } + + /** + * Checks if `value` is empty. Arrays, strings, or `arguments` objects with a + * length of `0` and objects with no own enumerable properties are considered + * "empty". + * + * @static + * @memberOf _ + * @category Objects + * @param {Array|Object|string} value The value to inspect. + * @returns {boolean} Returns `true` if the `value` is empty, else `false`. + * @example + * + * _.isEmpty([1, 2, 3]); + * // => false + * + * _.isEmpty({}); + * // => true + * + * _.isEmpty(''); + * // => true + */ + function isEmpty(value) { + var result = true; + if (!value) { + return result; + } + var className = toString.call(value), + length = value.length; + + if ((className == arrayClass || className == stringClass || className == argsClass ) || + (className == objectClass && typeof length == 'number' && isFunction(value.splice))) { + return !length; + } + forOwn(value, function() { + return (result = false); + }); + return result; + } + + /** + * Performs a deep comparison between two values to determine if they are + * equivalent to each other. If a callback is provided it will be executed + * to compare values. If the callback returns `undefined` comparisons will + * be handled by the method instead. The callback is bound to `thisArg` and + * invoked with two arguments; (a, b). + * + * @static + * @memberOf _ + * @category Objects + * @param {*} a The value to compare. + * @param {*} b The other value to compare. + * @param {Function} [callback] The function to customize comparing values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + * @example + * + * var object = { 'name': 'fred' }; + * var copy = { 'name': 'fred' }; + * + * object == copy; + * // => false + * + * _.isEqual(object, copy); + * // => true + * + * var words = ['hello', 'goodbye']; + * var otherWords = ['hi', 'goodbye']; + * + * _.isEqual(words, otherWords, function(a, b) { + * var reGreet = /^(?:hello|hi)$/i, + * aGreet = _.isString(a) && reGreet.test(a), + * bGreet = _.isString(b) && reGreet.test(b); + * + * return (aGreet || bGreet) ? (aGreet == bGreet) : undefined; + * }); + * // => true + */ + function isEqual(a, b, callback, thisArg) { + return baseIsEqual(a, b, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 2)); + } + + /** + * Checks if `value` is, or can be coerced to, a finite number. + * + * Note: This is not the same as native `isFinite` which will return true for + * booleans and empty strings. See http://es5.github.io/#x15.1.2.5. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is finite, else `false`. + * @example + * + * _.isFinite(-101); + * // => true + * + * _.isFinite('10'); + * // => true + * + * _.isFinite(true); + * // => false + * + * _.isFinite(''); + * // => false + * + * _.isFinite(Infinity); + * // => false + */ + function isFinite(value) { + return nativeIsFinite(value) && !nativeIsNaN(parseFloat(value)); + } + + /** + * Checks if `value` is a function. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a function, else `false`. + * @example + * + * _.isFunction(_); + * // => true + */ + function isFunction(value) { + return typeof value == 'function'; + } + + /** + * Checks if `value` is the language type of Object. + * (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(1); + * // => false + */ + function isObject(value) { + // check if the value is the ECMAScript language type of Object + // http://es5.github.io/#x8 + // and avoid a V8 bug + // http://code.google.com/p/v8/issues/detail?id=2291 + return !!(value && objectTypes[typeof value]); + } + + /** + * Checks if `value` is `NaN`. + * + * Note: This is not the same as native `isNaN` which will return `true` for + * `undefined` and other non-numeric values. See http://es5.github.io/#x15.1.2.4. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `NaN`, else `false`. + * @example + * + * _.isNaN(NaN); + * // => true + * + * _.isNaN(new Number(NaN)); + * // => true + * + * isNaN(undefined); + * // => true + * + * _.isNaN(undefined); + * // => false + */ + function isNaN(value) { + // `NaN` as a primitive is the only value that is not equal to itself + // (perform the [[Class]] check first to avoid errors with some host objects in IE) + return isNumber(value) && value != +value; + } + + /** + * Checks if `value` is `null`. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `null`, else `false`. + * @example + * + * _.isNull(null); + * // => true + * + * _.isNull(undefined); + * // => false + */ + function isNull(value) { + return value === null; + } + + /** + * Checks if `value` is a number. + * + * Note: `NaN` is considered a number. See http://es5.github.io/#x8.5. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a number, else `false`. + * @example + * + * _.isNumber(8.4 * 5); + * // => true + */ + function isNumber(value) { + return typeof value == 'number' || + value && typeof value == 'object' && toString.call(value) == numberClass || false; + } + + /** + * Checks if `value` is an object created by the `Object` constructor. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * _.isPlainObject(new Shape); + * // => false + * + * _.isPlainObject([1, 2, 3]); + * // => false + * + * _.isPlainObject({ 'x': 0, 'y': 0 }); + * // => true + */ + var isPlainObject = !getPrototypeOf ? shimIsPlainObject : function(value) { + if (!(value && toString.call(value) == objectClass)) { + return false; + } + var valueOf = value.valueOf, + objProto = isNative(valueOf) && (objProto = getPrototypeOf(valueOf)) && getPrototypeOf(objProto); + + return objProto + ? (value == objProto || getPrototypeOf(value) == objProto) + : shimIsPlainObject(value); + }; + + /** + * Checks if `value` is a regular expression. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a regular expression, else `false`. + * @example + * + * _.isRegExp(/fred/); + * // => true + */ + function isRegExp(value) { + return value && typeof value == 'object' && toString.call(value) == regexpClass || false; + } + + /** + * Checks if `value` is a string. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a string, else `false`. + * @example + * + * _.isString('fred'); + * // => true + */ + function isString(value) { + return typeof value == 'string' || + value && typeof value == 'object' && toString.call(value) == stringClass || false; + } + + /** + * Checks if `value` is `undefined`. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `undefined`, else `false`. + * @example + * + * _.isUndefined(void 0); + * // => true + */ + function isUndefined(value) { + return typeof value == 'undefined'; + } + + /** + * Creates an object with the same keys as `object` and values generated by + * running each own enumerable property of `object` through the callback. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new object with values of the results of each `callback` execution. + * @example + * + * _.mapValues({ 'a': 1, 'b': 2, 'c': 3} , function(num) { return num * 3; }); + * // => { 'a': 3, 'b': 6, 'c': 9 } + * + * var characters = { + * 'fred': { 'name': 'fred', 'age': 40 }, + * 'pebbles': { 'name': 'pebbles', 'age': 1 } + * }; + * + * // using "_.pluck" callback shorthand + * _.mapValues(characters, 'age'); + * // => { 'fred': 40, 'pebbles': 1 } + */ + function mapValues(object, callback, thisArg) { + var result = {}; + callback = lodash.createCallback(callback, thisArg, 3); + + forOwn(object, function(value, key, object) { + result[key] = callback(value, key, object); + }); + return result; + } + + /** + * Recursively merges own enumerable properties of the source object(s), that + * don't resolve to `undefined` into the destination object. Subsequent sources + * will overwrite property assignments of previous sources. If a callback is + * provided it will be executed to produce the merged values of the destination + * and source properties. If the callback returns `undefined` merging will + * be handled by the method instead. The callback is bound to `thisArg` and + * invoked with two arguments; (objectValue, sourceValue). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param {Function} [callback] The function to customize merging properties. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the destination object. + * @example + * + * var names = { + * 'characters': [ + * { 'name': 'barney' }, + * { 'name': 'fred' } + * ] + * }; + * + * var ages = { + * 'characters': [ + * { 'age': 36 }, + * { 'age': 40 } + * ] + * }; + * + * _.merge(names, ages); + * // => { 'characters': [{ 'name': 'barney', 'age': 36 }, { 'name': 'fred', 'age': 40 }] } + * + * var food = { + * 'fruits': ['apple'], + * 'vegetables': ['beet'] + * }; + * + * var otherFood = { + * 'fruits': ['banana'], + * 'vegetables': ['carrot'] + * }; + * + * _.merge(food, otherFood, function(a, b) { + * return _.isArray(a) ? a.concat(b) : undefined; + * }); + * // => { 'fruits': ['apple', 'banana'], 'vegetables': ['beet', 'carrot] } + */ + function merge(object) { + var args = arguments, + length = 2; + + if (!isObject(object)) { + return object; + } + // allows working with `_.reduce` and `_.reduceRight` without using + // their `index` and `collection` arguments + if (typeof args[2] != 'number') { + length = args.length; + } + if (length > 3 && typeof args[length - 2] == 'function') { + var callback = baseCreateCallback(args[--length - 1], args[length--], 2); + } else if (length > 2 && typeof args[length - 1] == 'function') { + callback = args[--length]; + } + var sources = slice(arguments, 1, length), + index = -1, + stackA = getArray(), + stackB = getArray(); + + while (++index < length) { + baseMerge(object, sources[index], callback, stackA, stackB); + } + releaseArray(stackA); + releaseArray(stackB); + return object; + } + + /** + * Creates a shallow clone of `object` excluding the specified properties. + * Property names may be specified as individual arguments or as arrays of + * property names. If a callback is provided it will be executed for each + * property of `object` omitting the properties the callback returns truey + * for. The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The source object. + * @param {Function|...string|string[]} [callback] The properties to omit or the + * function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns an object without the omitted properties. + * @example + * + * _.omit({ 'name': 'fred', 'age': 40 }, 'age'); + * // => { 'name': 'fred' } + * + * _.omit({ 'name': 'fred', 'age': 40 }, function(value) { + * return typeof value == 'number'; + * }); + * // => { 'name': 'fred' } + */ + function omit(object, callback, thisArg) { + var result = {}; + if (typeof callback != 'function') { + var props = []; + forIn(object, function(value, key) { + props.push(key); + }); + props = baseDifference(props, baseFlatten(arguments, true, false, 1)); + + var index = -1, + length = props.length; + + while (++index < length) { + var key = props[index]; + result[key] = object[key]; + } + } else { + callback = lodash.createCallback(callback, thisArg, 3); + forIn(object, function(value, key, object) { + if (!callback(value, key, object)) { + result[key] = value; + } + }); + } + return result; + } + + /** + * Creates a two dimensional array of an object's key-value pairs, + * i.e. `[[key1, value1], [key2, value2]]`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns new array of key-value pairs. + * @example + * + * _.pairs({ 'barney': 36, 'fred': 40 }); + * // => [['barney', 36], ['fred', 40]] (property order is not guaranteed across environments) + */ + function pairs(object) { + var index = -1, + props = keys(object), + length = props.length, + result = Array(length); + + while (++index < length) { + var key = props[index]; + result[index] = [key, object[key]]; + } + return result; + } + + /** + * Creates a shallow clone of `object` composed of the specified properties. + * Property names may be specified as individual arguments or as arrays of + * property names. If a callback is provided it will be executed for each + * property of `object` picking the properties the callback returns truey + * for. The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The source object. + * @param {Function|...string|string[]} [callback] The function called per + * iteration or property names to pick, specified as individual property + * names or arrays of property names. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns an object composed of the picked properties. + * @example + * + * _.pick({ 'name': 'fred', '_userid': 'fred1' }, 'name'); + * // => { 'name': 'fred' } + * + * _.pick({ 'name': 'fred', '_userid': 'fred1' }, function(value, key) { + * return key.charAt(0) != '_'; + * }); + * // => { 'name': 'fred' } + */ + function pick(object, callback, thisArg) { + var result = {}; + if (typeof callback != 'function') { + var index = -1, + props = baseFlatten(arguments, true, false, 1), + length = isObject(object) ? props.length : 0; + + while (++index < length) { + var key = props[index]; + if (key in object) { + result[key] = object[key]; + } + } + } else { + callback = lodash.createCallback(callback, thisArg, 3); + forIn(object, function(value, key, object) { + if (callback(value, key, object)) { + result[key] = value; + } + }); + } + return result; + } + + /** + * An alternative to `_.reduce` this method transforms `object` to a new + * `accumulator` object which is the result of running each of its own + * enumerable properties through a callback, with each callback execution + * potentially mutating the `accumulator` object. The callback is bound to + * `thisArg` and invoked with four arguments; (accumulator, value, key, object). + * Callbacks may exit iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Array|Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] The custom accumulator value. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var squares = _.transform([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], function(result, num) { + * num *= num; + * if (num % 2) { + * return result.push(num) < 3; + * } + * }); + * // => [1, 9, 25] + * + * var mapped = _.transform({ 'a': 1, 'b': 2, 'c': 3 }, function(result, num, key) { + * result[key] = num * 3; + * }); + * // => { 'a': 3, 'b': 6, 'c': 9 } + */ + function transform(object, callback, accumulator, thisArg) { + var isArr = isArray(object); + if (accumulator == null) { + if (isArr) { + accumulator = []; + } else { + var ctor = object && object.constructor, + proto = ctor && ctor.prototype; + + accumulator = baseCreate(proto); + } + } + if (callback) { + callback = lodash.createCallback(callback, thisArg, 4); + (isArr ? forEach : forOwn)(object, function(value, index, object) { + return callback(accumulator, value, index, object); + }); + } + return accumulator; + } + + /** + * Creates an array composed of the own enumerable property values of `object`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property values. + * @example + * + * _.values({ 'one': 1, 'two': 2, 'three': 3 }); + * // => [1, 2, 3] (property order is not guaranteed across environments) + */ + function values(object) { + var index = -1, + props = keys(object), + length = props.length, + result = Array(length); + + while (++index < length) { + result[index] = object[props[index]]; + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates an array of elements from the specified indexes, or keys, of the + * `collection`. Indexes may be specified as individual arguments or as arrays + * of indexes. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {...(number|number[]|string|string[])} [index] The indexes of `collection` + * to retrieve, specified as individual indexes or arrays of indexes. + * @returns {Array} Returns a new array of elements corresponding to the + * provided indexes. + * @example + * + * _.at(['a', 'b', 'c', 'd', 'e'], [0, 2, 4]); + * // => ['a', 'c', 'e'] + * + * _.at(['fred', 'barney', 'pebbles'], 0, 2); + * // => ['fred', 'pebbles'] + */ + function at(collection) { + var args = arguments, + index = -1, + props = baseFlatten(args, true, false, 1), + length = (args[2] && args[2][args[1]] === collection) ? 1 : props.length, + result = Array(length); + + while(++index < length) { + result[index] = collection[props[index]]; + } + return result; + } + + /** + * Checks if a given value is present in a collection using strict equality + * for comparisons, i.e. `===`. If `fromIndex` is negative, it is used as the + * offset from the end of the collection. + * + * @static + * @memberOf _ + * @alias include + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {*} target The value to check for. + * @param {number} [fromIndex=0] The index to search from. + * @returns {boolean} Returns `true` if the `target` element is found, else `false`. + * @example + * + * _.contains([1, 2, 3], 1); + * // => true + * + * _.contains([1, 2, 3], 1, 2); + * // => false + * + * _.contains({ 'name': 'fred', 'age': 40 }, 'fred'); + * // => true + * + * _.contains('pebbles', 'eb'); + * // => true + */ + function contains(collection, target, fromIndex) { + var index = -1, + indexOf = getIndexOf(), + length = collection ? collection.length : 0, + result = false; + + fromIndex = (fromIndex < 0 ? nativeMax(0, length + fromIndex) : fromIndex) || 0; + if (isArray(collection)) { + result = indexOf(collection, target, fromIndex) > -1; + } else if (typeof length == 'number') { + result = (isString(collection) ? collection.indexOf(target, fromIndex) : indexOf(collection, target, fromIndex)) > -1; + } else { + forOwn(collection, function(value) { + if (++index >= fromIndex) { + return !(result = value === target); + } + }); + } + return result; + } + + /** + * Creates an object composed of keys generated from the results of running + * each element of `collection` through the callback. The corresponding value + * of each key is the number of times the key was returned by the callback. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * _.countBy([4.3, 6.1, 6.4], function(num) { return Math.floor(num); }); + * // => { '4': 1, '6': 2 } + * + * _.countBy([4.3, 6.1, 6.4], function(num) { return this.floor(num); }, Math); + * // => { '4': 1, '6': 2 } + * + * _.countBy(['one', 'two', 'three'], 'length'); + * // => { '3': 2, '5': 1 } + */ + var countBy = createAggregator(function(result, value, key) { + (hasOwnProperty.call(result, key) ? result[key]++ : result[key] = 1); + }); + + /** + * Checks if the given callback returns truey value for **all** elements of + * a collection. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias all + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if all elements passed the callback check, + * else `false`. + * @example + * + * _.every([true, 1, null, 'yes']); + * // => false + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.every(characters, 'age'); + * // => true + * + * // using "_.where" callback shorthand + * _.every(characters, { 'age': 36 }); + * // => false + */ + function every(collection, callback, thisArg) { + var result = true; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + if (!(result = !!callback(collection[index], index, collection))) { + break; + } + } + } else { + forOwn(collection, function(value, index, collection) { + return (result = !!callback(value, index, collection)); + }); + } + return result; + } + + /** + * Iterates over elements of a collection, returning an array of all elements + * the callback returns truey for. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias select + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of elements that passed the callback check. + * @example + * + * var evens = _.filter([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); + * // => [2, 4, 6] + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.filter(characters, 'blocked'); + * // => [{ 'name': 'fred', 'age': 40, 'blocked': true }] + * + * // using "_.where" callback shorthand + * _.filter(characters, { 'age': 36 }); + * // => [{ 'name': 'barney', 'age': 36, 'blocked': false }] + */ + function filter(collection, callback, thisArg) { + var result = []; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + if (callback(value, index, collection)) { + result.push(value); + } + } + } else { + forOwn(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result.push(value); + } + }); + } + return result; + } + + /** + * Iterates over elements of a collection, returning the first element that + * the callback returns truey for. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias detect, findWhere + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the found element, else `undefined`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true }, + * { 'name': 'pebbles', 'age': 1, 'blocked': false } + * ]; + * + * _.find(characters, function(chr) { + * return chr.age < 40; + * }); + * // => { 'name': 'barney', 'age': 36, 'blocked': false } + * + * // using "_.where" callback shorthand + * _.find(characters, { 'age': 1 }); + * // => { 'name': 'pebbles', 'age': 1, 'blocked': false } + * + * // using "_.pluck" callback shorthand + * _.find(characters, 'blocked'); + * // => { 'name': 'fred', 'age': 40, 'blocked': true } + */ + function find(collection, callback, thisArg) { + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + if (callback(value, index, collection)) { + return value; + } + } + } else { + var result; + forOwn(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result = value; + return false; + } + }); + return result; + } + } + + /** + * This method is like `_.find` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the found element, else `undefined`. + * @example + * + * _.findLast([1, 2, 3, 4], function(num) { + * return num % 2 == 1; + * }); + * // => 3 + */ + function findLast(collection, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forEachRight(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result = value; + return false; + } + }); + return result; + } + + /** + * Iterates over elements of a collection, executing the callback for each + * element. The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). Callbacks may exit iteration early by + * explicitly returning `false`. + * + * Note: As with other "Collections" methods, objects with a `length` property + * are iterated like arrays. To avoid this behavior `_.forIn` or `_.forOwn` + * may be used for object iteration. + * + * @static + * @memberOf _ + * @alias each + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array|Object|string} Returns `collection`. + * @example + * + * _([1, 2, 3]).forEach(function(num) { console.log(num); }).join(','); + * // => logs each number and returns '1,2,3' + * + * _.forEach({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { console.log(num); }); + * // => logs each number and returns the object (property order is not guaranteed across environments) + */ + function forEach(collection, callback, thisArg) { + var index = -1, + length = collection ? collection.length : 0; + + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + if (typeof length == 'number') { + while (++index < length) { + if (callback(collection[index], index, collection) === false) { + break; + } + } + } else { + forOwn(collection, callback); + } + return collection; + } + + /** + * This method is like `_.forEach` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @alias eachRight + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array|Object|string} Returns `collection`. + * @example + * + * _([1, 2, 3]).forEachRight(function(num) { console.log(num); }).join(','); + * // => logs each number from right to left and returns '3,2,1' + */ + function forEachRight(collection, callback, thisArg) { + var length = collection ? collection.length : 0; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + if (typeof length == 'number') { + while (length--) { + if (callback(collection[length], length, collection) === false) { + break; + } + } + } else { + var props = keys(collection); + length = props.length; + forOwn(collection, function(value, key, collection) { + key = props ? props[--length] : --length; + return callback(collection[key], key, collection); + }); + } + return collection; + } + + /** + * Creates an object composed of keys generated from the results of running + * each element of a collection through the callback. The corresponding value + * of each key is an array of the elements responsible for generating the key. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false` + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * _.groupBy([4.2, 6.1, 6.4], function(num) { return Math.floor(num); }); + * // => { '4': [4.2], '6': [6.1, 6.4] } + * + * _.groupBy([4.2, 6.1, 6.4], function(num) { return this.floor(num); }, Math); + * // => { '4': [4.2], '6': [6.1, 6.4] } + * + * // using "_.pluck" callback shorthand + * _.groupBy(['one', 'two', 'three'], 'length'); + * // => { '3': ['one', 'two'], '5': ['three'] } + */ + var groupBy = createAggregator(function(result, value, key) { + (hasOwnProperty.call(result, key) ? result[key] : result[key] = []).push(value); + }); + + /** + * Creates an object composed of keys generated from the results of running + * each element of the collection through the given callback. The corresponding + * value of each key is the last element responsible for generating the key. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * var keys = [ + * { 'dir': 'left', 'code': 97 }, + * { 'dir': 'right', 'code': 100 } + * ]; + * + * _.indexBy(keys, 'dir'); + * // => { 'left': { 'dir': 'left', 'code': 97 }, 'right': { 'dir': 'right', 'code': 100 } } + * + * _.indexBy(keys, function(key) { return String.fromCharCode(key.code); }); + * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } } + * + * _.indexBy(characters, function(key) { this.fromCharCode(key.code); }, String); + * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } } + */ + var indexBy = createAggregator(function(result, value, key) { + result[key] = value; + }); + + /** + * Invokes the method named by `methodName` on each element in the `collection` + * returning an array of the results of each invoked method. Additional arguments + * will be provided to each invoked method. If `methodName` is a function it + * will be invoked for, and `this` bound to, each element in the `collection`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|string} methodName The name of the method to invoke or + * the function invoked per iteration. + * @param {...*} [arg] Arguments to invoke the method with. + * @returns {Array} Returns a new array of the results of each invoked method. + * @example + * + * _.invoke([[5, 1, 7], [3, 2, 1]], 'sort'); + * // => [[1, 5, 7], [1, 2, 3]] + * + * _.invoke([123, 456], String.prototype.split, ''); + * // => [['1', '2', '3'], ['4', '5', '6']] + */ + function invoke(collection, methodName) { + var args = slice(arguments, 2), + index = -1, + isFunc = typeof methodName == 'function', + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + forEach(collection, function(value) { + result[++index] = (isFunc ? methodName : value[methodName]).apply(value, args); + }); + return result; + } + + /** + * Creates an array of values by running each element in the collection + * through the callback. The callback is bound to `thisArg` and invoked with + * three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias collect + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of the results of each `callback` execution. + * @example + * + * _.map([1, 2, 3], function(num) { return num * 3; }); + * // => [3, 6, 9] + * + * _.map({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { return num * 3; }); + * // => [3, 6, 9] (property order is not guaranteed across environments) + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.map(characters, 'name'); + * // => ['barney', 'fred'] + */ + function map(collection, callback, thisArg) { + var index = -1, + length = collection ? collection.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + if (typeof length == 'number') { + var result = Array(length); + while (++index < length) { + result[index] = callback(collection[index], index, collection); + } + } else { + result = []; + forOwn(collection, function(value, key, collection) { + result[++index] = callback(value, key, collection); + }); + } + return result; + } + + /** + * Retrieves the maximum value of a collection. If the collection is empty or + * falsey `-Infinity` is returned. If a callback is provided it will be executed + * for each value in the collection to generate the criterion by which the value + * is ranked. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the maximum value. + * @example + * + * _.max([4, 2, 8, 6]); + * // => 8 + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.max(characters, function(chr) { return chr.age; }); + * // => { 'name': 'fred', 'age': 40 }; + * + * // using "_.pluck" callback shorthand + * _.max(characters, 'age'); + * // => { 'name': 'fred', 'age': 40 }; + */ + function max(collection, callback, thisArg) { + var computed = -Infinity, + result = computed; + + // allows working with functions like `_.map` without using + // their `index` argument as a callback + if (typeof callback != 'function' && thisArg && thisArg[callback] === collection) { + callback = null; + } + if (callback == null && isArray(collection)) { + var index = -1, + length = collection.length; + + while (++index < length) { + var value = collection[index]; + if (value > result) { + result = value; + } + } + } else { + callback = (callback == null && isString(collection)) + ? charAtCallback + : lodash.createCallback(callback, thisArg, 3); + + forEach(collection, function(value, index, collection) { + var current = callback(value, index, collection); + if (current > computed) { + computed = current; + result = value; + } + }); + } + return result; + } + + /** + * Retrieves the minimum value of a collection. If the collection is empty or + * falsey `Infinity` is returned. If a callback is provided it will be executed + * for each value in the collection to generate the criterion by which the value + * is ranked. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the minimum value. + * @example + * + * _.min([4, 2, 8, 6]); + * // => 2 + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.min(characters, function(chr) { return chr.age; }); + * // => { 'name': 'barney', 'age': 36 }; + * + * // using "_.pluck" callback shorthand + * _.min(characters, 'age'); + * // => { 'name': 'barney', 'age': 36 }; + */ + function min(collection, callback, thisArg) { + var computed = Infinity, + result = computed; + + // allows working with functions like `_.map` without using + // their `index` argument as a callback + if (typeof callback != 'function' && thisArg && thisArg[callback] === collection) { + callback = null; + } + if (callback == null && isArray(collection)) { + var index = -1, + length = collection.length; + + while (++index < length) { + var value = collection[index]; + if (value < result) { + result = value; + } + } + } else { + callback = (callback == null && isString(collection)) + ? charAtCallback + : lodash.createCallback(callback, thisArg, 3); + + forEach(collection, function(value, index, collection) { + var current = callback(value, index, collection); + if (current < computed) { + computed = current; + result = value; + } + }); + } + return result; + } + + /** + * Retrieves the value of a specified property from all elements in the collection. + * + * @static + * @memberOf _ + * @type Function + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {string} property The name of the property to pluck. + * @returns {Array} Returns a new array of property values. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.pluck(characters, 'name'); + * // => ['barney', 'fred'] + */ + var pluck = map; + + /** + * Reduces a collection to a value which is the accumulated result of running + * each element in the collection through the callback, where each successive + * callback execution consumes the return value of the previous execution. If + * `accumulator` is not provided the first element of the collection will be + * used as the initial `accumulator` value. The callback is bound to `thisArg` + * and invoked with four arguments; (accumulator, value, index|key, collection). + * + * @static + * @memberOf _ + * @alias foldl, inject + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] Initial value of the accumulator. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var sum = _.reduce([1, 2, 3], function(sum, num) { + * return sum + num; + * }); + * // => 6 + * + * var mapped = _.reduce({ 'a': 1, 'b': 2, 'c': 3 }, function(result, num, key) { + * result[key] = num * 3; + * return result; + * }, {}); + * // => { 'a': 3, 'b': 6, 'c': 9 } + */ + function reduce(collection, callback, accumulator, thisArg) { + if (!collection) return accumulator; + var noaccum = arguments.length < 3; + callback = lodash.createCallback(callback, thisArg, 4); + + var index = -1, + length = collection.length; + + if (typeof length == 'number') { + if (noaccum) { + accumulator = collection[++index]; + } + while (++index < length) { + accumulator = callback(accumulator, collection[index], index, collection); + } + } else { + forOwn(collection, function(value, index, collection) { + accumulator = noaccum + ? (noaccum = false, value) + : callback(accumulator, value, index, collection) + }); + } + return accumulator; + } + + /** + * This method is like `_.reduce` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @alias foldr + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] Initial value of the accumulator. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var list = [[0, 1], [2, 3], [4, 5]]; + * var flat = _.reduceRight(list, function(a, b) { return a.concat(b); }, []); + * // => [4, 5, 2, 3, 0, 1] + */ + function reduceRight(collection, callback, accumulator, thisArg) { + var noaccum = arguments.length < 3; + callback = lodash.createCallback(callback, thisArg, 4); + forEachRight(collection, function(value, index, collection) { + accumulator = noaccum + ? (noaccum = false, value) + : callback(accumulator, value, index, collection); + }); + return accumulator; + } + + /** + * The opposite of `_.filter` this method returns the elements of a + * collection that the callback does **not** return truey for. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of elements that failed the callback check. + * @example + * + * var odds = _.reject([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); + * // => [1, 3, 5] + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.reject(characters, 'blocked'); + * // => [{ 'name': 'barney', 'age': 36, 'blocked': false }] + * + * // using "_.where" callback shorthand + * _.reject(characters, { 'age': 36 }); + * // => [{ 'name': 'fred', 'age': 40, 'blocked': true }] + */ + function reject(collection, callback, thisArg) { + callback = lodash.createCallback(callback, thisArg, 3); + return filter(collection, function(value, index, collection) { + return !callback(value, index, collection); + }); + } + + /** + * Retrieves a random element or `n` random elements from a collection. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to sample. + * @param {number} [n] The number of elements to sample. + * @param- {Object} [guard] Allows working with functions like `_.map` + * without using their `index` arguments as `n`. + * @returns {Array} Returns the random sample(s) of `collection`. + * @example + * + * _.sample([1, 2, 3, 4]); + * // => 2 + * + * _.sample([1, 2, 3, 4], 2); + * // => [3, 1] + */ + function sample(collection, n, guard) { + if (collection && typeof collection.length != 'number') { + collection = values(collection); + } + if (n == null || guard) { + return collection ? collection[baseRandom(0, collection.length - 1)] : undefined; + } + var result = shuffle(collection); + result.length = nativeMin(nativeMax(0, n), result.length); + return result; + } + + /** + * Creates an array of shuffled values, using a version of the Fisher-Yates + * shuffle. See http://en.wikipedia.org/wiki/Fisher-Yates_shuffle. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to shuffle. + * @returns {Array} Returns a new shuffled collection. + * @example + * + * _.shuffle([1, 2, 3, 4, 5, 6]); + * // => [4, 1, 6, 3, 5, 2] + */ + function shuffle(collection) { + var index = -1, + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + forEach(collection, function(value) { + var rand = baseRandom(0, ++index); + result[index] = result[rand]; + result[rand] = value; + }); + return result; + } + + /** + * Gets the size of the `collection` by returning `collection.length` for arrays + * and array-like objects or the number of own enumerable properties for objects. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to inspect. + * @returns {number} Returns `collection.length` or number of own enumerable properties. + * @example + * + * _.size([1, 2]); + * // => 2 + * + * _.size({ 'one': 1, 'two': 2, 'three': 3 }); + * // => 3 + * + * _.size('pebbles'); + * // => 7 + */ + function size(collection) { + var length = collection ? collection.length : 0; + return typeof length == 'number' ? length : keys(collection).length; + } + + /** + * Checks if the callback returns a truey value for **any** element of a + * collection. The function returns as soon as it finds a passing value and + * does not iterate over the entire collection. The callback is bound to + * `thisArg` and invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias any + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if any element passed the callback check, + * else `false`. + * @example + * + * _.some([null, 0, 'yes', false], Boolean); + * // => true + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.some(characters, 'blocked'); + * // => true + * + * // using "_.where" callback shorthand + * _.some(characters, { 'age': 1 }); + * // => false + */ + function some(collection, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + if ((result = callback(collection[index], index, collection))) { + break; + } + } + } else { + forOwn(collection, function(value, index, collection) { + return !(result = callback(value, index, collection)); + }); + } + return !!result; + } + + /** + * Creates an array of elements, sorted in ascending order by the results of + * running each element in a collection through the callback. This method + * performs a stable sort, that is, it will preserve the original sort order + * of equal elements. The callback is bound to `thisArg` and invoked with + * three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an array of property names is provided for `callback` the collection + * will be sorted by each property value. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Array|Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of sorted elements. + * @example + * + * _.sortBy([1, 2, 3], function(num) { return Math.sin(num); }); + * // => [3, 1, 2] + * + * _.sortBy([1, 2, 3], function(num) { return this.sin(num); }, Math); + * // => [3, 1, 2] + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 }, + * { 'name': 'barney', 'age': 26 }, + * { 'name': 'fred', 'age': 30 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.map(_.sortBy(characters, 'age'), _.values); + * // => [['barney', 26], ['fred', 30], ['barney', 36], ['fred', 40]] + * + * // sorting by multiple properties + * _.map(_.sortBy(characters, ['name', 'age']), _.values); + * // = > [['barney', 26], ['barney', 36], ['fred', 30], ['fred', 40]] + */ + function sortBy(collection, callback, thisArg) { + var index = -1, + isArr = isArray(callback), + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + if (!isArr) { + callback = lodash.createCallback(callback, thisArg, 3); + } + forEach(collection, function(value, key, collection) { + var object = result[++index] = getObject(); + if (isArr) { + object.criteria = map(callback, function(key) { return value[key]; }); + } else { + (object.criteria = getArray())[0] = callback(value, key, collection); + } + object.index = index; + object.value = value; + }); + + length = result.length; + result.sort(compareAscending); + while (length--) { + var object = result[length]; + result[length] = object.value; + if (!isArr) { + releaseArray(object.criteria); + } + releaseObject(object); + } + return result; + } + + /** + * Converts the `collection` to an array. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to convert. + * @returns {Array} Returns the new converted array. + * @example + * + * (function() { return _.toArray(arguments).slice(1); })(1, 2, 3, 4); + * // => [2, 3, 4] + */ + function toArray(collection) { + if (collection && typeof collection.length == 'number') { + return slice(collection); + } + return values(collection); + } + + /** + * Performs a deep comparison of each element in a `collection` to the given + * `properties` object, returning an array of all elements that have equivalent + * property values. + * + * @static + * @memberOf _ + * @type Function + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Object} props The object of property values to filter by. + * @returns {Array} Returns a new array of elements that have the given properties. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'pets': ['hoppy'] }, + * { 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] } + * ]; + * + * _.where(characters, { 'age': 36 }); + * // => [{ 'name': 'barney', 'age': 36, 'pets': ['hoppy'] }] + * + * _.where(characters, { 'pets': ['dino'] }); + * // => [{ 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] }] + */ + var where = filter; + + /*--------------------------------------------------------------------------*/ + + /** + * Creates an array with all falsey values removed. The values `false`, `null`, + * `0`, `""`, `undefined`, and `NaN` are all falsey. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to compact. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.compact([0, 1, false, 2, '', 3]); + * // => [1, 2, 3] + */ + function compact(array) { + var index = -1, + length = array ? array.length : 0, + result = []; + + while (++index < length) { + var value = array[index]; + if (value) { + result.push(value); + } + } + return result; + } + + /** + * Creates an array excluding all values of the provided arrays using strict + * equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to process. + * @param {...Array} [values] The arrays of values to exclude. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.difference([1, 2, 3, 4, 5], [5, 2, 10]); + * // => [1, 3, 4] + */ + function difference(array) { + return baseDifference(array, baseFlatten(arguments, true, true, 1)); + } + + /** + * This method is like `_.find` except that it returns the index of the first + * element that passes the callback check, instead of the element itself. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index of the found element, else `-1`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true }, + * { 'name': 'pebbles', 'age': 1, 'blocked': false } + * ]; + * + * _.findIndex(characters, function(chr) { + * return chr.age < 20; + * }); + * // => 2 + * + * // using "_.where" callback shorthand + * _.findIndex(characters, { 'age': 36 }); + * // => 0 + * + * // using "_.pluck" callback shorthand + * _.findIndex(characters, 'blocked'); + * // => 1 + */ + function findIndex(array, callback, thisArg) { + var index = -1, + length = array ? array.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length) { + if (callback(array[index], index, array)) { + return index; + } + } + return -1; + } + + /** + * This method is like `_.findIndex` except that it iterates over elements + * of a `collection` from right to left. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index of the found element, else `-1`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': true }, + * { 'name': 'fred', 'age': 40, 'blocked': false }, + * { 'name': 'pebbles', 'age': 1, 'blocked': true } + * ]; + * + * _.findLastIndex(characters, function(chr) { + * return chr.age > 30; + * }); + * // => 1 + * + * // using "_.where" callback shorthand + * _.findLastIndex(characters, { 'age': 36 }); + * // => 0 + * + * // using "_.pluck" callback shorthand + * _.findLastIndex(characters, 'blocked'); + * // => 2 + */ + function findLastIndex(array, callback, thisArg) { + var length = array ? array.length : 0; + callback = lodash.createCallback(callback, thisArg, 3); + while (length--) { + if (callback(array[length], length, array)) { + return length; + } + } + return -1; + } + + /** + * Gets the first element or first `n` elements of an array. If a callback + * is provided elements at the beginning of the array are returned as long + * as the callback returns truey. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias head, take + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback] The function called + * per element or the number of elements to return. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the first element(s) of `array`. + * @example + * + * _.first([1, 2, 3]); + * // => 1 + * + * _.first([1, 2, 3], 2); + * // => [1, 2] + * + * _.first([1, 2, 3], function(num) { + * return num < 3; + * }); + * // => [1, 2] + * + * var characters = [ + * { 'name': 'barney', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.first(characters, 'blocked'); + * // => [{ 'name': 'barney', 'blocked': true, 'employer': 'slate' }] + * + * // using "_.where" callback shorthand + * _.pluck(_.first(characters, { 'employer': 'slate' }), 'name'); + * // => ['barney', 'fred'] + */ + function first(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = -1; + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length && callback(array[index], index, array)) { + n++; + } + } else { + n = callback; + if (n == null || thisArg) { + return array ? array[0] : undefined; + } + } + return slice(array, 0, nativeMin(nativeMax(0, n), length)); + } + + /** + * Flattens a nested array (the nesting can be to any depth). If `isShallow` + * is truey, the array will only be flattened a single level. If a callback + * is provided each element of the array is passed through the callback before + * flattening. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to flatten. + * @param {boolean} [isShallow=false] A flag to restrict flattening to a single level. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new flattened array. + * @example + * + * _.flatten([1, [2], [3, [[4]]]]); + * // => [1, 2, 3, 4]; + * + * _.flatten([1, [2], [3, [[4]]]], true); + * // => [1, 2, 3, [[4]]]; + * + * var characters = [ + * { 'name': 'barney', 'age': 30, 'pets': ['hoppy'] }, + * { 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] } + * ]; + * + * // using "_.pluck" callback shorthand + * _.flatten(characters, 'pets'); + * // => ['hoppy', 'baby puss', 'dino'] + */ + function flatten(array, isShallow, callback, thisArg) { + // juggle arguments + if (typeof isShallow != 'boolean' && isShallow != null) { + thisArg = callback; + callback = (typeof isShallow != 'function' && thisArg && thisArg[isShallow] === array) ? null : isShallow; + isShallow = false; + } + if (callback != null) { + array = map(array, callback, thisArg); + } + return baseFlatten(array, isShallow); + } + + /** + * Gets the index at which the first occurrence of `value` is found using + * strict equality for comparisons, i.e. `===`. If the array is already sorted + * providing `true` for `fromIndex` will run a faster binary search. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {boolean|number} [fromIndex=0] The index to search from or `true` + * to perform a binary search on a sorted array. + * @returns {number} Returns the index of the matched value or `-1`. + * @example + * + * _.indexOf([1, 2, 3, 1, 2, 3], 2); + * // => 1 + * + * _.indexOf([1, 2, 3, 1, 2, 3], 2, 3); + * // => 4 + * + * _.indexOf([1, 1, 2, 2, 3, 3], 2, true); + * // => 2 + */ + function indexOf(array, value, fromIndex) { + if (typeof fromIndex == 'number') { + var length = array ? array.length : 0; + fromIndex = (fromIndex < 0 ? nativeMax(0, length + fromIndex) : fromIndex || 0); + } else if (fromIndex) { + var index = sortedIndex(array, value); + return array[index] === value ? index : -1; + } + return baseIndexOf(array, value, fromIndex); + } + + /** + * Gets all but the last element or last `n` elements of an array. If a + * callback is provided elements at the end of the array are excluded from + * the result as long as the callback returns truey. The callback is bound + * to `thisArg` and invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback=1] The function called + * per element or the number of elements to exclude. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a slice of `array`. + * @example + * + * _.initial([1, 2, 3]); + * // => [1, 2] + * + * _.initial([1, 2, 3], 2); + * // => [1] + * + * _.initial([1, 2, 3], function(num) { + * return num > 1; + * }); + * // => [1] + * + * var characters = [ + * { 'name': 'barney', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.initial(characters, 'blocked'); + * // => [{ 'name': 'barney', 'blocked': false, 'employer': 'slate' }] + * + * // using "_.where" callback shorthand + * _.pluck(_.initial(characters, { 'employer': 'na' }), 'name'); + * // => ['barney', 'fred'] + */ + function initial(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = length; + callback = lodash.createCallback(callback, thisArg, 3); + while (index-- && callback(array[index], index, array)) { + n++; + } + } else { + n = (callback == null || thisArg) ? 1 : callback || n; + } + return slice(array, 0, nativeMin(nativeMax(0, length - n), length)); + } + + /** + * Creates an array of unique values present in all provided arrays using + * strict equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of shared values. + * @example + * + * _.intersection([1, 2, 3], [5, 2, 1, 4], [2, 1]); + * // => [1, 2] + */ + function intersection() { + var args = [], + argsIndex = -1, + argsLength = arguments.length, + caches = getArray(), + indexOf = getIndexOf(), + trustIndexOf = indexOf === baseIndexOf, + seen = getArray(); + + while (++argsIndex < argsLength) { + var value = arguments[argsIndex]; + if (isArray(value) || isArguments(value)) { + args.push(value); + caches.push(trustIndexOf && value.length >= largeArraySize && + createCache(argsIndex ? args[argsIndex] : seen)); + } + } + var array = args[0], + index = -1, + length = array ? array.length : 0, + result = []; + + outer: + while (++index < length) { + var cache = caches[0]; + value = array[index]; + + if ((cache ? cacheIndexOf(cache, value) : indexOf(seen, value)) < 0) { + argsIndex = argsLength; + (cache || seen).push(value); + while (--argsIndex) { + cache = caches[argsIndex]; + if ((cache ? cacheIndexOf(cache, value) : indexOf(args[argsIndex], value)) < 0) { + continue outer; + } + } + result.push(value); + } + } + while (argsLength--) { + cache = caches[argsLength]; + if (cache) { + releaseObject(cache); + } + } + releaseArray(caches); + releaseArray(seen); + return result; + } + + /** + * Gets the last element or last `n` elements of an array. If a callback is + * provided elements at the end of the array are returned as long as the + * callback returns truey. The callback is bound to `thisArg` and invoked + * with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback] The function called + * per element or the number of elements to return. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the last element(s) of `array`. + * @example + * + * _.last([1, 2, 3]); + * // => 3 + * + * _.last([1, 2, 3], 2); + * // => [2, 3] + * + * _.last([1, 2, 3], function(num) { + * return num > 1; + * }); + * // => [2, 3] + * + * var characters = [ + * { 'name': 'barney', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.pluck(_.last(characters, 'blocked'), 'name'); + * // => ['fred', 'pebbles'] + * + * // using "_.where" callback shorthand + * _.last(characters, { 'employer': 'na' }); + * // => [{ 'name': 'pebbles', 'blocked': true, 'employer': 'na' }] + */ + function last(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = length; + callback = lodash.createCallback(callback, thisArg, 3); + while (index-- && callback(array[index], index, array)) { + n++; + } + } else { + n = callback; + if (n == null || thisArg) { + return array ? array[length - 1] : undefined; + } + } + return slice(array, nativeMax(0, length - n)); + } + + /** + * Gets the index at which the last occurrence of `value` is found using strict + * equality for comparisons, i.e. `===`. If `fromIndex` is negative, it is used + * as the offset from the end of the collection. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {number} [fromIndex=array.length-1] The index to search from. + * @returns {number} Returns the index of the matched value or `-1`. + * @example + * + * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2); + * // => 4 + * + * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2, 3); + * // => 1 + */ + function lastIndexOf(array, value, fromIndex) { + var index = array ? array.length : 0; + if (typeof fromIndex == 'number') { + index = (fromIndex < 0 ? nativeMax(0, index + fromIndex) : nativeMin(fromIndex, index - 1)) + 1; + } + while (index--) { + if (array[index] === value) { + return index; + } + } + return -1; + } + + /** + * Removes all provided values from the given array using strict equality for + * comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to modify. + * @param {...*} [value] The values to remove. + * @returns {Array} Returns `array`. + * @example + * + * var array = [1, 2, 3, 1, 2, 3]; + * _.pull(array, 2, 3); + * console.log(array); + * // => [1, 1] + */ + function pull(array) { + var args = arguments, + argsIndex = 0, + argsLength = args.length, + length = array ? array.length : 0; + + while (++argsIndex < argsLength) { + var index = -1, + value = args[argsIndex]; + while (++index < length) { + if (array[index] === value) { + splice.call(array, index--, 1); + length--; + } + } + } + return array; + } + + /** + * Creates an array of numbers (positive and/or negative) progressing from + * `start` up to but not including `end`. If `start` is less than `stop` a + * zero-length range is created unless a negative `step` is specified. + * + * @static + * @memberOf _ + * @category Arrays + * @param {number} [start=0] The start of the range. + * @param {number} end The end of the range. + * @param {number} [step=1] The value to increment or decrement by. + * @returns {Array} Returns a new range array. + * @example + * + * _.range(4); + * // => [0, 1, 2, 3] + * + * _.range(1, 5); + * // => [1, 2, 3, 4] + * + * _.range(0, 20, 5); + * // => [0, 5, 10, 15] + * + * _.range(0, -4, -1); + * // => [0, -1, -2, -3] + * + * _.range(1, 4, 0); + * // => [1, 1, 1] + * + * _.range(0); + * // => [] + */ + function range(start, end, step) { + start = +start || 0; + step = typeof step == 'number' ? step : (+step || 1); + + if (end == null) { + end = start; + start = 0; + } + // use `Array(length)` so engines like Chakra and V8 avoid slower modes + // http://youtu.be/XAqIpGU8ZZk#t=17m25s + var index = -1, + length = nativeMax(0, ceil((end - start) / (step || 1))), + result = Array(length); + + while (++index < length) { + result[index] = start; + start += step; + } + return result; + } + + /** + * Removes all elements from an array that the callback returns truey for + * and returns an array of removed elements. The callback is bound to `thisArg` + * and invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to modify. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of removed elements. + * @example + * + * var array = [1, 2, 3, 4, 5, 6]; + * var evens = _.remove(array, function(num) { return num % 2 == 0; }); + * + * console.log(array); + * // => [1, 3, 5] + * + * console.log(evens); + * // => [2, 4, 6] + */ + function remove(array, callback, thisArg) { + var index = -1, + length = array ? array.length : 0, + result = []; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length) { + var value = array[index]; + if (callback(value, index, array)) { + result.push(value); + splice.call(array, index--, 1); + length--; + } + } + return result; + } + + /** + * The opposite of `_.initial` this method gets all but the first element or + * first `n` elements of an array. If a callback function is provided elements + * at the beginning of the array are excluded from the result as long as the + * callback returns truey. The callback is bound to `thisArg` and invoked + * with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias drop, tail + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback=1] The function called + * per element or the number of elements to exclude. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a slice of `array`. + * @example + * + * _.rest([1, 2, 3]); + * // => [2, 3] + * + * _.rest([1, 2, 3], 2); + * // => [3] + * + * _.rest([1, 2, 3], function(num) { + * return num < 3; + * }); + * // => [3] + * + * var characters = [ + * { 'name': 'barney', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.pluck(_.rest(characters, 'blocked'), 'name'); + * // => ['fred', 'pebbles'] + * + * // using "_.where" callback shorthand + * _.rest(characters, { 'employer': 'slate' }); + * // => [{ 'name': 'pebbles', 'blocked': true, 'employer': 'na' }] + */ + function rest(array, callback, thisArg) { + if (typeof callback != 'number' && callback != null) { + var n = 0, + index = -1, + length = array ? array.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length && callback(array[index], index, array)) { + n++; + } + } else { + n = (callback == null || thisArg) ? 1 : nativeMax(0, callback); + } + return slice(array, n); + } + + /** + * Uses a binary search to determine the smallest index at which a value + * should be inserted into a given sorted array in order to maintain the sort + * order of the array. If a callback is provided it will be executed for + * `value` and each element of `array` to compute their sort ranking. The + * callback is bound to `thisArg` and invoked with one argument; (value). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to inspect. + * @param {*} value The value to evaluate. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index at which `value` should be inserted + * into `array`. + * @example + * + * _.sortedIndex([20, 30, 50], 40); + * // => 2 + * + * // using "_.pluck" callback shorthand + * _.sortedIndex([{ 'x': 20 }, { 'x': 30 }, { 'x': 50 }], { 'x': 40 }, 'x'); + * // => 2 + * + * var dict = { + * 'wordToNumber': { 'twenty': 20, 'thirty': 30, 'fourty': 40, 'fifty': 50 } + * }; + * + * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { + * return dict.wordToNumber[word]; + * }); + * // => 2 + * + * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { + * return this.wordToNumber[word]; + * }, dict); + * // => 2 + */ + function sortedIndex(array, value, callback, thisArg) { + var low = 0, + high = array ? array.length : low; + + // explicitly reference `identity` for better inlining in Firefox + callback = callback ? lodash.createCallback(callback, thisArg, 1) : identity; + value = callback(value); + + while (low < high) { + var mid = (low + high) >>> 1; + (callback(array[mid]) < value) + ? low = mid + 1 + : high = mid; + } + return low; + } + + /** + * Creates an array of unique values, in order, of the provided arrays using + * strict equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of combined values. + * @example + * + * _.union([1, 2, 3], [5, 2, 1, 4], [2, 1]); + * // => [1, 2, 3, 5, 4] + */ + function union() { + return baseUniq(baseFlatten(arguments, true, true)); + } + + /** + * Creates a duplicate-value-free version of an array using strict equality + * for comparisons, i.e. `===`. If the array is sorted, providing + * `true` for `isSorted` will use a faster algorithm. If a callback is provided + * each element of `array` is passed through the callback before uniqueness + * is computed. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias unique + * @category Arrays + * @param {Array} array The array to process. + * @param {boolean} [isSorted=false] A flag to indicate that `array` is sorted. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a duplicate-value-free array. + * @example + * + * _.uniq([1, 2, 1, 3, 1]); + * // => [1, 2, 3] + * + * _.uniq([1, 1, 2, 2, 3], true); + * // => [1, 2, 3] + * + * _.uniq(['A', 'b', 'C', 'a', 'B', 'c'], function(letter) { return letter.toLowerCase(); }); + * // => ['A', 'b', 'C'] + * + * _.uniq([1, 2.5, 3, 1.5, 2, 3.5], function(num) { return this.floor(num); }, Math); + * // => [1, 2.5, 3] + * + * // using "_.pluck" callback shorthand + * _.uniq([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x'); + * // => [{ 'x': 1 }, { 'x': 2 }] + */ + function uniq(array, isSorted, callback, thisArg) { + // juggle arguments + if (typeof isSorted != 'boolean' && isSorted != null) { + thisArg = callback; + callback = (typeof isSorted != 'function' && thisArg && thisArg[isSorted] === array) ? null : isSorted; + isSorted = false; + } + if (callback != null) { + callback = lodash.createCallback(callback, thisArg, 3); + } + return baseUniq(array, isSorted, callback); + } + + /** + * Creates an array excluding all provided values using strict equality for + * comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to filter. + * @param {...*} [value] The values to exclude. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.without([1, 2, 1, 0, 3, 1, 4], 0, 1); + * // => [2, 3, 4] + */ + function without(array) { + return baseDifference(array, slice(arguments, 1)); + } + + /** + * Creates an array that is the symmetric difference of the provided arrays. + * See http://en.wikipedia.org/wiki/Symmetric_difference. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of values. + * @example + * + * _.xor([1, 2, 3], [5, 2, 1, 4]); + * // => [3, 5, 4] + * + * _.xor([1, 2, 5], [2, 3, 5], [3, 4, 5]); + * // => [1, 4, 5] + */ + function xor() { + var index = -1, + length = arguments.length; + + while (++index < length) { + var array = arguments[index]; + if (isArray(array) || isArguments(array)) { + var result = result + ? baseUniq(baseDifference(result, array).concat(baseDifference(array, result))) + : array; + } + } + return result || []; + } + + /** + * Creates an array of grouped elements, the first of which contains the first + * elements of the given arrays, the second of which contains the second + * elements of the given arrays, and so on. + * + * @static + * @memberOf _ + * @alias unzip + * @category Arrays + * @param {...Array} [array] Arrays to process. + * @returns {Array} Returns a new array of grouped elements. + * @example + * + * _.zip(['fred', 'barney'], [30, 40], [true, false]); + * // => [['fred', 30, true], ['barney', 40, false]] + */ + function zip() { + var array = arguments.length > 1 ? arguments : arguments[0], + index = -1, + length = array ? max(pluck(array, 'length')) : 0, + result = Array(length < 0 ? 0 : length); + + while (++index < length) { + result[index] = pluck(array, index); + } + return result; + } + + /** + * Creates an object composed from arrays of `keys` and `values`. Provide + * either a single two dimensional array, i.e. `[[key1, value1], [key2, value2]]` + * or two arrays, one of `keys` and one of corresponding `values`. + * + * @static + * @memberOf _ + * @alias object + * @category Arrays + * @param {Array} keys The array of keys. + * @param {Array} [values=[]] The array of values. + * @returns {Object} Returns an object composed of the given keys and + * corresponding values. + * @example + * + * _.zipObject(['fred', 'barney'], [30, 40]); + * // => { 'fred': 30, 'barney': 40 } + */ + function zipObject(keys, values) { + var index = -1, + length = keys ? keys.length : 0, + result = {}; + + if (!values && length && !isArray(keys[0])) { + values = []; + } + while (++index < length) { + var key = keys[index]; + if (values) { + result[key] = values[index]; + } else if (key) { + result[key[0]] = key[1]; + } + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a function that executes `func`, with the `this` binding and + * arguments of the created function, only after being called `n` times. + * + * @static + * @memberOf _ + * @category Functions + * @param {number} n The number of times the function must be called before + * `func` is executed. + * @param {Function} func The function to restrict. + * @returns {Function} Returns the new restricted function. + * @example + * + * var saves = ['profile', 'settings']; + * + * var done = _.after(saves.length, function() { + * console.log('Done saving!'); + * }); + * + * _.forEach(saves, function(type) { + * asyncSave({ 'type': type, 'complete': done }); + * }); + * // => logs 'Done saving!', after all saves have completed + */ + function after(n, func) { + if (!isFunction(func)) { + throw new TypeError; + } + return function() { + if (--n < 1) { + return func.apply(this, arguments); + } + }; + } + + /** + * Creates a function that, when called, invokes `func` with the `this` + * binding of `thisArg` and prepends any additional `bind` arguments to those + * provided to the bound function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to bind. + * @param {*} [thisArg] The `this` binding of `func`. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new bound function. + * @example + * + * var func = function(greeting) { + * return greeting + ' ' + this.name; + * }; + * + * func = _.bind(func, { 'name': 'fred' }, 'hi'); + * func(); + * // => 'hi fred' + */ + function bind(func, thisArg) { + return arguments.length > 2 + ? createWrapper(func, 17, slice(arguments, 2), null, thisArg) + : createWrapper(func, 1, null, null, thisArg); + } + + /** + * Binds methods of an object to the object itself, overwriting the existing + * method. Method names may be specified as individual arguments or as arrays + * of method names. If no method names are provided all the function properties + * of `object` will be bound. + * + * @static + * @memberOf _ + * @category Functions + * @param {Object} object The object to bind and assign the bound methods to. + * @param {...string} [methodName] The object method names to + * bind, specified as individual method names or arrays of method names. + * @returns {Object} Returns `object`. + * @example + * + * var view = { + * 'label': 'docs', + * 'onClick': function() { console.log('clicked ' + this.label); } + * }; + * + * _.bindAll(view); + * jQuery('#docs').on('click', view.onClick); + * // => logs 'clicked docs', when the button is clicked + */ + function bindAll(object) { + var funcs = arguments.length > 1 ? baseFlatten(arguments, true, false, 1) : functions(object), + index = -1, + length = funcs.length; + + while (++index < length) { + var key = funcs[index]; + object[key] = createWrapper(object[key], 1, null, null, object); + } + return object; + } + + /** + * Creates a function that, when called, invokes the method at `object[key]` + * and prepends any additional `bindKey` arguments to those provided to the bound + * function. This method differs from `_.bind` by allowing bound functions to + * reference methods that will be redefined or don't yet exist. + * See http://michaux.ca/articles/lazy-function-definition-pattern. + * + * @static + * @memberOf _ + * @category Functions + * @param {Object} object The object the method belongs to. + * @param {string} key The key of the method. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new bound function. + * @example + * + * var object = { + * 'name': 'fred', + * 'greet': function(greeting) { + * return greeting + ' ' + this.name; + * } + * }; + * + * var func = _.bindKey(object, 'greet', 'hi'); + * func(); + * // => 'hi fred' + * + * object.greet = function(greeting) { + * return greeting + 'ya ' + this.name + '!'; + * }; + * + * func(); + * // => 'hiya fred!' + */ + function bindKey(object, key) { + return arguments.length > 2 + ? createWrapper(key, 19, slice(arguments, 2), null, object) + : createWrapper(key, 3, null, null, object); + } + + /** + * Creates a function that is the composition of the provided functions, + * where each function consumes the return value of the function that follows. + * For example, composing the functions `f()`, `g()`, and `h()` produces `f(g(h()))`. + * Each function is executed with the `this` binding of the composed function. + * + * @static + * @memberOf _ + * @category Functions + * @param {...Function} [func] Functions to compose. + * @returns {Function} Returns the new composed function. + * @example + * + * var realNameMap = { + * 'pebbles': 'penelope' + * }; + * + * var format = function(name) { + * name = realNameMap[name.toLowerCase()] || name; + * return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + * }; + * + * var greet = function(formatted) { + * return 'Hiya ' + formatted + '!'; + * }; + * + * var welcome = _.compose(greet, format); + * welcome('pebbles'); + * // => 'Hiya Penelope!' + */ + function compose() { + var funcs = arguments, + length = funcs.length; + + while (length--) { + if (!isFunction(funcs[length])) { + throw new TypeError; + } + } + return function() { + var args = arguments, + length = funcs.length; + + while (length--) { + args = [funcs[length].apply(this, args)]; + } + return args[0]; + }; + } + + /** + * Creates a function which accepts one or more arguments of `func` that when + * invoked either executes `func` returning its result, if all `func` arguments + * have been provided, or returns a function that accepts one or more of the + * remaining `func` arguments, and so on. The arity of `func` can be specified + * if `func.length` is not sufficient. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to curry. + * @param {number} [arity=func.length] The arity of `func`. + * @returns {Function} Returns the new curried function. + * @example + * + * var curried = _.curry(function(a, b, c) { + * console.log(a + b + c); + * }); + * + * curried(1)(2)(3); + * // => 6 + * + * curried(1, 2)(3); + * // => 6 + * + * curried(1, 2, 3); + * // => 6 + */ + function curry(func, arity) { + arity = typeof arity == 'number' ? arity : (+arity || func.length); + return createWrapper(func, 4, null, null, null, arity); + } + + /** + * Creates a function that will delay the execution of `func` until after + * `wait` milliseconds have elapsed since the last time it was invoked. + * Provide an options object to indicate that `func` should be invoked on + * the leading and/or trailing edge of the `wait` timeout. Subsequent calls + * to the debounced function will return the result of the last `func` call. + * + * Note: If `leading` and `trailing` options are `true` `func` will be called + * on the trailing edge of the timeout only if the the debounced function is + * invoked more than once during the `wait` timeout. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to debounce. + * @param {number} wait The number of milliseconds to delay. + * @param {Object} [options] The options object. + * @param {boolean} [options.leading=false] Specify execution on the leading edge of the timeout. + * @param {number} [options.maxWait] The maximum time `func` is allowed to be delayed before it's called. + * @param {boolean} [options.trailing=true] Specify execution on the trailing edge of the timeout. + * @returns {Function} Returns the new debounced function. + * @example + * + * // avoid costly calculations while the window size is in flux + * var lazyLayout = _.debounce(calculateLayout, 150); + * jQuery(window).on('resize', lazyLayout); + * + * // execute `sendMail` when the click event is fired, debouncing subsequent calls + * jQuery('#postbox').on('click', _.debounce(sendMail, 300, { + * 'leading': true, + * 'trailing': false + * }); + * + * // ensure `batchLog` is executed once after 1 second of debounced calls + * var source = new EventSource('/stream'); + * source.addEventListener('message', _.debounce(batchLog, 250, { + * 'maxWait': 1000 + * }, false); + */ + function debounce(func, wait, options) { + var args, + maxTimeoutId, + result, + stamp, + thisArg, + timeoutId, + trailingCall, + lastCalled = 0, + maxWait = false, + trailing = true; + + if (!isFunction(func)) { + throw new TypeError; + } + wait = nativeMax(0, wait) || 0; + if (options === true) { + var leading = true; + trailing = false; + } else if (isObject(options)) { + leading = options.leading; + maxWait = 'maxWait' in options && (nativeMax(wait, options.maxWait) || 0); + trailing = 'trailing' in options ? options.trailing : trailing; + } + var delayed = function() { + var remaining = wait - (now() - stamp); + if (remaining <= 0) { + if (maxTimeoutId) { + clearTimeout(maxTimeoutId); + } + var isCalled = trailingCall; + maxTimeoutId = timeoutId = trailingCall = undefined; + if (isCalled) { + lastCalled = now(); + result = func.apply(thisArg, args); + if (!timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + } + } else { + timeoutId = setTimeout(delayed, remaining); + } + }; + + var maxDelayed = function() { + if (timeoutId) { + clearTimeout(timeoutId); + } + maxTimeoutId = timeoutId = trailingCall = undefined; + if (trailing || (maxWait !== wait)) { + lastCalled = now(); + result = func.apply(thisArg, args); + if (!timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + } + }; + + return function() { + args = arguments; + stamp = now(); + thisArg = this; + trailingCall = trailing && (timeoutId || !leading); + + if (maxWait === false) { + var leadingCall = leading && !timeoutId; + } else { + if (!maxTimeoutId && !leading) { + lastCalled = stamp; + } + var remaining = maxWait - (stamp - lastCalled), + isCalled = remaining <= 0; + + if (isCalled) { + if (maxTimeoutId) { + maxTimeoutId = clearTimeout(maxTimeoutId); + } + lastCalled = stamp; + result = func.apply(thisArg, args); + } + else if (!maxTimeoutId) { + maxTimeoutId = setTimeout(maxDelayed, remaining); + } + } + if (isCalled && timeoutId) { + timeoutId = clearTimeout(timeoutId); + } + else if (!timeoutId && wait !== maxWait) { + timeoutId = setTimeout(delayed, wait); + } + if (leadingCall) { + isCalled = true; + result = func.apply(thisArg, args); + } + if (isCalled && !timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + return result; + }; + } + + /** + * Defers executing the `func` function until the current call stack has cleared. + * Additional arguments will be provided to `func` when it is invoked. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to defer. + * @param {...*} [arg] Arguments to invoke the function with. + * @returns {number} Returns the timer id. + * @example + * + * _.defer(function(text) { console.log(text); }, 'deferred'); + * // logs 'deferred' after one or more milliseconds + */ + function defer(func) { + if (!isFunction(func)) { + throw new TypeError; + } + var args = slice(arguments, 1); + return setTimeout(function() { func.apply(undefined, args); }, 1); + } + + /** + * Executes the `func` function after `wait` milliseconds. Additional arguments + * will be provided to `func` when it is invoked. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to delay. + * @param {number} wait The number of milliseconds to delay execution. + * @param {...*} [arg] Arguments to invoke the function with. + * @returns {number} Returns the timer id. + * @example + * + * _.delay(function(text) { console.log(text); }, 1000, 'later'); + * // => logs 'later' after one second + */ + function delay(func, wait) { + if (!isFunction(func)) { + throw new TypeError; + } + var args = slice(arguments, 2); + return setTimeout(function() { func.apply(undefined, args); }, wait); + } + + /** + * Creates a function that memoizes the result of `func`. If `resolver` is + * provided it will be used to determine the cache key for storing the result + * based on the arguments provided to the memoized function. By default, the + * first argument provided to the memoized function is used as the cache key. + * The `func` is executed with the `this` binding of the memoized function. + * The result cache is exposed as the `cache` property on the memoized function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to have its output memoized. + * @param {Function} [resolver] A function used to resolve the cache key. + * @returns {Function} Returns the new memoizing function. + * @example + * + * var fibonacci = _.memoize(function(n) { + * return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2); + * }); + * + * fibonacci(9) + * // => 34 + * + * var data = { + * 'fred': { 'name': 'fred', 'age': 40 }, + * 'pebbles': { 'name': 'pebbles', 'age': 1 } + * }; + * + * // modifying the result cache + * var get = _.memoize(function(name) { return data[name]; }, _.identity); + * get('pebbles'); + * // => { 'name': 'pebbles', 'age': 1 } + * + * get.cache.pebbles.name = 'penelope'; + * get('pebbles'); + * // => { 'name': 'penelope', 'age': 1 } + */ + function memoize(func, resolver) { + if (!isFunction(func)) { + throw new TypeError; + } + var memoized = function() { + var cache = memoized.cache, + key = resolver ? resolver.apply(this, arguments) : keyPrefix + arguments[0]; + + return hasOwnProperty.call(cache, key) + ? cache[key] + : (cache[key] = func.apply(this, arguments)); + } + memoized.cache = {}; + return memoized; + } + + /** + * Creates a function that is restricted to execute `func` once. Repeat calls to + * the function will return the value of the first call. The `func` is executed + * with the `this` binding of the created function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to restrict. + * @returns {Function} Returns the new restricted function. + * @example + * + * var initialize = _.once(createApplication); + * initialize(); + * initialize(); + * // `initialize` executes `createApplication` once + */ + function once(func) { + var ran, + result; + + if (!isFunction(func)) { + throw new TypeError; + } + return function() { + if (ran) { + return result; + } + ran = true; + result = func.apply(this, arguments); + + // clear the `func` variable so the function may be garbage collected + func = null; + return result; + }; + } + + /** + * Creates a function that, when called, invokes `func` with any additional + * `partial` arguments prepended to those provided to the new function. This + * method is similar to `_.bind` except it does **not** alter the `this` binding. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to partially apply arguments to. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new partially applied function. + * @example + * + * var greet = function(greeting, name) { return greeting + ' ' + name; }; + * var hi = _.partial(greet, 'hi'); + * hi('fred'); + * // => 'hi fred' + */ + function partial(func) { + return createWrapper(func, 16, slice(arguments, 1)); + } + + /** + * This method is like `_.partial` except that `partial` arguments are + * appended to those provided to the new function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to partially apply arguments to. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new partially applied function. + * @example + * + * var defaultsDeep = _.partialRight(_.merge, _.defaults); + * + * var options = { + * 'variable': 'data', + * 'imports': { 'jq': $ } + * }; + * + * defaultsDeep(options, _.templateSettings); + * + * options.variable + * // => 'data' + * + * options.imports + * // => { '_': _, 'jq': $ } + */ + function partialRight(func) { + return createWrapper(func, 32, null, slice(arguments, 1)); + } + + /** + * Creates a function that, when executed, will only call the `func` function + * at most once per every `wait` milliseconds. Provide an options object to + * indicate that `func` should be invoked on the leading and/or trailing edge + * of the `wait` timeout. Subsequent calls to the throttled function will + * return the result of the last `func` call. + * + * Note: If `leading` and `trailing` options are `true` `func` will be called + * on the trailing edge of the timeout only if the the throttled function is + * invoked more than once during the `wait` timeout. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to throttle. + * @param {number} wait The number of milliseconds to throttle executions to. + * @param {Object} [options] The options object. + * @param {boolean} [options.leading=true] Specify execution on the leading edge of the timeout. + * @param {boolean} [options.trailing=true] Specify execution on the trailing edge of the timeout. + * @returns {Function} Returns the new throttled function. + * @example + * + * // avoid excessively updating the position while scrolling + * var throttled = _.throttle(updatePosition, 100); + * jQuery(window).on('scroll', throttled); + * + * // execute `renewToken` when the click event is fired, but not more than once every 5 minutes + * jQuery('.interactive').on('click', _.throttle(renewToken, 300000, { + * 'trailing': false + * })); + */ + function throttle(func, wait, options) { + var leading = true, + trailing = true; + + if (!isFunction(func)) { + throw new TypeError; + } + if (options === false) { + leading = false; + } else if (isObject(options)) { + leading = 'leading' in options ? options.leading : leading; + trailing = 'trailing' in options ? options.trailing : trailing; + } + debounceOptions.leading = leading; + debounceOptions.maxWait = wait; + debounceOptions.trailing = trailing; + + return debounce(func, wait, debounceOptions); + } + + /** + * Creates a function that provides `value` to the wrapper function as its + * first argument. Additional arguments provided to the function are appended + * to those provided to the wrapper function. The wrapper is executed with + * the `this` binding of the created function. + * + * @static + * @memberOf _ + * @category Functions + * @param {*} value The value to wrap. + * @param {Function} wrapper The wrapper function. + * @returns {Function} Returns the new function. + * @example + * + * var p = _.wrap(_.escape, function(func, text) { + * return '

' + func(text) + '

'; + * }); + * + * p('Fred, Wilma, & Pebbles'); + * // => '

Fred, Wilma, & Pebbles

' + */ + function wrap(value, wrapper) { + return createWrapper(wrapper, 16, [value]); + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a function that returns `value`. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} value The value to return from the new function. + * @returns {Function} Returns the new function. + * @example + * + * var object = { 'name': 'fred' }; + * var getter = _.constant(object); + * getter() === object; + * // => true + */ + function constant(value) { + return function() { + return value; + }; + } + + /** + * Produces a callback bound to an optional `thisArg`. If `func` is a property + * name the created callback will return the property value for a given element. + * If `func` is an object the created callback will return `true` for elements + * that contain the equivalent object properties, otherwise it will return `false`. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} [func=identity] The value to convert to a callback. + * @param {*} [thisArg] The `this` binding of the created callback. + * @param {number} [argCount] The number of arguments the callback accepts. + * @returns {Function} Returns a callback function. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // wrap to create custom callback shorthands + * _.createCallback = _.wrap(_.createCallback, function(func, callback, thisArg) { + * var match = /^(.+?)__([gl]t)(.+)$/.exec(callback); + * return !match ? func(callback, thisArg) : function(object) { + * return match[2] == 'gt' ? object[match[1]] > match[3] : object[match[1]] < match[3]; + * }; + * }); + * + * _.filter(characters, 'age__gt38'); + * // => [{ 'name': 'fred', 'age': 40 }] + */ + function createCallback(func, thisArg, argCount) { + var type = typeof func; + if (func == null || type == 'function') { + return baseCreateCallback(func, thisArg, argCount); + } + // handle "_.pluck" style callback shorthands + if (type != 'object') { + return property(func); + } + var props = keys(func), + key = props[0], + a = func[key]; + + // handle "_.where" style callback shorthands + if (props.length == 1 && a === a && !isObject(a)) { + // fast path the common case of providing an object with a single + // property containing a primitive value + return function(object) { + var b = object[key]; + return a === b && (a !== 0 || (1 / a == 1 / b)); + }; + } + return function(object) { + var length = props.length, + result = false; + + while (length--) { + if (!(result = baseIsEqual(object[props[length]], func[props[length]], null, true))) { + break; + } + } + return result; + }; + } + + /** + * Converts the characters `&`, `<`, `>`, `"`, and `'` in `string` to their + * corresponding HTML entities. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} string The string to escape. + * @returns {string} Returns the escaped string. + * @example + * + * _.escape('Fred, Wilma, & Pebbles'); + * // => 'Fred, Wilma, & Pebbles' + */ + function escape(string) { + return string == null ? '' : String(string).replace(reUnescapedHtml, escapeHtmlChar); + } + + /** + * This method returns the first argument provided to it. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} value Any value. + * @returns {*} Returns `value`. + * @example + * + * var object = { 'name': 'fred' }; + * _.identity(object) === object; + * // => true + */ + function identity(value) { + return value; + } + + /** + * Adds function properties of a source object to the destination object. + * If `object` is a function methods will be added to its prototype as well. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Function|Object} [object=lodash] object The destination object. + * @param {Object} source The object of functions to add. + * @param {Object} [options] The options object. + * @param {boolean} [options.chain=true] Specify whether the functions added are chainable. + * @example + * + * function capitalize(string) { + * return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); + * } + * + * _.mixin({ 'capitalize': capitalize }); + * _.capitalize('fred'); + * // => 'Fred' + * + * _('fred').capitalize().value(); + * // => 'Fred' + * + * _.mixin({ 'capitalize': capitalize }, { 'chain': false }); + * _('fred').capitalize(); + * // => 'Fred' + */ + function mixin(object, source, options) { + var chain = true, + methodNames = source && functions(source); + + if (!source || (!options && !methodNames.length)) { + if (options == null) { + options = source; + } + ctor = lodashWrapper; + source = object; + object = lodash; + methodNames = functions(source); + } + if (options === false) { + chain = false; + } else if (isObject(options) && 'chain' in options) { + chain = options.chain; + } + var ctor = object, + isFunc = isFunction(ctor); + + forEach(methodNames, function(methodName) { + var func = object[methodName] = source[methodName]; + if (isFunc) { + ctor.prototype[methodName] = function() { + var chainAll = this.__chain__, + value = this.__wrapped__, + args = [value]; + + push.apply(args, arguments); + var result = func.apply(object, args); + if (chain || chainAll) { + if (value === result && isObject(result)) { + return this; + } + result = new ctor(result); + result.__chain__ = chainAll; + } + return result; + }; + } + }); + } + + /** + * Reverts the '_' variable to its previous value and returns a reference to + * the `lodash` function. + * + * @static + * @memberOf _ + * @category Utilities + * @returns {Function} Returns the `lodash` function. + * @example + * + * var lodash = _.noConflict(); + */ + function noConflict() { + context._ = oldDash; + return this; + } + + /** + * A no-operation function. + * + * @static + * @memberOf _ + * @category Utilities + * @example + * + * var object = { 'name': 'fred' }; + * _.noop(object) === undefined; + * // => true + */ + function noop() { + // no operation performed + } + + /** + * Gets the number of milliseconds that have elapsed since the Unix epoch + * (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @category Utilities + * @example + * + * var stamp = _.now(); + * _.defer(function() { console.log(_.now() - stamp); }); + * // => logs the number of milliseconds it took for the deferred function to be called + */ + var now = isNative(now = Date.now) && now || function() { + return new Date().getTime(); + }; + + /** + * Converts the given value into an integer of the specified radix. + * If `radix` is `undefined` or `0` a `radix` of `10` is used unless the + * `value` is a hexadecimal, in which case a `radix` of `16` is used. + * + * Note: This method avoids differences in native ES3 and ES5 `parseInt` + * implementations. See http://es5.github.io/#E. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} value The value to parse. + * @param {number} [radix] The radix used to interpret the value to parse. + * @returns {number} Returns the new integer value. + * @example + * + * _.parseInt('08'); + * // => 8 + */ + var parseInt = nativeParseInt(whitespace + '08') == 8 ? nativeParseInt : function(value, radix) { + // Firefox < 21 and Opera < 15 follow the ES3 specified implementation of `parseInt` + return nativeParseInt(isString(value) ? value.replace(reLeadingSpacesAndZeros, '') : value, radix || 0); + }; + + /** + * Creates a "_.pluck" style function, which returns the `key` value of a + * given object. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} key The name of the property to retrieve. + * @returns {Function} Returns the new function. + * @example + * + * var characters = [ + * { 'name': 'fred', 'age': 40 }, + * { 'name': 'barney', 'age': 36 } + * ]; + * + * var getName = _.property('name'); + * + * _.map(characters, getName); + * // => ['barney', 'fred'] + * + * _.sortBy(characters, getName); + * // => [{ 'name': 'barney', 'age': 36 }, { 'name': 'fred', 'age': 40 }] + */ + function property(key) { + return function(object) { + return object[key]; + }; + } + + /** + * Produces a random number between `min` and `max` (inclusive). If only one + * argument is provided a number between `0` and the given number will be + * returned. If `floating` is truey or either `min` or `max` are floats a + * floating-point number will be returned instead of an integer. + * + * @static + * @memberOf _ + * @category Utilities + * @param {number} [min=0] The minimum possible value. + * @param {number} [max=1] The maximum possible value. + * @param {boolean} [floating=false] Specify returning a floating-point number. + * @returns {number} Returns a random number. + * @example + * + * _.random(0, 5); + * // => an integer between 0 and 5 + * + * _.random(5); + * // => also an integer between 0 and 5 + * + * _.random(5, true); + * // => a floating-point number between 0 and 5 + * + * _.random(1.2, 5.2); + * // => a floating-point number between 1.2 and 5.2 + */ + function random(min, max, floating) { + var noMin = min == null, + noMax = max == null; + + if (floating == null) { + if (typeof min == 'boolean' && noMax) { + floating = min; + min = 1; + } + else if (!noMax && typeof max == 'boolean') { + floating = max; + noMax = true; + } + } + if (noMin && noMax) { + max = 1; + } + min = +min || 0; + if (noMax) { + max = min; + min = 0; + } else { + max = +max || 0; + } + if (floating || min % 1 || max % 1) { + var rand = nativeRandom(); + return nativeMin(min + (rand * (max - min + parseFloat('1e-' + ((rand +'').length - 1)))), max); + } + return baseRandom(min, max); + } + + /** + * Resolves the value of property `key` on `object`. If `key` is a function + * it will be invoked with the `this` binding of `object` and its result returned, + * else the property value is returned. If `object` is falsey then `undefined` + * is returned. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Object} object The object to inspect. + * @param {string} key The name of the property to resolve. + * @returns {*} Returns the resolved value. + * @example + * + * var object = { + * 'cheese': 'crumpets', + * 'stuff': function() { + * return 'nonsense'; + * } + * }; + * + * _.result(object, 'cheese'); + * // => 'crumpets' + * + * _.result(object, 'stuff'); + * // => 'nonsense' + */ + function result(object, key) { + if (object) { + var value = object[key]; + return isFunction(value) ? object[key]() : value; + } + } + + /** + * A micro-templating method that handles arbitrary delimiters, preserves + * whitespace, and correctly escapes quotes within interpolated code. + * + * Note: In the development build, `_.template` utilizes sourceURLs for easier + * debugging. See http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl + * + * For more information on precompiling templates see: + * http://lodash.com/custom-builds + * + * For more information on Chrome extension sandboxes see: + * http://developer.chrome.com/stable/extensions/sandboxingEval.html + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} text The template text. + * @param {Object} data The data object used to populate the text. + * @param {Object} [options] The options object. + * @param {RegExp} [options.escape] The "escape" delimiter. + * @param {RegExp} [options.evaluate] The "evaluate" delimiter. + * @param {Object} [options.imports] An object to import into the template as local variables. + * @param {RegExp} [options.interpolate] The "interpolate" delimiter. + * @param {string} [sourceURL] The sourceURL of the template's compiled source. + * @param {string} [variable] The data object variable name. + * @returns {Function|string} Returns a compiled function when no `data` object + * is given, else it returns the interpolated text. + * @example + * + * // using the "interpolate" delimiter to create a compiled template + * var compiled = _.template('hello <%= name %>'); + * compiled({ 'name': 'fred' }); + * // => 'hello fred' + * + * // using the "escape" delimiter to escape HTML in data property values + * _.template('<%- value %>', { 'value': '\n\n\n'; + } + + var form = document.createElement('form'); + form.action = "http://plnkr.co/edit/?p=preview"; + form.method = "POST"; + form.target = "_blank"; + + document.body.appendChild(form); + + var textarea = document.createElement('textarea'); + textarea.name = "files[index.html]"; + textarea.value = html; + form.appendChild(textarea); + + var input = document.createElement('input'); + input.name = "description"; + input.value = "Fork from " + window.location; + form.appendChild(input); + + form.submit(); + form.remove(); + } + + + function normalizeHtml() { + var codeLc = code.toLowerCase(); + var hasBodyStart = codeLc.match(''); + var hasBodyEnd = codeLc.match(''); + var hasHtmlStart = codeLc.match(''); + var hasHtmlEnd = codeLc.match(''); + + var hasDocType = codeLc.match(/^\s*\n' + result; + } + + if (!hasHtmlEnd) { + result = result + '\n'; + } + + if (!hasBodyStart) { + result = result.replace('', '\n\n \n\n'); + } + + if (!hasBodyEnd) { + result = result.replace('', '\n\n'); + } + + result = '\n' + result; + + return result; + } + + + function run() { + if (isJS) { + runJS(); + } else { + runHTML(); + } + isFirstRun = false; + } + + +} + + +function addBlockHighlight(pre, lines) { + + if (!lines) { + return; + } + + var ranges = lines.replace(/\s+/g, '').split(','); + + /*jshint -W084 */ + for (var i = 0, range; range = ranges[i++];) { + range = range.split('-'); + + var start = +range[0], + end = +range[1] || start; + + + var mask = '' + + new Array(start + 1).join('\n') + + '' + new Array(end - start + 2).join('\n') + ''; + + pre.insertAdjacentHTML("afterBegin", mask); + } + +} + + +function addInlineHighlight(pre, ranges) { + + // select code with the language text, not block-highlighter + var codeElem = pre.querySelector('code[class*="language-"]'); + + ranges = ranges ? ranges.split(",") : []; + + for (var i = 0; i < ranges.length; i++) { + var piece = ranges[i].split(':'); + var lineNum = +piece[0], strRange = piece[1].split('-'); + var start = +strRange[0], end = +strRange[1]; + var mask = '' + + new Array(lineNum + 1).join('\n') + + new Array(start + 1).join(' ') + + '' + new Array(end - start + 1).join(' ') + ''; + + codeElem.insertAdjacentHTML("afterBegin", mask); + } +} + + +module.exports = CodeBox; diff --git a/client/prism/codeTabsBox.js b/client/prism/codeTabsBox.js new file mode 100755 index 000000000..cd561eb78 --- /dev/null +++ b/client/prism/codeTabsBox.js @@ -0,0 +1,97 @@ +var delegate = require('client/delegate'); +var addLineNumbers = require('./addLineNumbers'); + +function CodeTabsBox(elem) { + if (window.ebookType) { + return; + } + + this.elem = elem; + this.translateX = 0; + + this.switchesElem = elem.querySelector('[data-code-tabs-switches]'); + this.switchesElemItems = this.switchesElem.firstElementChild; + this.arrowLeft = elem.querySelector('[data-code-tabs-left]'); + this.arrowRight = elem.querySelector('[data-code-tabs-right]'); + + + this.arrowLeft.onclick = function(e) { + e.preventDefault(); + + this.translateX = Math.max(0, this.translateX - this.switchesElem.offsetWidth); + this.renderTranslate(); + }.bind(this); + + + this.arrowRight.onclick = function(e) { + e.preventDefault(); + + this.translateX = Math.min(this.translateX +this.switchesElem.offsetWidth, this.switchesElemItems.offsetWidth - this.switchesElem.offsetWidth); + this.renderTranslate(); + }.bind(this); + + this.delegate('.code-tabs__switch', 'click', this.onSwitchClick); +} + +CodeTabsBox.prototype.onSwitchClick = function(e) { + e.preventDefault(); + + var siblings = e.delegateTarget.parentNode.children; + var tabs = this.elem.querySelector('[data-code-tabs-content]').children; + + + var selectedIndex; + for(var i=0; i start/stop() +// 2) new Spinner() -> somewhere.append(spinner.elem) -> start/stop +function Spinner(options) { + options = options || {}; + this.elem = options.elem; + + this.size = options.size || 'medium'; + // any class to add to spinner (make spinner special here) + this.class = options.class ? (' ' + options.class) : ''; + + // any class to add to element (to hide it's content for instance) + this.elemClass = options.elemClass; + + if (this.size != 'medium' && this.size != 'small' && this.size != 'large') { + throw new Error("Unsupported size: " + this.size); + } + + if (!this.elem) { + this.elem = document.createElement('div'); + } +} + +Spinner.prototype.start = function() { + if (this.elemClass) { + this.elem.classList.toggle(this.elemClass); + } + + this.elem.insertAdjacentHTML('beforeend', ''); +}; + +Spinner.prototype.stop = function() { + var spinnerElem = this.elem.querySelector('.spinner'); + if (!spinnerElem) return; // already stopped or never started + + spinnerElem.remove(); + + if (this.elemClass) { + this.elem.classList.toggle(this.elemClass); + } +}; + +module.exports = Spinner; diff --git a/client/trackSticky.js b/client/trackSticky.js new file mode 100755 index 000000000..18a8179d6 --- /dev/null +++ b/client/trackSticky.js @@ -0,0 +1,67 @@ +module.exports = trackSticky; + + +function trackSticky() { + + var stickyElems = document.querySelectorAll('[data-sticky]'); + + for (var i = 0; i < stickyElems.length; i++) { + var stickyElem = stickyElems[i]; + var container = stickyElem.dataset.sticky ? + document.querySelector(stickyElem.dataset.sticky) : document.body; + + if (stickyElem.getBoundingClientRect().top < 0) { + // become fixed + if (stickyElem.style.cssText) { + // already fixed + // inertia: happens when scrolled fast too much to bottom + // http://ilyakantor.ru/screen/2015-02-24_1555.swf + return; + } + + var savedLeft = stickyElem.getBoundingClientRect().left; + var placeholder = createPlaceholder(stickyElem); + + stickyElem.parentNode.insertBefore(placeholder, stickyElem); + + container.appendChild(stickyElem); + stickyElem.classList.add('sticky'); + stickyElem.style.position = 'fixed'; + stickyElem.style.top = 0; + stickyElem.style.left = savedLeft + 'px'; + // zIndex < 1000, because it must be under an overlay, + // e.g. sitemap must show over the progress bar + stickyElem.style.zIndex = 101; + stickyElem.style.background = 'white'; // non-transparent to cover the text + stickyElem.style.margin = 0; + stickyElem.style.width = placeholder.offsetWidth + 'px'; // keep same width as before + stickyElem.placeholder = placeholder; + } else if (stickyElem.placeholder && stickyElem.placeholder.getBoundingClientRect().top > 0) { + // become non-fixed + stickyElem.style.cssText = ''; + stickyElem.classList.remove('sticky'); + stickyElem.placeholder.parentNode.insertBefore(stickyElem, stickyElem.placeholder); + stickyElem.placeholder.remove(); + + stickyElem.placeholder = null; + } + } + +} + +/** + * Creates a placeholder w/ same size & margin + * @param elem + * @returns {*|!HTMLElement} + */ +function createPlaceholder(elem) { + var placeholder = document.createElement('div'); + var style = getComputedStyle(elem); + placeholder.style.width = elem.offsetWidth + 'px'; + placeholder.style.marginLeft = style.marginLeft; + placeholder.style.marginRight = style.marginRight; + placeholder.style.height = elem.offsetHeight + 'px'; + placeholder.style.marginBottom = style.marginBottom; + placeholder.style.marginTop = style.marginTop; + return placeholder; +} \ No newline at end of file diff --git a/client/trackjs.js b/client/trackjs.js new file mode 100755 index 000000000..8d4c3f25b --- /dev/null +++ b/client/trackjs.js @@ -0,0 +1,43 @@ + +window._trackJs = { token: '8d286dd1cbf744b987a7226ee9a09324' }; +// COPYRIGHT (c) 2015 TrackJS LLC ALL RIGHTS RESERVED +(function(h,p,k){"use awesome";if(h.trackJs)h.console&&h.console.warn&&h.console.warn("TrackJS global conflict");else{var l=function(a,b,c,d,e){this.util=a;this.onError=b;this.onFault=c;this.options=e;e.enabled&&this.initialize(d)};l.prototype={initialize:function(a){a.addEventListener&&(this.wrapAndCatch(a.Element.prototype,"addEventListener",1),this.wrapAndCatch(a.XMLHttpRequest.prototype,"addEventListener",1),this.wrapRemoveEventListener(a.Element.prototype),this.wrapRemoveEventListener(a.XMLHttpRequest.prototype)); + this.wrapAndCatch(a,"setTimeout",0);this.wrapAndCatch(a,"setInterval",0)},wrapAndCatch:function(a,b,c){var d=this,e=a[b];d.util.hasFunction(e,"apply")&&(a[b]=function(){try{var f=Array.prototype.slice.call(arguments),g=f[c],u,h;if(d.options.bindStack)try{throw Error();}catch(k){h=k.stack,u=d.util.isoNow()}if("addEventListener"===b&&(this._trackJsEvt||(this._trackJsEvt=new m),this._trackJsEvt.getWrapped(f[0],g,f[2])))return;g&&d.util.hasFunction(g,"apply")&&(f[c]=function(){try{return g.apply(this, + arguments)}catch(a){throw d.onError("catch",a,{bindTime:u,bindStack:h}),d.util.wrapError(a);}},"addEventListener"===b&&this._trackJsEvt.add(f[0],g,f[2],f[c]));return e.apply(this,f)}catch(l){a[b]=e,d.onFault(l)}})},wrapRemoveEventListener:function(a){if(a&&a.removeEventListener&&this.util.hasFunction(a.removeEventListener,"call")){var b=a.removeEventListener;a.removeEventListener=function(a,d,e){if(this._trackJsEvt){var f=this._trackJsEvt.getWrapped(a,d,e);f&&this._trackJsEvt.remove(a,d,e);return b.call(this, + a,f,e)}return b.call(this,a,d,e)}}}};var m=function(){this.events=[]};m.prototype={add:function(a,b,c,d){-1>=this.indexOf(a,b,c)&&this.events.push([a,b,!!c,d])},remove:function(a,b,c){a=this.indexOf(a,b,!!c);0<=a&&this.events.splice(a,1)},getWrapped:function(a,b,c){a=this.indexOf(a,b,!!c);return 0<=a?this.events[a][3]:k},indexOf:function(a,b,c){for(var d=0;dthis.maxLength&&(this.appender=this.appender.slice(Math.max(this.appender.length-this.maxLength,0)))},add:function(a,b){var c=this.util.uuid();this.appender.push({key:c,category:a,value:b});this.truncate();return c},get:function(a,b){var c,d;for(d=0;db.indexOf("localhost:0")&&(this._trackJs={method:a,url:b});return c.apply(this,arguments)};a.prototype.send=function(){try{if(!this._trackJs)return d.apply(this,arguments);this._trackJs.logId=b.log.add("n",{startedOn:b.util.isoNow(),method:this._trackJs.method,url:this._trackJs.url});b.listenForNetworkComplete(this)}catch(a){b.onFault(a)}return d.apply(this,arguments)};return a},listenForNetworkComplete:function(a){var b=this; + b.window.ProgressEvent&&a.addEventListener&&a.addEventListener("readystatechange",function(){4===a.readyState&&b.finalizeNetworkEvent(a)},!0);a.addEventListener?a.addEventListener("load",function(){b.finalizeNetworkEvent(a);b.checkNetworkFault(a)},!0):setTimeout(function(){try{var c=a.onload;a.onload=function(){b.finalizeNetworkEvent(a);b.checkNetworkFault(a);"function"===typeof c&&b.util.hasFunction(c,"apply")&&c.apply(a,arguments)};var d=a.onerror;a.onerror=function(){b.finalizeNetworkEvent(a); + b.checkNetworkFault(a);"function"===typeof oldOnError&&d.apply(a,arguments)}}catch(e){b.onFault(e)}},0)},finalizeNetworkEvent:function(a){if(a._trackJs){var b=this.log.get("n",a._trackJs.logId);b&&(b.completedOn=this.util.isoNow(),b.statusCode=1223==a.status?204:a.status,b.statusText=1223==a.status?"No Content":a.statusText)}},checkNetworkFault:function(a){if(this.options.error&&400<=a.status&&1223!=a.status){var b=a._trackJs||{};this.onError("ajax",a.status+" "+a.statusText+": "+b.method+" "+b.url)}}, + report:function(){return this.log.all("n")}};var n=function(a){this.util=a;this.disabled=!1;this.throttleStats={attemptCount:0,throttledCount:0,lastAttempt:(new Date).getTime()};h.JSON&&h.JSON.stringify||(this.disabled=!0)};n.prototype={errorEndpoint:function(a,b){b=(b||"https://capture.trackjs.com/capture")+("?token="+a);return this.util.isBrowserIE()?"//"+b.split("://")[1]:b},usageEndpoint:function(a){return this.appendObjectAsQuery(a,"https://usage.trackjs.com/usage.gif")},trackerFaultEndpoint:function(a){return this.appendObjectAsQuery(a, + "https://usage.trackjs.com/fault.gif")},appendObjectAsQuery:function(a,b){b+="?";for(var c in a)a.hasOwnProperty(c)&&(b+=encodeURIComponent(c)+"="+encodeURIComponent(a[c])+"&");return b},getCORSRequest:function(a,b){var c=new h.XMLHttpRequest;"withCredentials"in c?(c.open(a,b),c.setRequestHeader("Content-Type","text/plain")):"undefined"!==typeof h.XDomainRequest?(c=new h.XDomainRequest,c.open(a,b)):c=null;return c},sendTrackerFault:function(a){this.throttle(a)||((new Image).src=this.trackerFaultEndpoint(a))}, + sendUsage:function(a){(new Image).src=this.usageEndpoint(a)},sendError:function(a,b){var c=this;if(!this.disabled&&!this.throttle(a))try{var d=this.getCORSRequest("POST",this.errorEndpoint(b));d.onreadystatechange=function(){4===d.readyState&&200!==d.status&&(c.disabled=!0)};d._trackJs=k;d.send(h.JSON.stringify(a))}catch(e){throw this.disabled=!0,e;}},throttle:function(a){var b=(new Date).getTime();this.throttleStats.attemptCount++;if(this.throttleStats.lastAttempt+1E3>=b){if(this.throttleStats.lastAttempt= + b,10 unless options.noGlobalEvents is set +// +// # Events +// triggers fail/success on load end: +// --> by default status=200 is ok, the others are failures +// --> options.normalStatuses = [201,409] allow given statuses +// --> fail event has .reason field +// --> success event has .result field +// +// # JSON +// --> send(object) calls JSON.stringify +// --> adds Accept: json (we want json) by default, unless options.raw +// if options.json or server returned json content type +// --> autoparse json +// --> fail if error +// +// # CSRF +// --> requests sends header X-XSRF-TOKEN from cookie + +function xhr(options) { + + var request = new XMLHttpRequest(); + + var method = options.method || 'GET'; + + var body = options.body; + var url = options.url; + + request.open(method, url, options.sync ? false : true); + + request.method = method; + + // token/header names same as angular $http for easier interop + var csrfCookie = getCsrfCookie(); + if (csrfCookie && !options.skipCsrf) { + request.setRequestHeader("X-XSRF-TOKEN", csrfCookie); + } + + if ({}.toString.call(body) == '[object Object]') { + // must be OPENed to setRequestHeader + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + body = JSON.stringify(body); + } + + if (!options.noDocumentEvents) { + request.addEventListener('loadstart', event => { + request.timeStart = Date.now(); + var e = wrapEvent('xhrstart', event); + document.dispatchEvent(e); + }); + request.addEventListener('loadend', event => { + var e = wrapEvent('xhrend', event); + document.dispatchEvent(e); + }); + request.addEventListener('success', event => { + var e = wrapEvent('xhrsuccess', event); + e.result = event.result; + document.dispatchEvent(e); + }); + request.addEventListener('fail', event => { + var e = wrapEvent('xhrfail', event); + e.reason = event.reason; + document.dispatchEvent(e); + }); + } + + if (!options.raw) { // means we want json + request.setRequestHeader("Accept", "application/json"); + } + + request.setRequestHeader('X-Requested-With', "XMLHttpRequest"); + + var normalStatuses = options.normalStatuses || [200]; + + function wrapEvent(name, e) { + var event = new CustomEvent(name); + event.originalEvent = e; + return event; + } + + function fail(reason, originalEvent) { + var e = wrapEvent("fail", originalEvent); + e.reason = reason; + request.dispatchEvent(e); + } + + function success(result, originalEvent) { + var e = wrapEvent("success", originalEvent); + e.result = result; + request.dispatchEvent(e); + } + + request.addEventListener("error", e => { + fail("Ошибка связи с сервером.", e); + }); + + request.addEventListener("timeout", e => { + fail("Превышено максимально допустимое время ожидания ответа от сервера.", e); + }); + + request.addEventListener("abort", e => { + fail("Запрос был прерван.", e); + }); + + request.addEventListener("load", e => { + if (!request.status) { // does that ever happen? + fail("Не получен ответ от сервера.", e); + return; + } + + if (normalStatuses.indexOf(request.status) == -1) { + fail("Ошибка на стороне сервера (код " + request.status + "), попытайтесь позднее.", e); + return; + } + + var result = request.responseText; + var contentType = request.getResponseHeader("Content-Type"); + if (contentType.match(/^application\/json/) || options.json) { // autoparse json if WANT or RECEIVED json + try { + result = JSON.parse(result); + } catch (e) { + fail("Некорректный формат ответа от сервера.", e); + return; + } + } + + success(result, e); + }); + + // defer to let other handlers be assigned + setTimeout(function() { + request.send(body); + }, 0); + + return request; + +} + + +document.addEventListener('xhrfail', function(event) { + new notification.Error(event.reason); +}); + + +module.exports = xhr; diff --git a/config/base.js b/config/base.js deleted file mode 100755 index 5e59fe976..000000000 --- a/config/base.js +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = function() { - return { - "port": process.env.PORT || 3000, - "host": process.env.HOST || '0.0.0.0', - "mongoose": { - "uri": "mongodb://localhost/javascript", - "options": { - "server": { - "socketOptions": { - "keepAlive": 1 - }, - "poolSize": 5 - } - } - }, - "session": { - "keys": ["KillerIsJim"] - }, - template: { - path: process.cwd() + '/views', - options: { - 'default': 'jade', - 'cache': true - } - } - } -}; \ No newline at end of file diff --git a/config/env/development.js b/config/env/development.js deleted file mode 100755 index 188b92380..000000000 --- a/config/env/development.js +++ /dev/null @@ -1,5 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return config; -}; \ No newline at end of file diff --git a/config/env/production.js b/config/env/production.js deleted file mode 100755 index 188b92380..000000000 --- a/config/env/production.js +++ /dev/null @@ -1,5 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return config; -}; \ No newline at end of file diff --git a/config/env/test.js b/config/env/test.js deleted file mode 100755 index 10dfe4268..000000000 --- a/config/env/test.js +++ /dev/null @@ -1,9 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return _.merge(config, { - "mongoose": { - "uri": "mongodb://localhost/javascript_test" - } - }); -}; diff --git a/config/index.js b/config/index.js deleted file mode 100755 index defd7abe4..000000000 --- a/config/index.js +++ /dev/null @@ -1,10 +0,0 @@ - -if (!process.env.NODE_ENV) { - throw new Error("NODE_ENV environment variable is required"); -} - - -var base = require('./base')(); -var env = require('./env/' + process.env.NODE_ENV)(base); - -module.exports = env; diff --git a/controllers/frontpage.js b/controllers/frontpage.js deleted file mode 100644 index 8782b0332..000000000 --- a/controllers/frontpage.js +++ /dev/null @@ -1,7 +0,0 @@ - -exports.get = function *get (next) { - yield this.render('index', { - title: 'Hello, world' - }); -}; - diff --git a/course b/course new file mode 120000 index 000000000..5a0d9288a --- /dev/null +++ b/course @@ -0,0 +1 @@ +/js/course \ No newline at end of file diff --git a/docs/fontello.zip b/docs/fontello.zip new file mode 100644 index 000000000..fd041490f Binary files /dev/null and b/docs/fontello.zip differ diff --git a/docs/logo/logo.171x60.2x.png b/docs/logo/logo.171x60.2x.png new file mode 100644 index 000000000..b67ea9df8 Binary files /dev/null and b/docs/logo/logo.171x60.2x.png differ diff --git a/docs/logo/logo.171x60.png b/docs/logo/logo.171x60.png new file mode 100644 index 000000000..4fb9ddf49 Binary files /dev/null and b/docs/logo/logo.171x60.png differ diff --git a/docs/logo/logo.gotowebinar.400x200.gif b/docs/logo/logo.gotowebinar.400x200.gif new file mode 100755 index 000000000..8d1246dc4 Binary files /dev/null and b/docs/logo/logo.gotowebinar.400x200.gif differ diff --git a/docs/logo/logo_big.png b/docs/logo/logo_big.png new file mode 100755 index 000000000..569314a0f Binary files /dev/null and b/docs/logo/logo_big.png differ diff --git a/docs/logo/logo_big_square.png b/docs/logo/logo_big_square.png new file mode 100755 index 000000000..3e40cb367 Binary files /dev/null and b/docs/logo/logo_big_square.png differ diff --git a/docs/logo/logo_interkassa.png b/docs/logo/logo_interkassa.png new file mode 100755 index 000000000..6dd717aac Binary files /dev/null and b/docs/logo/logo_interkassa.png differ diff --git a/docs/logo/logo_paypal_90x250.png b/docs/logo/logo_paypal_90x250.png new file mode 100755 index 000000000..e3c4366be Binary files /dev/null and b/docs/logo/logo_paypal_90x250.png differ diff --git a/docs/logo/logo_vk_150x150.png b/docs/logo/logo_vk_150x150.png new file mode 100755 index 000000000..4fc94534f Binary files /dev/null and b/docs/logo/logo_vk_150x150.png differ diff --git a/docs/logo/logo_vk_16x16.png b/docs/logo/logo_vk_16x16.png new file mode 100755 index 000000000..c9713943d Binary files /dev/null and b/docs/logo/logo_vk_16x16.png differ diff --git a/docs/payment.sketch b/docs/payment.sketch new file mode 100755 index 000000000..91fdb89e4 Binary files /dev/null and b/docs/payment.sketch differ diff --git a/docs/sitetoolbar__logo.nominify.svg b/docs/sitetoolbar__logo.nominify.svg new file mode 100755 index 000000000..1601d742c --- /dev/null +++ b/docs/sitetoolbar__logo.nominify.svg @@ -0,0 +1,87 @@ + + + + + + + + + diff --git a/docs/sitetoolbar__logo_small.nominify.svg b/docs/sitetoolbar__logo_small.nominify.svg new file mode 100755 index 000000000..252d811b3 --- /dev/null +++ b/docs/sitetoolbar__logo_small.nominify.svg @@ -0,0 +1,67 @@ + + + + + + + + + + diff --git a/docs/sitetoolbar_logo.en.nominify.svg b/docs/sitetoolbar_logo.en.nominify.svg new file mode 100644 index 000000000..19e42238f --- /dev/null +++ b/docs/sitetoolbar_logo.en.nominify.svg @@ -0,0 +1,82 @@ + + + + + + + + + + diff --git a/download b/download new file mode 120000 index 000000000..4d21a1181 --- /dev/null +++ b/download @@ -0,0 +1 @@ +/js/download \ No newline at end of file diff --git a/ecosystem.json b/ecosystem.json new file mode 100755 index 000000000..ee4b5b188 --- /dev/null +++ b/ecosystem.json @@ -0,0 +1,33 @@ +{ + "apps": [ + { + "name": "javascript", + "script": "bin/server", + "instances": "2", + "node_args": "", + "exec_mode": "cluster_mode", + "max_memory_restart": "2G", + "log_file": "/var/log/node/javascript.log", + "error_file": "/var/log/node/javascript-err.log", + "out_file": "/var/log/node/javascript-out.log", + "env": { + "HOST": "127.0.0.1", + "PORT": "3000", + "PM2_GRACEFUL_LISTEN_TIMEOUT": 1000, + "PM2_GRACEFUL_TIMEOUT": 5000, + "ASSET_VERSIONING": "file", + "NODE_ENV": "production" + }, + "env_nightly": { + "NODE_LANG": "ru", + "SITE_HOST": "https://learn.javascript.ru", + "STATIC_HOST": "https://ru.js.cx" + }, + "env_en": { + "NODE_LANG": "en", + "SITE_HOST": "https://javascript.info", + "STATIC_HOST": "https://en.js.cx" + } + } + ] +} diff --git a/edit b/edit new file mode 100755 index 000000000..e93f88173 --- /dev/null +++ b/edit @@ -0,0 +1,4 @@ +#!/bin/bash + +TUTORIAL_ROOT=/js/javascript-tutorial gulp --harmony_classes edit + diff --git a/en b/en new file mode 100644 index 000000000..74e29e955 --- /dev/null +++ b/en @@ -0,0 +1,6 @@ +#!/bin/bash + +#export SITE_HOST=http://javascript.in +#export STATIC_HOST=http://javascript.in + +ASSET_VERSIONING=query NODE_LANG=en NODE_ENV=development WATCH=1 gulp dev | bunyan -o short -l debug diff --git a/error/httpError.js b/error/httpError.js deleted file mode 100755 index cd5a3dd78..000000000 --- a/error/httpError.js +++ /dev/null @@ -1,19 +0,0 @@ -var util = require('util'); -var http = require('http'); - -// ошибки для выдачи посетителю -function HttpError(status, message) { - Error.apply(this, arguments); - Error.captureStackTrace(this, HttpError); - - this.status = status; - this.message = message || http.STATUS_CODES[status] || "Error"; -} - -util.inherits(HttpError, Error); -module.exports = HttpError; - -HttpError.prototype.name = 'HttpError'; - - - diff --git a/error/index.js b/error/index.js deleted file mode 100755 index 3522cd29c..000000000 --- a/error/index.js +++ /dev/null @@ -1,9 +0,0 @@ -var fs = require('fs'); -var path = require('path'); - -var files = fs.readdirSync(__dirname); -files.forEach(function(file) { - if (file == 'index.js') return; - var errorClass = require(path.join(__dirname, file)); - module.exports[errorClass.prototype.name] = errorClass; -}); diff --git a/files/Readme.md b/files/Readme.md new file mode 100755 index 000000000..484c968f8 --- /dev/null +++ b/files/Readme.md @@ -0,0 +1,3 @@ +Restricted files, not directly accessible from outside. + +A user can download these using ExpiringDownloadLink or by other non-direct means. diff --git a/fixture/init/course.js b/fixture/init/course.js new file mode 100644 index 000000000..de6ab521f --- /dev/null +++ b/fixture/init/course.js @@ -0,0 +1,78 @@ +const mongoose = require('mongoose'); + +var Course = require('courses').Course; +var CourseGroup = require('courses').CourseGroup; +var CourseInvite = require('courses').CourseInvite; +var oid = require('oid'); + +exports.Course = [ + { + "_id": oid('course-js'), + slug: "js", + videoKeyTag: "js", + title: "Курс JavaScript/DOM/интерфейсы", + shortDescription: ` +

"Правильный" курс по профессиональному JavaScript, цель которого – научить думать на JavaScript, писать просто, быстро и красиво.

+

Стоимость обучения 21000 руб, время обучения: 2 месяца.

`, + isListed: true, + weight: 1 + }, + { + "_id": oid('course-nodejs'), + slug: "nodejs", + videoKeyTag: "js", + title: "Курс по Node.JS", + shortDescription: ` +

Профессиональная разработка на платформе Node.JS/IO.JS (серверный JavaScript), с использованием современных фреймворков и технологий.

+

Стоимость обучения 13500 руб, время обучения: 1 месяц.

`, + isListed: true, + weight: 2 + } +]; + +exports.CourseInvite = []; +exports.CourseParticipant = []; +exports.CourseFeedback = []; + +exports.CourseGroup = [ + { + course: oid('course-js'), + dateStart: new Date(2016, 0, 1), + dateEnd: new Date(2016, 10, 10), + timeDesc: "пн/чт 19:30 - 21:00 GMT+3", + slug: 'js-1', + price: 1, + participantsLimit: 30, + webinarId: '123', + isListed: true, + isOpenForSignup: false, + title: "Курс JavaScript/DOM/интерфейсы (01.01)" + }, + { + course: oid('course-nodejs'), + dateStart: new Date(2015, 6, 22), + dateEnd: new Date(2015, 7, 10), + timeDesc: "пн/ср/сб 19:30 - 21:00 GMT+3", + slug: 'nodejs-20160722', + price: 1, + webinarId: '456', + participantsLimit: 30, + isListed: true, + isOpenForSignup: true, + title: "Курс по Node.JS (22.07)" + }, + { + course: oid('course-nodejs'), + dateStart: new Date(2016, 6, 1), + dateEnd: new Date(2016, 11, 10), + timeDesc: "пн/чт 21:30 - 23:00 GMT+3", + slug: "nodejs-01", + price: 1, + webinarId: '789', + participantsLimit: 30, + isListed: true, + isOpenForSignup: false, + title: "Курс по Node.JS" + } +]; + diff --git a/fixture/init/index.js b/fixture/init/index.js new file mode 100755 index 000000000..0739b813b --- /dev/null +++ b/fixture/init/index.js @@ -0,0 +1,10 @@ +const _ = require('lodash'); + +module.exports = _.merge( + require('./user'), + require('./newsletter'), + require('./payments'), + require('./course'), + require('./migrationState'), + require('./videoKey') +); diff --git a/fixture/init/migrationState.js b/fixture/init/migrationState.js new file mode 100644 index 000000000..fbfd6b8cf --- /dev/null +++ b/fixture/init/migrationState.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); +const glob = require('glob'); +const migrationsRoot = require('config').migrationsRoot; +const MigrationState = require('migrate').MigrationState; +const path = require('path'); + +/** + * Set current migration state to latest + */ +var migrationFiles = glob.sync(path.join(migrationsRoot, '*')).filter(function(migrationFile) { + return parseInt(path.basename(migrationFile)); // only files like 20150505... +}); + + +exports.MigrationState = []; + +if (migrationFiles.length) { + exports.MigrationState.push({ + currentMigration: parseInt(path.basename(migrationFiles.pop())) + }); +} + + + diff --git a/fixture/init/newsletter.js b/fixture/init/newsletter.js new file mode 100755 index 000000000..88363097e --- /dev/null +++ b/fixture/init/newsletter.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose'); + +var Newsletter = require('newsletter').Newsletter; +var Subscription = require('newsletter').Subscription; + +exports.Subscription = []; + +exports.Newsletter = [ + { + title: "Курс и скринкасты по Node.JS / IO.JS", + slug: "nodejs", + period: "несколько раз в год", + weight: 1 + }, + { + title: "Курс JavaScript/DOM/интерфейсы", + period: "раз в 1.5-2 месяца", + weight: 0, + slug: "js" + }, + { + title: "Продвинутые курсы, мастер-классы и конференции по JavaScript", + period: "редко", + weight: 2, + slug: "advanced" + } +]; + diff --git a/fixture/init/payments.js b/fixture/init/payments.js new file mode 100644 index 000000000..8b9b2de75 --- /dev/null +++ b/fixture/init/payments.js @@ -0,0 +1,48 @@ +const mongoose = require('mongoose'); +require('payments'); + +exports.Transaction = []; +exports.TransactionLog = []; +exports.Discount = []; +exports.Order = []; + +exports.OrderTemplate = [ + { + title: "Язык JavaScript", + description: "600+ стр, PDF + EPUB (10Mb)", + slug: "js", + module: 'ebook', + weight: 1, + amount: 2, + data: { + file: "tutorial/js.zip" + } + }, + { + title: "Документ, события, интерфейсы", + description: "380+ стр, PDF + EPUB (8Mb)", + slug: "ui", + module: 'ebook', + weight: 2, + amount: 2, + data: { + file: "tutorial/ui.zip" + } + }, + { + title: "Две книги сразу", + description: "2xPDF + 2xEPUB, (18Mb)", + slug: "js-ui", + module: 'ebook', + weight: 3, + amount: 2, + data: { + file: "tutorial/js-ui.zip" + } + }, + { + module: 'courses', + slug: 'course' + } +]; + diff --git a/fixture/init/user.js b/fixture/init/user.js new file mode 100755 index 000000000..2c699e3f9 --- /dev/null +++ b/fixture/init/user.js @@ -0,0 +1,18 @@ +const mongoose = require('mongoose'); + +var User = require('users').User; + +exports.User = [{ + email: "mk@javascript.ru", + displayName: "Tester", + profileName: 'tester', + password: "123456", + verifiedEmail: true +}, { + email: "iliakan@javascript.ru", + displayName: "Ilya Kantor", + profileName: 'iliakan', + password: "123456", + verifiedEmail: true +}]; + diff --git a/fixture/init/videoKey.js b/fixture/init/videoKey.js new file mode 100644 index 000000000..8b0b849e4 --- /dev/null +++ b/fixture/init/videoKey.js @@ -0,0 +1,39 @@ +const mongoose = require('mongoose'); + +var VideoKey = require('videoKey').VideoKey; + +exports.VideoKey = [ + { + key: 'J1', + tag: 'js' + }, + { + key: 'J2', + tag: 'js' + }, + { + key: 'J3', + tag: 'js' + }, + { + key: 'J4', + tag: 'js' + }, + { + key: 'N1', + tag: 'node' + }, + { + key: 'N2', + tag: 'node' + }, + { + key: 'N3', + tag: 'node' + }, + { + key: 'N4', + tag: 'node' + } + +]; diff --git a/fixture/openCourse.js b/fixture/openCourse.js new file mode 100644 index 000000000..81c8f18bf --- /dev/null +++ b/fixture/openCourse.js @@ -0,0 +1,45 @@ +const mongoose = require('mongoose'); + +require('courses').CourseGroup; + +exports.CourseGroup = [ + { + course: '5569b7fc097bf243c1d54e5b', + dateStart: new Date(2015, 5, 18), + dateEnd: new Date(2015, 7, 18), + timeDesc: "пн/чт 21:30 - 23:00 GMT+3", + slug: 'js-20150618', + price: 21000, + participantsLimit: 50, + webinarId: '145468211', + isListed: true, + isOpenForSignup: true, + title: "Курс JavaScript/DOM/интерфейсы (18.06)" + }, + { + course: '556deb46a8bc324096206ff1', + dateStart: new Date(2015, 5, 16), + dateEnd: new Date(2015, 6, 14), + timeDesc: "вт/пт 21:30 - 23:00 GMT+3", + slug: 'nodejs-20150616', + price: 13500, + participantsLimit: 30, + webinarId: '150179171', + isListed: true, + isOpenForSignup: true, + title: "Курс Node.JS (16.06)" + }, + { + course: '556deb46a8bc324096206ff1', + dateStart: new Date(2015, 5, 30), + dateEnd: new Date(2015, 6, 28), + timeDesc: "вт/пт 19:30 - 21:00 GMT+3", + slug: 'nodejs-20150630', + price: 13500, + participantsLimit: 30, + webinarId: '149647499', + isListed: true, + isOpenForSignup: true, + title: "Курс Node.JS (30.06)" + } +]; diff --git a/fixture/tutorial.js b/fixture/tutorial.js new file mode 100755 index 000000000..6085bd5cc --- /dev/null +++ b/fixture/tutorial.js @@ -0,0 +1,78 @@ +require('users').User; +require('tutorial').Article; + +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000002", + "created": new Date(2014,0,1), + "displayName": "tester", + "email": "tester@mail.com", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000003", + "created": new Date(2014,0,1), + "displayName": "vasya", + "email": "vasya@mail.com", + "password": "1234", + "verifiedEmail": false + } +]; + + +exports.Article = [ + { + "_id": '000000000000000000000010', + "isFolder" : true, + "content" : "# JS", + "weight" : 1, + "slug" : "js", + "title" : "JavaScript" + }, + { + "_id": '000000000000000000000011', + "isFolder" : true, + "content" : "# UI", + "weight" : 2, + "slug" : "ui", + "title" : "Интерфейсы" + }, + { + "_id": '000000000000000000000012', + "isFolder" : true, + "content" : "# UI", + "weight" : 3, + "slug" : "more", + "title" : "Дополнительно" + }, + { + "parent" : '000000000000000000000010', + "_id": '000000000000000000000013', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 1, + "slug" : "article", + "title" : "Введение в JavaScript" + }, + { + "parent" : '000000000000000000000011', + "_id": '000000000000000000000014', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 2, + "slug" : "article2", + "title" : "Введение в JavaScript" + }, + { + "parent" : '000000000000000000000012', + "_id": '000000000000000000000015', + "content" : "# И ещё про JavaScript\n\n ...", "isFolder" : false, + "weight" : 3, + "slug" : "article3", + "title" : "И ещё про JavaScript" + } +]; diff --git a/gulpfile.js b/gulpfile.js new file mode 100755 index 000000000..c8e8245e6 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,217 @@ +/** + * NB: All tasks are initialized lazily, even plugins are required lazily, + * running 1 task does not require all tasks' files + */ + +const gulp = require('gulp'); +const path = require('path'); +const fs = require('fs'); +const assert = require('assert'); +const runSequence = require('run-sequence'); + +const linkModules = require('./modules/linkModules'); + +linkModules({ + src: ['client', 'styles', 'modules/*', 'handlers/*', 'extra/handlers/*'] +}); + +require('cls'); // init CLS namespace once + +const config = require('config'); +const mongoose = require('lib/mongoose'); + +process.on('uncaughtException', function(err) { + console.error(err.message, err.stack, err.errors); + process.exit(255); +}); + +const jsSources = [ + 'handlers/**/*.js', 'modules/**/*.js', 'tasks/**/*.js', '*.js' +]; + +function lazyRequireTask(path) { + var args = [].slice.call(arguments, 1); + return function(callback) { + var task = require(path).apply(this, args); + + return task(callback); + }; +} + +/* the task does nothing, used to run linkModules only */ +gulp.task('init'); + +gulp.task('lint-once', lazyRequireTask('./tasks/lint', { src: jsSources })); +gulp.task('lint-or-die', lazyRequireTask('./tasks/lint', { src: jsSources, dieOnError: true })); + +// usage: gulp db:load --from fixture/init --harmony +gulp.task('db:load', lazyRequireTask('./tasks/dbLoad')); +gulp.task('db:clear', lazyRequireTask('./tasks/dbClear')); +gulp.task('migrate:play', lazyRequireTask('./tasks/migratePlay')); + +gulp.task('migrate:up', lazyRequireTask('migrate/tasks/up')); +gulp.task('migrate:down', lazyRequireTask('migrate/tasks/down')); +gulp.task('migrate:create', lazyRequireTask('migrate/tasks/create')); + +gulp.task('courses:material:add', lazyRequireTask('courses/tasks/materialAdd')); +gulp.task('courses:group:send', lazyRequireTask('courses/tasks/groupSend')); +gulp.task('courses:invite:remind', lazyRequireTask('courses/tasks/inviteRemind')); + +gulp.task("nodemon", lazyRequireTask('./tasks/nodemon', { + // shared client/server code has require('template.jade) which precompiles template on run + // so I have to restart server to pickup the template change + ext: "js,jade", + + nodeArgs: ['--debug', '--harmony_classes'], + script: "./bin/server", + ignore: '**/client/', // ignore handlers' client code + watch: ["handlers", "modules"] +})); + +gulp.task("client:livereload", lazyRequireTask("./tasks/livereload", { + // watch files *.*, not directories, no need to reload for new/removed files, + // we're only interested in changes + + watch: [ + "public/pack/**/*.*", + // not using this file, using only styles.css (extracttextplugin) + "!public/pack/styles.js", + // this file changes every time we update styles + // don't watch it, so that the page won't reload fully on style change + "!public/pack/head.js" + ] +})); + +gulp.task("tutorial:import:watch", lazyRequireTask('tutorial/tasks/importWatch', { + root: process.env.TUTORIAL_ROOT +})); + +gulp.task("tutorial:beautify", lazyRequireTask('tutorial/tasks/beautify', { + root: process.env.TUTORIAL_ROOT +})); + +gulp.task("tutorial:edit", lazyRequireTask('tutorial/tasks/edit')); + +gulp.task("payments:order:paid", lazyRequireTask('payments/tasks/orderPaid')); +gulp.task("payments:transaction:paid", lazyRequireTask('payments/tasks/transactionPaid')); +gulp.task("payments:order:cancelPending", lazyRequireTask('payments/tasks/orderCancelPending')); + +gulp.task("newsletter:send", lazyRequireTask('newsletter/tasks/send')); +gulp.task("newsletter:createLetters", lazyRequireTask('newsletter/tasks/createLetters')); + +var testSrcs = ['{handlers,modules}/**/test/**/*.js']; +// on Travis, keys are required for E2E Selenium tests +// for PRs there are no keys, so we disable E2E +if (!process.env.TEST_E2E || process.env.CI && process.env.TRAVIS_SECURE_ENV_VARS=="false") { + testSrcs.push('!{handlers,modules}/**/test/e2e/*.js'); +} + +gulp.task("test", lazyRequireTask('./tasks/test', { + src: testSrcs, + reporter: 'spec', + timeout: 100000 // big timeout for webdriver e2e tests +})); + + +gulp.task('watch', lazyRequireTask('./tasks/watch', { + root: __dirname, + // for performance, watch only these dirs under root + dirs: ['assets', 'styles'], + taskMapping: [ + { + watch: 'assets/**', + task: 'client:sync-resources' + } + ] +})); + +// init deploy (kill all and recreate) +gulp.task('deploy:init', lazyRequireTask('deploy/tasks/init')); + +// build on remote +gulp.task('deploy:build', lazyRequireTask('deploy/tasks/build')); + +// apply db migrations +gulp.task('deploy:migrate', lazyRequireTask('deploy/tasks/migrate')); + +// update remote working site & repo +gulp.task('deploy:update', lazyRequireTask('deploy/tasks/update')); + +gulp.task('deploy', function(callback) { + runSequence("deploy:build", "deploy:update", callback); +}); + +gulp.task("client:sync-resources", lazyRequireTask('./tasks/syncResources', { + assets: 'public' +})); + +gulp.task("videoKey:load", lazyRequireTask('videoKey/tasks/load')); + +// Show errors if encountered +gulp.task('client:compile-css', + lazyRequireTask('./tasks/compileCss', { + src: './styles/base.styl', + dst: './public/styles', + publicDst: process.env.STATIC_HOST + '/styles/', // from browser point of view + manifest: path.join(config.manifestRoot, 'styles.versions.json'), + assetVersioning: config.assetVersioning + }) +); + + +gulp.task('client:minify', lazyRequireTask('./tasks/minify')); +gulp.task('client:resize-retina-images', lazyRequireTask('./tasks/resizeRetinaImages')); + +gulp.task('client:webpack', lazyRequireTask('./tasks/webpack')); +//gulp.task('client:webpack-dev-server', lazyRequireTask('./tasks/webpackDevServer')); + + +gulp.task('build', function(callback) { + runSequence("client:sync-resources", 'client:webpack', callback); +}); + +gulp.task('server', lazyRequireTask('./tasks/server')); + +gulp.task('edit', ['build', 'tutorial:import:watch', "client:sync-resources", 'client:livereload', 'server']); + + +gulp.task('dev', function(callback) { + runSequence("client:sync-resources", ['nodemon', 'client:livereload', 'client:webpack', 'watch'], callback); +}); + +gulp.task('tutorial:import', ['cache:clean'], lazyRequireTask('tutorial/tasks/tutorialImport')); + +gulp.task('quiz:import', ['cache:clean'], lazyRequireTask('quiz/tasks/quizImport')); + + +gulp.task('tutorial:remote:update', lazyRequireTask('tutorial/tasks/remoteUpdate')); + +gulp.task('figures:import', lazyRequireTask('tutorial/tasks/figuresImport')); + +gulp.task('tutorial:kill:content', ['cache:clean'], lazyRequireTask('tutorial/tasks/killContent')); + +gulp.task('tutorial:cache:regenerate', lazyRequireTask('tutorial/tasks/cacheRegenerate')); + +gulp.task('cloudflare:clean', lazyRequireTask('./tasks/cloudflareClean', { + domains: ['javascript.ru', 'js.cx'] +})); + +gulp.task('cache:clean', lazyRequireTask('./tasks/cacheClean')); + +gulp.task('config:nginx', lazyRequireTask('./tasks/configNginx')); + +// when queue finished successfully or aborted, close db +// orchestrator events (sic!) +gulp.on('stop', function() { + mongoose.disconnect(); +}); + +gulp.on('err', function(gulpErr) { + if (gulpErr.err) { + // cause + console.error("Gulp error details", [gulpErr.err.message, gulpErr.err.stack, gulpErr.err.errors].filter(Boolean)); + } + mongoose.disconnect(); +}); + + diff --git a/handlers/404.js b/handlers/404.js new file mode 100755 index 000000000..4792ca496 --- /dev/null +++ b/handlers/404.js @@ -0,0 +1,15 @@ + +exports.init = function(app) { + // by default if the router didn't find anything => it yields to next middleware + // so I throw error here manually + app.use(function* (next) { + yield* next; + + if (this.status == 404) { + // still nothing found? let default errorHandler show 404 + this.throw(404); + } + }); + + +}; \ No newline at end of file diff --git a/handlers/about/client/citymap.js b/handlers/about/client/citymap.js new file mode 100755 index 000000000..55a0ca9bf --- /dev/null +++ b/handlers/about/client/citymap.js @@ -0,0 +1,173 @@ +// an object containing LatLng and population for each city. +module.exports = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 30000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 20000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 18000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 18000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 18000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 18000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 20000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 18000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 18000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 20000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 18000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + radius: 30000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + radius: 30000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 25000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 22000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 18000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 18000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 18000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 22000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 20000 + } + + +}; + diff --git a/handlers/about/client/index.js b/handlers/about/client/index.js new file mode 100755 index 000000000..1390f315e --- /dev/null +++ b/handlers/about/client/index.js @@ -0,0 +1,188 @@ +require('./styles'); +var citymap = require('./citymap'); + +/* + * L.TileLayer is used for standard xyz-numbered tile layers. + * @see https://gist.github.com/crofty/2197042 + */ +L.Google = L.Class.extend({ + includes: L.Mixin.Events, + + options: { + minZoom: 0, + maxZoom: 18, + tileSize: 256, + subdomains: 'abc', + errorTileUrl: '', + attribution: '', + opacity: 1, + continuousWorld: false, + noWrap: false, + }, + + // Possible types: SATELLITE, ROADMAP, HYBRID + initialize: function(type, options) { + L.Util.setOptions(this, options); + + this._type = google.maps.MapTypeId[type || 'SATELLITE']; + }, + + onAdd: function(map, insertAtTheBottom) { + this._map = map; + this._insertAtTheBottom = insertAtTheBottom; + + // create a container div for tiles + this._initContainer(); + this._initMapObject(); + + // set up events + map.on('viewreset', this._resetCallback, this); + + this._limitedUpdate = L.Util.limitExecByInterval(this._update, 150, this); + map.on('move', this._update, this); + //map.on('moveend', this._update, this); + + this._reset(); + this._update(); + }, + + onRemove: function(map) { + this._map._container.removeChild(this._container); + //this._container = null; + + this._map.off('viewreset', this._resetCallback, this); + + this._map.off('move', this._update, this); + //this._map.off('moveend', this._update, this); + }, + + getAttribution: function() { + return this.options.attribution; + }, + + setOpacity: function(opacity) { + this.options.opacity = opacity; + if (opacity < 1) { + L.DomUtil.setOpacity(this._container, opacity); + } + }, + + _initContainer: function() { + var tilePane = this._map._container + var first = tilePane.firstChild; + + if (!this._container) { + this._container = L.DomUtil.create('div', 'leaflet-google-layer leaflet-top leaflet-left'); + this._container.id = "_GMapContainer"; + } + + tilePane.insertBefore(this._container, first); + + this.setOpacity(this.options.opacity); + var size = this._map.getSize(); + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + }, + + _initMapObject: function() { + this._google_center = new google.maps.LatLng(0, 0); + var map = new google.maps.Map(this._container, { + center: this._google_center, + zoom: 0, + mapTypeId: this._type, + disableDefaultUI: true, + keyboardShortcuts: false, + draggable: false, + disableDoubleClickZoom: true, + scrollwheel: false, + streetViewControl: false, + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + }); + + var _this = this; + this._reposition = google.maps.event.addListenerOnce(map, "center_changed", + function() { _this.onReposition(); }); + + map.backgroundColor = '#ff0000'; + this._google = map; + }, + + _resetCallback: function(e) { + this._reset(e.hard); + }, + + _reset: function(clearOldContainer) { + this._initContainer(); + }, + + _update: function() { + this._resize(); + + var bounds = this._map.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + var center = this._map.getCenter(); + var _center = new google.maps.LatLng(center.lat, center.lng); + + this._google.setCenter(_center); + this._google.setZoom(this._map.getZoom()); + }, + + _resize: function() { + var size = this._map.getSize(); + if (this._container.style.width == size.x && + this._container.style.height == size.y) + return; + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + google.maps.event.trigger(this._google, "resize"); + }, + + onReposition: function() { + //google.maps.event.trigger(this._google, "resize"); + } +}); + + + +// ==================================================== + +function init() { + + var map = new L.Map('map', { + center: new L.LatLng(54.231473, 37.734144), + zoom: 5, + attributionControl: false, + scrollWheelZoom: false, + markerZoomAnimation: false + }); + var googleLayer = new L.Google('TERRAIN'); + map.addLayer(googleLayer); + + // Construct the circle for each value in citymap. + // Note: We scale the area of the circle based on the population. + for (var city in citymap) (function(city) { + var marker = L.circleMarker([citymap[city].location.lat-0.01, citymap[city].location.lng], { + radius: citymap[city].radius / 3000, + stroke: false, + opacity: 1, + fill: true, + clickable: false, + fillColor: '#C13335', + fillOpacity: 1 + }); + map.addLayer(marker); + + }(city)); + +} + +exports.init = init; diff --git a/handlers/about/client/styles/about-banner/header.jpg b/handlers/about/client/styles/about-banner/header.jpg new file mode 100755 index 000000000..e46f79c32 Binary files /dev/null and b/handlers/about/client/styles/about-banner/header.jpg differ diff --git a/handlers/about/client/styles/about-banner/index.styl b/handlers/about/client/styles/about-banner/index.styl new file mode 100755 index 000000000..945a35ab7 --- /dev/null +++ b/handlers/about/client/styles/about-banner/index.styl @@ -0,0 +1,50 @@ +.about-banner + position relative + + min-height 380px + + color #fff + background url("about-banner/header.jpg") no-repeat; + background-size cover + + + &__header + position absolute + top 75px + + width 100% + + text-align center + + &__title + font-size 24px + font-weight normal + line-height initial + + &__name + font-size 51px + font-weight bold + line-height 51px + + &__line + width 110px + height 2px + margin-top 42px + + border none + background rgba(255,255,255,0.2) + + @media (max-width: 690px) + &__header + top: 35px + + &__title + font-size 18px + + &__name + font-size 40px + line-height 40px + + @media (max-width: 480px) + &__line + display none diff --git a/handlers/about/client/styles/about-layout/index.styl b/handlers/about/client/styles/about-layout/index.styl new file mode 100755 index 000000000..172606370 --- /dev/null +++ b/handlers/about/client/styles/about-layout/index.styl @@ -0,0 +1,19 @@ +.about-layout + max-width 1200px + margin 0 auto + + & .columns__col + padding 40px 30px + + &__left.columns__col + padding-left 85px + + border-right 1px solid #eee + + @media tablet + & .columns__col + display block + + width auto + margin-top 20px + border none \ No newline at end of file diff --git a/handlers/about/client/styles/about-list/index.styl b/handlers/about/client/styles/about-list/index.styl new file mode 100755 index 000000000..643525384 --- /dev/null +++ b/handlers/about/client/styles/about-list/index.styl @@ -0,0 +1,52 @@ +.about-list + position absolute + bottom 30px + + width 100% + + text-align center + + &__list + display inline-table + width 100% + max-width 1200px + + &__item + display table-cell + + padding 0 10px + + text-align center + + &__num + font-size 2.8vw + line-height 2.8vw + font-weight bold + + &__description + margin 10px 0 0 0 + + opacity 0.6 + + @media (min-width: largescreen) + &__num + font-size 42px + line-height 42px + + @media tablet + &__num + font-size 16px + + &__description + font-size 12px + + @media (max-width: 690px) + &__list + display block + + text-align center + + &__item + display inline-block + + margin-top 20px diff --git a/handlers/about/client/styles/about-map/index.styl b/handlers/about/client/styles/about-map/index.styl new file mode 100755 index 000000000..786360ef0 --- /dev/null +++ b/handlers/about/client/styles/about-map/index.styl @@ -0,0 +1,12 @@ +.about-map + background #fbfafa + + &__title + line-height initial + padding 25px + + text-align center + + &__map-container + height 500px + diff --git a/handlers/about/client/styles/about-text/index.styl b/handlers/about/client/styles/about-text/index.styl new file mode 100755 index 000000000..3d3a64936 --- /dev/null +++ b/handlers/about/client/styles/about-text/index.styl @@ -0,0 +1,59 @@ +.about-text + font-size 16px + line-height initial + + counter-reset items + + &__body_center + text-align center + + &__title + text-align center + + margin-bottom 40px + + &__list + list-style none + + &__item + margin-top 15px + padding-left 1.6em + + &__item:before + position absolute + + margin-left -1.6em + + color light_gray_color + text-align right + + counter-increment items + content counter(items) "." + + &__human + min-height 66px + margin-top 20px + + padding-left 78px + + &__human-title a:visited + color link_color + + &__human-userpic + position absolute + overflow hidden + + width 65px + height 65px + margin -10px 0 0 -78px + + border-radius 50% + box-shadow: 0 0 1px rgba(0,0,0,0.3) + + &__human-userpic-i + width 65px + + &__human-role + margin 10px 0 0 0 + + color light_gray_color diff --git a/handlers/about/client/styles/index.styl b/handlers/about/client/styles/index.styl new file mode 100755 index 000000000..b1b4801bb --- /dev/null +++ b/handlers/about/client/styles/index.styl @@ -0,0 +1,8 @@ + +@require "~styles/blocks/variables/variables" + +@require "about-banner" +@require "about-list" +@require "about-layout" +@require "about-text" +@require "about-map" diff --git a/handlers/about/controllers/index.js b/handlers/about/controllers/index.js new file mode 100755 index 000000000..3688139c1 --- /dev/null +++ b/handlers/about/controllers/index.js @@ -0,0 +1,9 @@ +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +exports.get = function*() { + this.locals.siteToolbarCurrentSection = "about"; + + this.body = this.render('index'); +}; diff --git a/handlers/about/index.js b/handlers/about/index.js new file mode 100755 index 000000000..29a10188c --- /dev/null +++ b/handlers/about/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/about', __dirname)); +}; + diff --git a/handlers/about/router.js b/handlers/about/router.js new file mode 100755 index 000000000..e0509cf7f --- /dev/null +++ b/handlers/about/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); diff --git a/handlers/about/templates/index.jade b/handlers/about/templates/index.jade new file mode 100755 index 000000000..b1c6fbc7e --- /dev/null +++ b/handlers/about/templates/index.jade @@ -0,0 +1,156 @@ + +extends /layouts/main + + +block append variables + + - var headTitle = 'Современный учебник Javascript'; + - var sitetoolbar = true + - var header = false + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + + //- var layout_main_class = "main_width-limit" +block append head + !=css("about") + script(defer src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + !=js("about", {defer: true}) + //- google maps will call about.init() when loaded + script(defer src="https://maps.googleapis.com/maps/api/js?v=3.exp&&callback=about.init") + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + style. + .leaflet-map-pane { + z-index: 2 !important; + } + .leaflet-google-layer { + z-index: 1 !important; + } + +block content + +b.about-banner + +e("header").header + +e("h1").title проект + br + +e("span").name Javascript.ru + + +e("hr").line + + +b.about-list + +e("ul").list + +e("li").item + +e("h2").num 2 + +e("p").description + | конференции + br + | JS. Talks + + +e("li").item + +e("h2").num 4940 + +e("p").description + | участников очных + br + | мастер-классов + + +e("li").item + +e("h2").num 1255 + +e("p").description + | участников + br + | дистанционного обучения + + +e("li").item + +e("h2").num >282000 + +e("p").description + | посетителей в месяц + br + | (на основе последнего года) + + +e("li").item + +e("h2").num >24000 + +e("p").description + | строк на js в + br + | open-source коде сайта + + +e("li").item + +e("h2").num >93000 + +e("p").description + | строк в учебнике + br + | Javascript + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title О проекте + +e.body + p Javascript.ru был запущен в 2007 году и с тех пор стал одним из крупнейших русскоязычных порталов по JavaScript. Сегодня основные цели проекта это: + +e('ol').list + +e('li').item Предоставлять грамотную и актуальную информацию по JavaScript и смежным технологиям. + +e('li').item Популяризировать современные фронтенд-технологии. + +e('li').item Проводить онлайн и оффлайн-мероприятия по обучению JavaScript. + +e('li').item Создание сообщества JS-разработчиков и обмен знаниями. + p + | Код этого сайта и содержимое учебника по Javascript находится в open-source доступе и его можно посмотреть на  + a(href="https://github.com/iliakan/javascript-nodejs") github + | . + + +b.about-text.about-layout__right.columns__col + +e('h1').title Люди + +e.body + +e('ul').humans + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/iliakan.jpg") + +e('h3').human-title + +e('a')(href="https://ikantor.moikrug.ru/") Илья Кантор + +e('p').human-role Координатор, тренер, JS-разработчик + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/bezart.jpg") + +e('h3').human-title + +e('a')(href="http://bezart.me/") Артем Безценный + +e('p').human-role UX-дизайнер + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/tyv.jpg") + +e('h3').human-title + +e('a')(href="https://ua.linkedin.com/in/tkachenkoyuri") Юрий Ткаченко + +e('p').human-role На дуде игрец + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/amax.jpg") + +e('h3').human-title + +e('a')(href="https://www.linkedin.com/pub/aleksey-maximov/54/b76/215") Алексей Максимов + +e('p').human-role Админ + + +e('p') А также другие контрибьюторы. + + +b.about-map + +e('h1').title География офлайн событий + +e('h1').map-container#map + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title Принять участие в проекте + +e.body + p + | Если у вас есть идеи по улучшению работы сайта либо содержимого учебника по Javascript, не стесняйтесь присылать их нам либо заходите на наш  + a(href="https://github.com/iliakan/javascript-nodejs") github + | . + + +b.about-text.about-layout__right.columns__col + +e('h1').title#contact-us Связаться с нами + +e.body._center + p + | Илья Кантор + p + a(href="mailto:iliakan@javascript.ru") iliakan@javascript.ru + br + | +79035419441 + diff --git a/handlers/accessLogger.js b/handlers/accessLogger.js new file mode 100755 index 000000000..ae9c77a20 --- /dev/null +++ b/handlers/accessLogger.js @@ -0,0 +1,70 @@ +// adapted koa-logger for bunyan +const Counter = require('passthrough-counter'); +const clsNamespace = require("continuation-local-storage").getNamespace("app"); + +// binds onfinish to current context +// bindEmitter didn't work here +exports.init = function(app) { + app.use(function *logger(next) { + // request + var req = this.req; + + var start = Date.now(); + this.log.info({ + event: "request-start", + method: req.method, + url: req.url, + referer: this.request.get('referer'), + ua: this.request.get('user-agent') + }, "--> %s %s", req.method, req.originalUrl || req.url); + + try { + yield next; + } catch (err) { + // log uncaught downstream errors + log(this, start, err); + throw err; + } + + // log when the response is finished or closed, + // whichever happens first. + var ctx = this; + var res = this.res; + + var onfinish = done.bind(null, 'finish'); + var onclose = done.bind(null, 'close'); + + res.once('finish', clsNamespace.bind(onfinish)); + res.once('close', clsNamespace.bind(onclose)); + + function done(event) { + res.removeListener('finish', onfinish); + res.removeListener('close', onclose); + log(ctx, start, null, event); + } + + /** + * Log helper. + */ + + function log(ctx, start, err, event) { + // get the status code of the response + var status = err ? (err.status || 500) : (ctx.status || 404); + + // set the color of the status code; + var s = status / 100 | 0; + + // not ctx.url, but ctx.originalUrl because mount middleware changes it + // request to /payments/common/order in case of error is logged as /order + + ctx.log[err ? 'error' : 'info']({ + event: "request-end", + method: ctx.method, + url: ctx.originalUrl, + status: status, + timeDuration: Date.now() - start + }, "<-- %s %s", ctx.method, ctx.originalUrl); + } + + }); +}; diff --git a/handlers/auth/client/authForm.js b/handlers/auth/client/authForm.js new file mode 100644 index 000000000..bc8071a89 --- /dev/null +++ b/handlers/auth/client/authForm.js @@ -0,0 +1,389 @@ +var xhr = require('client/xhr'); + +var delegate = require('client/delegate'); +var Spinner = require('client/spinner'); + +var loginForm = require('../templates/login-form.jade'); +var registerForm = require('../templates/register-form.jade'); +var forgotForm = require('../templates/forgot-form.jade'); + +var clientRender = require('client/clientRender'); + +/** + * Options: + * - callback: function to be called after successful login (by default - go to successRedirect) + * - message: form message to be shown when the login form appears ("Log in to leave the comment") + * - successRedirect: the page to redirect (current page by default) + * - after immediate login + * - after registration for "confirm email" link + */ +class AuthForm { + + constructor(options) { + this.options = options; + + if (!options.successRedirect) { + options.successRedirect = window.location.href; + } + } + + render() { + this.elem = document.createElement('div'); + this.elem.innerHTML = clientRender(loginForm, this.options); + + if (this.options.message) { + this.showFormMessage(this.options.message); + } + + this.initEventHandlers(); + } + + getElem() { + if (!this.elem) this.render(); + return this.elem; + } + + successRedirect() { + if (window.location.href == this.options.successRedirect) { + window.location.reload(); + } else { + window.location.href = this.options.successRedirect; + } + } + + clearFormMessages() { + /* + remove error for this notation: + span.text-input.text-input_invalid.login-form__input + input.text-input__control#password(type="password", name="password") + span.text-inpuxt__err Пароли не совпадают + */ + [].forEach.call(this.elem.querySelectorAll('.text-input_invalid'), function(elem) { + elem.classList.remove('text-input_invalid'); + }); + + [].forEach.call(this.elem.querySelectorAll('.text-input__err'), function(elem) { + elem.remove(); + }); + + // clear form-wide notification + this.elem.querySelector('[data-notification]').innerHTML = ''; + } + + request(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; + } + + startRequestIndication() { + this.elem.classList.add('modal-overlay_light'); + var self = this; + + var submitButton = this.elem.querySelector('[type="submit"]'); + + if (submitButton) { + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + class: '', + elemClass: 'button_loading' + }); + spinner.start(); + } + + return function onEnd() { + self.elem.classList.remove('modal-overlay_light'); + if (spinner) spinner.stop(); + }; + + } + + initEventHandlers() { + + this.delegate('[data-switch="register-form"]', 'click', function(e) { + e.preventDefault(); + this.elem.innerHTML = clientRender(registerForm, this.options); + }); + + this.delegate('[data-switch="login-form"]', 'click', function(e) { + e.preventDefault(); + this.elem.innerHTML = clientRender(loginForm, this.options); + }); + + this.delegate('[data-switch="forgot-form"]', 'click', function(e) { + e.preventDefault(); + + // move currently entered email into forgotForm + var oldEmailInput = this.elem.querySelector('[type="email"]'); + this.elem.innerHTML = clientRender(forgotForm, this.options); + var newEmailInput = this.elem.querySelector('[type="email"]'); + newEmailInput.value = oldEmailInput.value; + }); + + + this.delegate('[data-form="login"]', 'submit', function(event) { + event.preventDefault(); + this.submitLoginForm(event.target); + }); + + + this.delegate('[data-form="register"]', 'submit', function(event) { + event.preventDefault(); + this.submitRegisterForm(event.target); + }); + + this.delegate('[data-form="forgot"]', 'submit', function(event) { + event.preventDefault(); + this.submitForgotForm(event.target); + }); + + this.delegate("[data-provider]", "click", function(event) { + event.preventDefault(); + this.openAuthPopup('/auth/login/' + event.delegateTarget.dataset.provider); + }); + + this.delegate('[data-action-verify-email]', 'click', function(event) { + event.preventDefault(); + + var payload = new FormData(); + var email = event.delegateTarget.dataset.actionVerifyEmail; + payload.append("email", email); + + var request = this.request({ + method: 'POST', + url: '/auth/reverify', + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 200) { + self.showFormMessage({ + html: ` +

Письмо-подтверждение отправлено ещё раз.

+

перезапросить подтверждение.

+ `, + type: 'success' + }); + } else { + self.showFormMessage({type: 'error', html: event.result}); + } + + }); + + }); + } + + submitRegisterForm(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (!form.elements.displayName.value) { + hasErrors = true; + this.showInputError(form.elements.displayName, 'Введите, пожалуста, имя пользователя.'); + } + + if (!form.elements.password.value) { + hasErrors = true; + this.showInputError(form.elements.password, 'Введите, пожалуста, пароль.'); + } + + if (hasErrors) return; + + var payload = new FormData(form); + payload.append("successRedirect", this.options.successRedirect); + + var request = this.request({ + method: 'POST', + url: '/auth/register', + normalStatuses: [201, 400], + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 201) { + self.elem.innerHTML = clientRender(loginForm, self.options); + self.showFormMessage({ + html: "

С адреса notify@javascript.ru отправлено письмо со ссылкой-подтверждением.

" + + "

перезапросить подтверждение.

", + type: 'success' + }); + return; + } + + if (this.status == 400) { + for (var field in event.result.errors) { + self.showInputError(form.elements[field], event.result.errors[field]); + } + return; + } + + self.showFormMessage({html: "Неизвестный статус ответа сервера", type:'error'}); + }); + + } + + + submitForgotForm(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (hasErrors) return; + + var payload = new FormData(form); + payload.append("successRedirect", this.options.successRedirect); + + var request = this.request({ + method: 'POST', + url: '/auth/forgot', + normalStatuses: [200, 404, 403], + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 200) { + self.elem.innerHTML = clientRender(loginForm, this.options); + self.showFormMessage({html: event.result, type: 'success'}); + } else if (this.status == 404) { + self.showFormMessage({html: event.result, type: 'error'}); + } else if (this.status == 403) { + self.showFormMessage({html: event.result.message || "Действие запрещено.", type: 'error'}); + } + }); + + } + + showInputError(input, error) { + input.parentNode.classList.add('text-input_invalid'); + var errorSpan = document.createElement('span'); + errorSpan.className = 'text-input__err'; + errorSpan.innerHTML = error; + input.parentNode.appendChild(errorSpan); + } + + showFormMessage(message) { + var html = message.html; + if (html.indexOf('

') !== 0) { + html = '

' + html + '

'; + } + + var type = message.type; + if (['info', 'error', 'warning', 'success'].indexOf(type) == -1) { + throw new Error("Unsupported type: " + type); + } + + var container = document.createElement('div'); + container.className = 'login-form__' + type; + container.innerHTML = html; + + this.elem.querySelector('[data-notification]').innerHTML = ''; + this.elem.querySelector('[data-notification]').appendChild(container); + } + + submitLoginForm(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (!form.elements.password.value) { + hasErrors = true; + this.showInputError(form.elements.password, 'Введите, пожалуста, пароль.'); + } + + if (hasErrors) return; + + var request = xhr({ + method: 'POST', + url: '/auth/login/local', + noDocumentEvents: true, // we handle all events/errors in this code + normalStatuses: [200, 401], + body: new FormData(form) + }); + + var onEnd = this.startRequestIndication(); + + request.addEventListener('success', (event) => { + + if (request.status == 401) { + onEnd(); + this.onAuthFailure(event.result.message); + return; + } + + // don't stop progress indication if login successful && we're making redirect + if (!this.options.callback) { + this.onAuthSuccess(event.result.user); + } else { + onEnd(); + this.onAuthSuccess(event.result.user); + } + }); + + request.addEventListener('fail', (event) => { + onEnd(); + this.onAuthFailure(event.reason); + }); + + } + + openAuthPopup(url) { + if (this.authPopup && !this.authPopup.closed) { + this.authPopup.close(); // close old popup if any + } + var width = 800, height = 600; + var top = (window.outerHeight - height) / 2; + var left = (window.outerWidth - width) / 2; + window.authForm = this; + this.authPopup = window.open(url, 'authForm', 'width=' + width + ',height=' + height + ',scrollbars=0,top=' + top + ',left=' + left); + } + + /* + все обработчики авторизации (включая Facebook из popup-а и локальный) + в итоге триггерят один из этих каллбэков + */ + onAuthSuccess(user) { + window.currentUser = user; + if (this.options.callback) { + this.options.callback(); + } else { + this.successRedirect(); + } + } + + onAuthFailure(errorMessage) { + this.showFormMessage({html: errorMessage || "Отказ в авторизации.", type: 'error'}); + } +} + + +delegate.delegateMixin(AuthForm.prototype); + +module.exports = AuthForm; diff --git a/handlers/auth/client/authModal.js b/handlers/auth/client/authModal.js new file mode 100755 index 000000000..b022854c8 --- /dev/null +++ b/handlers/auth/client/authModal.js @@ -0,0 +1,32 @@ +var Modal = require('client/head/modal'); +var AuthForm = require('./authForm'); + +/** + * Options: + * - callback: function to be called after successful login (by default - go to successRedirect) + * - message: form message to be shown when the login form appears ("Log in to leave the comment") + * - successRedirect: the page to redirect (current page by default) + * - after immediate login + * - after registration for "confirm email" link + */ +class AuthModal extends Modal { + + constructor(options) { + super(options); + this.options = options || {}; + this.options.inModal = true; + + var authForm = new AuthForm(this.options); + this.setContent(authForm.getElem()); + } + + + render() { + super.render(); + this.elem.classList.add('login-form-modal'); + } + +} + + +module.exports = AuthModal; diff --git a/handlers/auth/client/index.js b/handlers/auth/client/index.js new file mode 100755 index 000000000..155ec4325 --- /dev/null +++ b/handlers/auth/client/index.js @@ -0,0 +1,13 @@ +exports.AuthModal = require('./authModal'); + +const AuthForm = require('./authForm'); + +function init() { + + var form = new AuthForm(window.authOptions); + + document.getElementById("auth-form").appendChild(form.getElem()); + +} + +init(); diff --git a/handlers/auth/controller/disconnect.js b/handlers/auth/controller/disconnect.js new file mode 100755 index 000000000..af8a972d3 --- /dev/null +++ b/handlers/auth/controller/disconnect.js @@ -0,0 +1,22 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +// Remove provider profile from the user +exports.post = function* (next) { + + var user = this.user; + + for (var i = 0; i < user.providers.length; i++) { + var provider = user.providers[i]; + if (provider.name == this.params.providerName) { + provider.remove(); + i--; + } + } + + yield user.persist(); + + this.body = ''; +}; diff --git a/handlers/auth/controller/forgot.js b/handlers/auth/controller/forgot.js new file mode 100755 index 000000000..3cf913954 --- /dev/null +++ b/handlers/auth/controller/forgot.js @@ -0,0 +1,42 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +exports.post = function* (next) { + + var user = yield User.findOne({ + email: this.request.body.email + }).exec(); + + if (!user) { + this.status = 404; + this.body = 'Нет такого пользователя.'; + return; + } + + user.passwordResetToken = Math.random().toString(36).slice(2, 10); + user.passwordResetTokenExpires = new Date(Date.now() + 86400*1e3); + user.passwordResetRedirect = this.request.body.successRedirect; + + yield user.persist(); + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'forgot-email'), + to: user.email, + subject: "Восстановление доступа", + link: config.server.siteHost + '/auth/forgot-recover/' + user.passwordResetToken + }); + + } catch(e) { + this.log.error({err: e}, "Mail send failed"); + this.throw(500, "На сервере ошибка отправки email."); + } + + this.status = 200; + this.body = 'На вашу почту отправлено письмо со ссылкой на смену пароля.'; + +}; diff --git a/handlers/auth/controller/forgotRecover.js b/handlers/auth/controller/forgotRecover.js new file mode 100755 index 000000000..947b10593 --- /dev/null +++ b/handlers/auth/controller/forgotRecover.js @@ -0,0 +1,72 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +exports.get = function* (next) { + + var passwordResetToken = this.params.passwordResetToken; + + var user = yield User.findOne({ + passwordResetToken: passwordResetToken, + passwordResetTokenExpires: { + $gt: new Date() + } + }).exec(); + + if (!user) { + this.throw(404, 'Вы перешли по устаревшей или недействительной ссылке на восстановление.'); + } + + this.body = this.render('forgot-recover', { + passwordResetToken: passwordResetToken + }); + +}; + +exports.post = function* (next) { + + var passwordResetToken = this.request.body.passwordResetToken; + + var user = yield User.findOne({ + passwordResetToken: passwordResetToken, + passwordResetTokenExpires: { + $gt: new Date() + } + }).exec(); + + if (!user) { + this.throw(404, 'Ваша ссылка на восстановление недействительна или устарела.'); + } + + var error = ""; + if (!this.request.body.password) { + error = "Пароль не должен быть пустым."; + } + if (this.request.body.password.length < 4) { + error = "Пароль должен содержать минимум 4 символа."; + } + + if (error) { + this.body = this.render('forgot-recover', { + passwordResetToken: passwordResetToken, + error: error + }); + + return; + } + + var redirect = user.passwordResetRedirect; + + delete user.passwordResetToken; + delete user.passwordResetTokenExpires; + delete user.passwordResetRedirect; + + user.password = this.request.body.password; + + yield user.persist(); + + yield this.login(user); + + this.redirect(redirect); +}; diff --git a/handlers/auth/controller/login.js b/handlers/auth/controller/login.js new file mode 100644 index 000000000..9361b14de --- /dev/null +++ b/handlers/auth/controller/login.js @@ -0,0 +1,25 @@ +// plain login +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + + +exports.get = function* () { + + // logged in? + if (this.user) { + this.redirect('/'); + return; + } + + this.locals.headTitle = "Авторизация"; + + this.locals.authOptions = { + successRedirect: this.flash.successRedirect || '/', + message: this.flash.messages && this.flash.messages[0] + }; + + this.body = this.render('login'); +}; diff --git a/handlers/auth/controller/loginAs.js b/handlers/auth/controller/loginAs.js new file mode 100644 index 000000000..31c8a9166 --- /dev/null +++ b/handlers/auth/controller/loginAs.js @@ -0,0 +1,38 @@ +// plain login +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + + +exports.get = function* () { + + if (this.user) { + this.logout(); + } + + var user = yield User.findOne({ + profileName: this.params.profileNameOrEmailOrId + }).exec(); + + if (!user) { + user = yield User.findOne({ + email: this.params.profileNameOrEmailOrId + }).exec(); + } + + if (!user) { + try { + user = yield User.findById(this.params.profileNameOrEmailOrId).exec(); + } catch(e) {} + } + + console.log(user); + + if (!user) this.throw(404); + + yield this.login(user); + + this.redirect('/'); +}; diff --git a/handlers/auth/controller/logout.js b/handlers/auth/controller/logout.js new file mode 100755 index 000000000..b8dd22418 --- /dev/null +++ b/handlers/auth/controller/logout.js @@ -0,0 +1,13 @@ + +exports.post = function*(next) { + this.cookies.set('remember'); // remove "remember me" cookie + this.cookies.set('remember.sig'); + + this.cookies.set('sid'); // logout removes sid, but not sid.sig (3rd party bug?) + this.cookies.set('sid.sig'); + + this.logout(); + this.session = null; + this.redirect('/'); +}; + diff --git a/handlers/auth/controller/register.js b/handlers/auth/controller/register.js new file mode 100644 index 000000000..df0b631d4 --- /dev/null +++ b/handlers/auth/controller/register.js @@ -0,0 +1,61 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +// Регистрация пользователя. +exports.post = function* (next) { + +// yield function(callback) {}; + + var verifyEmailToken = Math.random().toString(36).slice(2, 10); + var user = new User({ + email: this.request.body.email, + displayName: this.request.body.displayName, + password: this.request.body.password, + verifiedEmail: false, + verifyEmailToken: verifyEmailToken, + verifyEmailRedirect: this.request.body.successRedirect + }); + + //yield user.generateProfileName(); + + try { + yield user.persist(); + } catch(e) { + if (e.name == 'ValidationError') { + try { + if (e.errors.email.type == "notunique") { + e.errors.email.message += ' Если он ваш, то можно войти или восстановить пароль.'; + } + } catch (ex) { /* e.errors.email is undefined, that's ok */ } + this.renderError(e); + return; + } else { + this.throw(e); + } + } + + + // We're here if no errors happened + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'verify-registration-email'), + to: user.email, + subject: "Подтверждение email", + link: config.server.siteHost + '/auth/verify/' + verifyEmailToken + }); + + } catch(e) { + this.log.error("Registration failed", {err: e}); + this.throw(500, "Ошибка отправки email."); + } + + + this.status = 201; + this.body = ''; //Вы зарегистрированы. Пожалуйста, загляните в почтовый ящик, там письмо с Email-подтверждением.'; + +}; diff --git a/handlers/auth/controller/reverify.js b/handlers/auth/controller/reverify.js new file mode 100755 index 000000000..cdac698a3 --- /dev/null +++ b/handlers/auth/controller/reverify.js @@ -0,0 +1,42 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +// Регистрация пользователя. +exports.post = function* (next) { + + var email = this.request.body.email; + if (!email) { + this.throw(404, 'Не указан email пользователя.'); + } + + var user = yield User.findOne({ + email: email + }).exec(); + + if (!user) { + this.throw(404, 'Нет такого пользователя.'); + } + + if (user.verifiedEmail) { + this.throw(403, 'Ваш Email уже подтверждён.'); + } + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'verify-registration-email'), + to: user.email, + subject: "Подтверждение email", + link: config.server.siteHost + '/auth/verify/' + user.verifyEmailToken + }); + + } catch(e) { + this.log.error({err: e}, "Reverify failed"); + this.throw(500, "На сервере ошибка отправки email."); + } + + this.body = ''; +}; diff --git a/handlers/auth/controller/verify.js b/handlers/auth/controller/verify.js new file mode 100755 index 000000000..88a03d150 --- /dev/null +++ b/handlers/auth/controller/verify.js @@ -0,0 +1,47 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +exports.get = function* (next) { + + var user = yield User.findOne({ + verifyEmailToken: this.params.verifyEmailToken + }).exec(); + + if (!user) { + this.throw(404, 'Ссылка подтверждения недействительна или устарела.'); + } + + var redirect = user.verifyEmailRedirect; + delete user.verifyEmailRedirect; + + if (!user.verifiedEmail) { + user.verifiedEmail = true; + user.verifiedEmailsHistory.push({date: new Date(), email: user.email}); + yield user.persist(); + + } else if (user.pendingVerifyEmail) { + user.email = user.pendingVerifyEmail; + + user.verifiedEmailsHistory.push({date: new Date(), email: user.email}); + try { + yield user.persist(); + } catch (e) { + if (e.name != 'ValidationError') { + throw e; + } else { + this.throw(400, 'Изменение email невозможно, адрес уже занят.'); + } + } + + } else { + this.throw(404, 'Изменений не произведено: ваш email и так верифицирован, его смена не запрашивалась.'); + } + + delete user.verifyEmailToken; + + yield this.login(user); + + this.redirect(redirect); +}; diff --git a/handlers/auth/controller/xmpp.js b/handlers/auth/controller/xmpp.js new file mode 100644 index 000000000..1e29e29c6 --- /dev/null +++ b/handlers/auth/controller/xmpp.js @@ -0,0 +1,33 @@ +var User = require('users').User; +var path = require('path'); +var config = require('config'); + +// Remove provider profile from the user +exports.post = function* (next) { + + // anti-bruteforce pause + yield function(callback) { + setTimeout(callback, 100); + }; + + var user = yield User.findOne({ + profileName: this.request.body.user + }).exec(); + + if (!user) { + this.log.error("No such user", this.request.body.user); + this.body = "0"; + return; + } + + switch(this.request.body.command) { + case 'auth': + this.body = user.checkPassword(this.request.body.password) ? "1" : "0"; + return; + default: + // do not support other requests yet + this.log.debug("Command not supported", this.request.body.command); + this.body = "0"; + } + +}; diff --git a/handlers/auth/forms.js b/handlers/auth/forms.js new file mode 100755 index 000000000..e2d3f5601 --- /dev/null +++ b/handlers/auth/forms.js @@ -0,0 +1,2 @@ +exports.forgot = require('./templates/forgot-form.jade'); + diff --git a/handlers/auth/index.js b/handlers/auth/index.js new file mode 100755 index 000000000..d4245af8d --- /dev/null +++ b/handlers/auth/index.js @@ -0,0 +1,31 @@ +exports.mustBeAuthenticated = require('./lib/mustBeAuthenticated'); +exports.mustNotBeAuthenticated = require('./lib/mustNotBeAuthenticated'); +exports.mustBeAdmin = require('./lib/mustBeAdmin'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + + require('./strategies'); + + app.use(mountHandlerMiddleware('/auth', __dirname)); + + // no csrf check for guest endpoints (no generation of csrf for anon) + app.csrfChecker.ignore.add('/auth/login/:any*'); + app.csrfChecker.ignore.add('/auth/register'); + app.csrfChecker.ignore.add('/auth/reverify'); + app.csrfChecker.ignore.add('/auth/forgot'); + app.csrfChecker.ignore.add('/auth/forgot-recover'); + + app.use(function*(next) { + this.authAndRedirect = function(url) { + this.addFlashMessage('info', 'Для доступа к этой странице нужна авторизация.'); + this.newFlash.successRedirect = url; + this.redirect('/auth/login'); + }; + yield* next; + }); +}; + + + diff --git a/handlers/auth/lib/authenticateByProfile.js b/handlers/auth/lib/authenticateByProfile.js new file mode 100755 index 000000000..0e20eb66b --- /dev/null +++ b/handlers/auth/lib/authenticateByProfile.js @@ -0,0 +1,152 @@ +const User = require('users').User; +const config = require('config'); +const co = require('co'); +const _ = require('lodash'); +const request = require('koa-request'); +const transload = require('imgur').transload; +const log = require('log')(); + +function UserAuthError(message) { + this.message = message; +} + +function* mergeProfile(user, profile) { + if (!user.photo && profile.photos && profile.photos.length && profile.photos[0].type != 'default') { + // assign an avatar unless it's default + var photoUrl = profile.photos[0].value; + var photoInfo = yield* transload(photoUrl); + user.photo = photoInfo.link; + } + + if (!user.email && profile.emails && profile.emails.length) { + user.email = profile.emails[0].value; + } + + if (!user.displayName && profile.displayName) { + user.displayName = profile.displayName; + } + + if (!user.realName && profile.realName) { + user.realName = profile.realName; + } + + if (!user.gender && profile.gender) { + user.gender = profile.gender; + } + + // remove previous profile from the same provider, replace by the new one + var nameId = makeProviderId(profile); + for (var i = 0; i < user.providers.length; i++) { + var provider = user.providers[i]; + if (provider.nameId == nameId) { + provider.remove(); + i--; + } + } + + user.providers.push({ + name: profile.provider, + nameId: makeProviderId(profile), + profile: profile + }); + + user.verifiedEmail = true; +} + +function makeProviderId(profile) { + return profile.provider + ":" + profile.id; +} + +module.exports = function(req, profile, done) { + // profile = the data returned by the facebook graph api + + log.debug({profile: profile}, "profile"); + + var userToConnect = req.user; + + co(function*() { + var providerNameId = makeProviderId(profile); + + var user; + + if (userToConnect) { + // merge auth result with the user profile if it is not bound anywhere yet + + // look for another user already using this profile + var alreadyConnectedUser = yield User.findOne({ + "providers.nameId": providerNameId, + _id: {$ne: userToConnect._id} + }).exec(); + + if (alreadyConnectedUser) { + // if old user is in read-only, + // I can't just reattach the profile to the new user and keep logging in w/ it + if (alreadyConnectedUser.readOnly) { + throw new UserAuthError("Вход по этому профилю не разрешён, извините."); + } + + // before this social login was used by alreadyConnectedUser + // now we clean the connection to make a new one + for (var i = 0; i < alreadyConnectedUser.providers.length; i++) { + var provider = alreadyConnectedUser.providers[i]; + if (provider.nameId == providerNameId) { + provider.remove(); + i--; + } + } + yield alreadyConnectedUser.persist(); + } + + user = userToConnect; + + } else { + user = yield User.findOne({"providers.nameId": providerNameId}).exec(); + + if (!user) { + // if we have user with same email, assume it's exactly the same person as the new man + user = yield User.findOne({email: profile.emails[0].value}).exec(); + + if (!user) { + // auto-register + user = new User(); + } + } + } + + + try { + yield* mergeProfile(user, profile); + } catch (e) { + if (e.name == 'BadImageError') { // image too big or kind of + throw new UserAuthError(e.message); + } else { + throw e; + } + } + + try { + yield function(callback) { + user.validate(callback); + }; + } catch (e) { + // there's a required field + // maybe, when the user was on the remote social login screen, he disallowed something? + throw new UserAuthError("Недостаточно данных, разрешите их передачу, пожалуйста."); + } + + yield user.persist(); + + + return user; + + }).then(function(user) { + done(null, user); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +}; diff --git a/handlers/auth/lib/mustBeAdmin.js b/handlers/auth/lib/mustBeAdmin.js new file mode 100755 index 000000000..8cfa7fdf9 --- /dev/null +++ b/handlers/auth/lib/mustBeAdmin.js @@ -0,0 +1,10 @@ +var config = require('config'); + +module.exports = function*(next) { + + if (process.env.NODE_ENV == 'development' || this.isAdmin) { + yield* next; + } else { + this.throw(403); + } +}; diff --git a/handlers/auth/lib/mustBeAuthenticated.js b/handlers/auth/lib/mustBeAuthenticated.js new file mode 100755 index 000000000..2fe11b889 --- /dev/null +++ b/handlers/auth/lib/mustBeAuthenticated.js @@ -0,0 +1,8 @@ + +module.exports = function*(next) { + if (this.isAuthenticated()) { + yield* next; + } else { + this.throw(401); + } +}; diff --git a/handlers/auth/lib/mustNotBeAuthenticated.js b/handlers/auth/lib/mustNotBeAuthenticated.js new file mode 100755 index 000000000..888c57453 --- /dev/null +++ b/handlers/auth/lib/mustNotBeAuthenticated.js @@ -0,0 +1,8 @@ + +module.exports = function*(next) { + if (!this.isAuthenticated()) { + yield* next; + } else { + this.throw(403, 'Это действие доступно только для неавторизованных посетителей.'); + } +}; diff --git a/handlers/auth/out.js b/handlers/auth/out.js new file mode 100755 index 000000000..497d97a54 --- /dev/null +++ b/handlers/auth/out.js @@ -0,0 +1,5 @@ +// deprecated, not used +exports.get = function*(next) { + this.logout(); + this.redirect('/'); +}; diff --git a/handlers/auth/router.js b/handlers/auth/router.js new file mode 100755 index 000000000..0f2820f3f --- /dev/null +++ b/handlers/auth/router.js @@ -0,0 +1,127 @@ +var Router = require('koa-router'); +var config = require('config'); +var register = require('./controller/register'); +var verify = require('./controller/verify'); +var reverify = require('./controller/reverify'); +var disconnect = require('./controller/disconnect'); +var forgot = require('./controller/forgot'); +var forgotRecover = require('./controller/forgotRecover'); +var logout = require('./controller/logout'); +var login = require('./controller/login'); +var loginAs = require('./controller/loginAs'); +var xmpp = require('./controller/xmpp'); +var mustBeAuthenticated = require('./lib/mustBeAuthenticated'); +var mustBeAdmin = require('./lib/mustBeAdmin'); +var mustNotBeAuthenticated = require('./lib/mustNotBeAuthenticated'); +var passport = require('koa-passport'); + +require('./strategies'); + +var router = module.exports = new Router(); + +router.post('/login/local', function*(next) { + var ctx = this; + + // only callback-form of authenticate allows to assign ctx.body=info if 401 + yield passport.authenticate('local', function*(err, user, info) { + if (err) throw err; + if (user === false) { + ctx.status = 401; + ctx.body = info; + } else { + yield ctx.login(user); + yield ctx.rememberMe(); + ctx.body = {user: user.getInfoFields() }; + } + }).call(this, next); + +}); + +router.post('/logout', mustBeAuthenticated, logout.post); + +if (process.env.NODE_ENV == 'development') { + router.get('/out', require('./out').get); // GET logout for DEV +} + +router.post('/register', mustNotBeAuthenticated, register.post); +router.post('/forgot', mustNotBeAuthenticated, forgot.post); + +router.get('/login', login.get); + +router.get('/login-as/:profileNameOrEmailOrId', mustBeAdmin, loginAs.get); + +router.get('/verify/:verifyEmailToken', verify.get); +router.get('/forgot-recover/:passwordResetToken?', mustNotBeAuthenticated, forgotRecover.get); +router.post('/forgot-recover', forgotRecover.post); + + +router.post('/reverify', reverify.post); + +Object.keys(config.auth.providers).forEach(addProviderRoute); + +function addProviderRoute(providerName) { + var provider = config.auth.providers[providerName]; + + // login + router.get('/login/' + providerName, passport.authenticate(providerName, provider.passportOptions)); + + // connect with existing profile + router.get('/connect/' + providerName, mustBeAuthenticated, passport.authorize(providerName, provider.passportOptions)); + + + // http://stage.javascript.ru/auth/callback/facebook?error=access_denied&error_code=200&error_description=Permissions+error&error_reason=user_denied#_=_ + + router.get('/callback/' + providerName, function*(next) { + var ctx = this; + this.nocache(); + + yield passport.authenticate(providerName, function*(err, user, info) { + if (err) { + // throw err would get swallowed (!!!) + // so I must render error here + ctx.renderError(err); + return; + } + + if (user) { + yield ctx.login(user); + yield ctx.rememberMe(); + ctx.body = ctx.render('popup-success'); + return; + } + + var reason = info.message || info; + + ctx.body = ctx.render('popup-failure', { reason: reason }); + + }).call(this, next); + + yield* next; + }); + /* + router.get('/callback/' + providerName, passport.authenticate(providerName, { + failureMessage: true, + successRedirect: '/auth/popup-success', + failureRedirect: '/auth/popup-failure' + }) + + );*/ +} + +// these pages are not used if https site and https auth, because of direct opener<->popup communication +// but when site is http and popup is https, it redirects here +router.get('/popup-success', mustBeAuthenticated, function*() { + this.nocache(); + this.body = this.render('popup-success'); +}); +router.post('/popup-failure', mustNotBeAuthenticated, function*() { + this.nocache(); + this.body = this.render('popup-failure', { + reason: this.request.body.reason + }); +}); + +// disconnect with existing profile +router.post('/disconnect/:providerName', mustBeAuthenticated, disconnect.post); + +router.post('/xmpp', xmpp.post); diff --git a/handlers/auth/strategies/facebookStrategy.js b/handlers/auth/strategies/facebookStrategy.js new file mode 100755 index 000000000..a8dc2a4e5 --- /dev/null +++ b/handlers/auth/strategies/facebookStrategy.js @@ -0,0 +1,121 @@ +var User = require('users').User; +const FacebookStrategy = require('passport-facebook').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); +const request = require('koa-request'); +const co = require('co'); + +/* + Returns fields: +{ + "id": "765813916814019", + "email": "login\u0040mail.ru", + "gender": "male", + "link": "https:\/\/www.facebook.com\/app_scoped_user_id\/765813916814019\/", + "locale": "ru_RU", + "timezone": 4, + "verified": true, + "name": "Ilya Kantor", + "last_name": "Kantor", + "first_name": "Ilya" +} + + If I add "picture" to profileURL?fields, I get a *small* picture. + + Real picture is (public): + (76581...19 is user id) + http://graph.facebook.com/v2.1/765813916814019/picture?redirect=0&width=1000&height=1000 + + redirect=0 means to get meta info, not picture + then check is_silhouette (if true, no avatar) + + then if is_silhouette = false, go URL + (P.S. width/height are unreliable, not sure which exactly size we get) + +*/ + +function UserAuthError(message) { + this.message = message; +} + +module.exports = new FacebookStrategy({ + clientID: config.auth.providers.facebook.appId, + clientSecret: config.auth.providers.facebook.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/facebook", + // fields are described here: + // https://developers.facebook.com/docs/graph-api/reference/v2.1/user + profileURL: 'https://graph.facebook.com/me?fields=id,about,email,gender,link,locale,timezone,verified,name,last_name,first_name,middle_name', + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + + // req example: + // '/callback/facebook?code=...', + + // accessToken: + // ... (from ?code) + + // refreshToken: + // undefined + + + co(function*() { + + var permissionError = null; + // I guess, facebook won't allow to use an email w/o verification, but still... + if (!profile._json.verified) { + permissionError = "Почта на facebook должна быть подтверждена"; + } + + if (!profile.emails || !profile.emails[0]) { // user may allow authentication, but disable email access (e.g in fb) + permissionError = "При входе разрешите доступ к email. Он используется для идентификации пользователя."; + } + + if (permissionError) { + // revoke facebook auth, so that next time facebook will ask it again (otherwise it won't) + var response = yield request({ + method: 'DELETE', + url: "https://graph.facebook.com/me/permissions?access_token=" + accessToken + }); + + if (response.body != 'true') { + req.log.error("Unexpected facebook response", {res: response, body: response.body}); + throw new Error("Facebook auth delete call after successful auth must return true"); + } + + throw new UserAuthError(permissionError); + } + + var response = yield request.get({ + url: 'http://graph.facebook.com/v2.1/' + profile.id + '/picture?redirect=0&width=1000&height=1000', + json: true + }); + + if (response.statusCode != 200) { + throw new UserAuthError("Ошибка в запросе к Facebook"); + } + + var photoData = response.body.data; + /* jshint -W106 */ + profile.photos = [{ + value: photoData.url, + type: photoData.is_silhouette ? 'default' : 'photo' + }]; + + profile.realName = profile._json.name; + + }).then(function() { + authenticateByProfile(req, profile, done); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +// http://graph.facebook.com/v2.1/765813916814019/picture?redirect=0&width=1000&height=1000 + + + } +); diff --git a/handlers/auth/strategies/githubStrategy.js b/handlers/auth/strategies/githubStrategy.js new file mode 100755 index 000000000..262e58350 --- /dev/null +++ b/handlers/auth/strategies/githubStrategy.js @@ -0,0 +1,125 @@ +var User = require('users').User; +const GithubStrategy = require('passport-github').Strategy; +const authenticateByProfile = require('./../lib/authenticateByProfile'); +const config = require('config'); +const request = require('request'); + +/* +Minimal result example: +{ + login: 'a1109126', + id: 8511653, + avatar_url: 'https://avatars.githubusercontent.com/u/8511653?v=2', + gravatar_id: '93243a77b75990dc8a614056ab9e4f65', + url: 'https://api.github.com/users/a1109126', + html_url: 'https://github.com/a1109126', + followers_url: 'https://api.github.com/users/a1109126/followers', + following_url: 'https://api.github.com/users/a1109126/following{/other_user}', + gists_url: 'https://api.github.com/users/a1109126/gists{/gist_id}', + starred_url: 'https://api.github.com/users/a1109126/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/a1109126/subscriptions', + organizations_url: 'https://api.github.com/users/a1109126/orgs', + repos_url: 'https://api.github.com/users/a1109126/repos', + events_url: 'https://api.github.com/users/a1109126/events{/privacy}', + received_events_url: 'https://api.github.com/users/a1109126/received_events', + type: 'User', + site_admin: false, + public_repos: 0, + public_gists: 0, + followers: 0, + following: 0, + created_at: '2014-08-21T08:48:48Z', + updated_at: '2014-08-21T08:48:48Z' +} + +Result example: +{ + login: 'iliakan', + id: 349336, + avatar_url: 'https://avatars.githubusercontent.com/u/349336?v=2', + gravatar_id: '0cfca32a200bbd63e41058ec5b8e51ed', + url: 'https://api.github.com/users/iliakan', + html_url: 'https://github.com/iliakan', + followers_url: 'https://api.github.com/users/iliakan/followers', + following_url: 'https://api.github.com/users/iliakan/following{/other_user}', + gists_url: 'https://api.github.com/users/iliakan/gists{/gist_id}', + starred_url: 'https://api.github.com/users/iliakan/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/iliakan/subscriptions', + organizations_url: 'https://api.github.com/users/iliakan/orgs', + repos_url: 'https://api.github.com/users/iliakan/repos', + events_url: 'https://api.github.com/users/iliakan/events{/privacy}', + received_events_url: 'https://api.github.com/users/iliakan/received_events', + type: 'User', + site_admin: false, + name: 'Ilya Kantor', + company: '', + blog: 'http://javascript.ru', + location: '', + email: '', + hireable: false, + bio: null, + public_repos: 37, + public_gists: 663, + followers: 85, + following: 0, + created_at: '2010-07-30T14:31:35Z', + updated_at: '2014-08-20T14:48:14Z' } +} + +*/ + +module.exports = new GithubStrategy({ + clientID: config.auth.providers.github.appId, + clientSecret: config.auth.providers.github.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/github", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + + // this may be a default avatar, or a real user pic, can't be sure + /* jshint -W106 */ + profile.photos = [ + { + value: profile._json.avatar_url + } + ]; + + var options = { + headers: { + 'User-Agent': 'JavaScript.ru', + 'Authorization': 'token ' + accessToken + }, + json: true, + url: 'https://api.github.com/user/emails' + }; + + // get emails using oauth token + request(options, function(error, response, body) { + if (error || response.statusCode != 200) { + req.log.error(error, body); + done(null, false, {message: "Ошибка связи с сервером github."}); + return; + } + +// [ { email: 'iliakan@gmail.com', primary: true, verified: true } ], + + var emails = body.filter(function(email) { + return email.verified; + }); + + if (!emails.length) { + return done(null, false, {message: "Почта на github должна быть подтверждена."}); + } + + profile.emails = [ + {value: emails[0].email } + ]; + + profile.realName = profile.displayName; + + authenticateByProfile(req, profile, done); + }); + + + } +); diff --git a/handlers/auth/strategies/googleStrategy.js b/handlers/auth/strategies/googleStrategy.js new file mode 100755 index 000000000..6805cce42 --- /dev/null +++ b/handlers/auth/strategies/googleStrategy.js @@ -0,0 +1,62 @@ +var User = require('users').User; +const GoogleStrategy = require('passport-google-oauth').Strategy; +const authenticateByProfile = require('./../lib/authenticateByProfile'); +const config = require('config'); + +// Doesn't work: error when denied access, +// maybe https://www.npmjs.org/package/passport-google-plus ? +// should not require G+ + +/* Result example: + var result = { + "kind": "plus#person", + "etag": "\"pNz5TVTpPz2Rn5Xw8UrubkkbOJ0/79ehDjWVUdPtREa5lO-8QSWwSUQ\"", + "emails": [ + { + "value": "julia.b.kantor@gmail.com", + "type": "account" + } + ], + "objectType": "person", + "id": "104971107141139955646", + "displayName": "Юлия Кантор", + "name": { + "familyName": "Кантор", + "givenName": "Юлия" + }, + "image": { + "url": "https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50", + "isDefault": true + }, + "isPlusUser": false, + "language": "ru", + "verified": false + } + */ + +/* + + For image: + ?sz=SIZE, large picture without sz! + isDefault: true if no picture + + */ + +/* + // revoke permission: https://security.google.com/settings/security/permissions?pli=1 + */ + +module.exports = new GoogleStrategy({ + clientID: config.auth.providers.google.appId, + clientSecret: config.auth.providers.google.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/google", + passReqToCallback: true + }, + function(req, token, tokenSecret, profile, done) { + + profile.realName = profile._json.nickname; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/strategies/index.js b/handlers/auth/strategies/index.js new file mode 100755 index 000000000..80e3097be --- /dev/null +++ b/handlers/auth/strategies/index.js @@ -0,0 +1,12 @@ +var passport = require('koa-passport'); + + +passport.use(require('./localStrategy')); + +passport.use(require('./facebookStrategy')); +passport.use(require('./googleStrategy')); +passport.use(require('./yandexStrategy')); +passport.use(require('./githubStrategy')); +passport.use(require('./vkontakteStrategy')); + + diff --git a/handlers/auth/strategies/localStrategy.js b/handlers/auth/strategies/localStrategy.js new file mode 100755 index 000000000..9c5826fa8 --- /dev/null +++ b/handlers/auth/strategies/localStrategy.js @@ -0,0 +1,54 @@ +var User = require('users').User; + +const LocalStrategy = require('passport-local').Strategy; +const co = require('co'); + +function UserAuthError(message) { + this.message = message; +} + + +// done(null, user) +// OR +// done(null, false, { message: }) <- 3rd arg format is from built-in messages of strategies +module.exports = new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' +}, function(email, password, done) { + + co(function*() { + + if (!email) throw new UserAuthError('Укажите email.'); + if (!password) throw new UserAuthError('Укажите пароль.'); + + // anti-bruteforce pause + yield function(callback) { + setTimeout(callback, 100); + }; + + var user = yield User.findOne({email: email}).exec(); + + if (!user) { + throw new UserAuthError('Нет такого пользователя.'); + } + + if (!user.checkPassword(password)) { + throw new UserAuthError('Пароль неверен.'); + } + + if (!user.verifiedEmail) { + throw new UserAuthError('Ваш email не подтверждён, проверьте почту. Также можно запросить подтверждение заново.'); + } + + return user; + }).then(function(user) { + done(null, user); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +}); diff --git a/handlers/auth/strategies/vkontakteStrategy.js b/handlers/auth/strategies/vkontakteStrategy.js new file mode 100755 index 000000000..bf52af178 --- /dev/null +++ b/handlers/auth/strategies/vkontakteStrategy.js @@ -0,0 +1,42 @@ +var User = require('users').User; +const VkontakteStrategy = require('passport-vkontakte').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); + +/* +result: +{ id: 1818925, + first_name: 'Юля', + last_name: 'Дубовик', + sex: 1, + screen_name: 'id1818925', + photo: 'http://cs5475.vk.me/u1818925/e_974b5ece.jpg' +} +(email in oauthResponse) +*/ + +module.exports = new VkontakteStrategy({ + clientID: config.auth.providers.vkontakte.appId, + clientSecret: config.auth.providers.vkontakte.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/vkontakte", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, oauthResponse, profile, done) { + + // Vkontakte gives email in oauthResponse, not in profile (which is 1 more request) + if (!oauthResponse.email) { + return done(null, false, {message: "При входе разрешите доступ к email. Он используется для идентификации пользователя."}); + } + + + profile.emails = [ + {value: oauthResponse.email} + ]; + + // vkontakte assumes this to be a real name + profile.realName = profile.displayName; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/strategies/yandexStrategy.js b/handlers/auth/strategies/yandexStrategy.js new file mode 100755 index 000000000..e9d356531 --- /dev/null +++ b/handlers/auth/strategies/yandexStrategy.js @@ -0,0 +1,60 @@ +var User = require('users').User; +const YandexStrategy = require('passport-yandex').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); + +/* + profile: { + "provider": "yandex", + "id": "11111", + "username": "iliakan", + "displayName": "iliakan", + "name": { + "familyName": "Ilya", + "givenName": "Kantor" + }, + "gender": "male", + "emails": [ + { + "value": "login@yandex.ru" + } + ], + "_raw": "{\"first_name\": \"Ilya\", \"last_name\": \"Kantor\", \"display_name\": \"iliakan\", \"emails\": [\"login@yandex.ru\"], \"default_email\": \"login@yandex.ru\", \"real_name\": \"Ilya Kantor\", \"default_avatar_id\": \"11111\", \"login\": \"login\", \"sex\": \"male\", \"id\": \"11111\"}", + "_json": { + "first_name": "Ilya", + "last_name": "Kantor", + "display_name": "iliakan", + "emails": [ + "login@yandex.ru" + ], + "default_email": "login@yandex.ru", + "real_name": "Ilya Kantor", + "default_avatar_id": "11111", + "login": "login", + "sex": "male", + "id": "11111" + }, + "realName": "Ilya Kantor" + } +*/ + +module.exports = new YandexStrategy({ + clientID: config.auth.providers.yandex.appId, + clientSecret: config.auth.providers.yandex.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/yandex", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + /* jshint -W106 */ + profile.realName = profile._json.real_name; + + // there is no way to know if it is a default avatar or not + // if user has no avatar, this gives us the "default yandex blank avatar" + profile.photos = [{ + value: `https://avatars.yandex.net/get-yapic/${profile._json.default_avatar_id}/islands-200` + }]; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/templates/forgot-email.jade b/handlers/auth/templates/forgot-email.jade new file mode 100755 index 000000000..d89e96bfa --- /dev/null +++ b/handlers/auth/templates/forgot-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Восстановление доступа на javascript.ru + p Для восстановления доступа перейдите, пожалуйста, по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/auth/templates/forgot-form.jade b/handlers/auth/templates/forgot-form.jade new file mode 100644 index 000000000..3fa036398 --- /dev/null +++ b/handlers/auth/templates/forgot-form.jade @@ -0,0 +1,33 @@ +include /bem + ++b(class=['login-form', (inModal ? '_modal' : '_inline')]) + +e('form').form(action="#" data-form="forgot") + + if inModal + +e.line.__header + +e('h4').title Восстановление пароля + + else + p + | Если у вас еще нет аккаунта, вы можете  + +e('button').button-link.__register(type="button" data-switch="register-form") зарегистрироваться + + +e.body + +e.line.__notification(data-notification) + +e.line + +e('label').label(for="forgot-email") Email: + +b('span').text-input.__input + +e('input').control#forgot-email(name="email" type="email" autofocus) + +e.line + +b('button').button._action.__submit(type="submit") + +e('span').text Восстановить пароль + +e.line.__footer + +e('button').button-link(type="button" data-switch="login-form") Вход + =" " + +e('span').separator / + =" " + +e('button').button-link(data-switch="register-form") Регистрация + if inModal + +e('a').close-link.tablet-only.modal__close Отмена + include login-form-footer + diff --git a/handlers/auth/templates/forgot-recover.jade b/handlers/auth/templates/forgot-recover.jade new file mode 100644 index 000000000..2ed50c865 --- /dev/null +++ b/handlers/auth/templates/forgot-recover.jade @@ -0,0 +1,31 @@ +extends /layouts/body + +block append variables + - var title = "Восстановление пароля" + +block body + +b.page + +e.inner + +b.main._width-limit + + +b.recover + +e('h1').title Восстановление пароля + + if error + //- __message - на будущее, даёт класс recover__message для стилизации в контексте recover + +b.notification._message._error.__message + +e.content= error + +e('button').close(title="Закрыть") + + +e('form')(action="/auth/forgot-recover" method="POST").content + input(type="hidden" name="passwordResetToken" value=passwordResetToken) + +e.controls + +e.label-wrap + +e('label').label(for="newpass") Новый пароль + +e.input-wrap + +b.text-input._small.__input + +e('input').control#newpass(type="password" name="password" autofocus) + +e.save-wrap + +b('button').button._action.__save + +e('text') Сохранить пароль + diff --git a/handlers/auth/templates/login-form-footer.jade b/handlers/auth/templates/login-form-footer.jade new file mode 100755 index 000000000..57a13832a --- /dev/null +++ b/handlers/auth/templates/login-form-footer.jade @@ -0,0 +1,5 @@ + ++e.line.__social-logins + +e('h5').social-logins-title Вход через социальные сети + =" " + include providers diff --git a/handlers/auth/templates/login-form.jade b/handlers/auth/templates/login-form.jade new file mode 100755 index 000000000..3443d5c0a --- /dev/null +++ b/handlers/auth/templates/login-form.jade @@ -0,0 +1,40 @@ +include /bem + ++b(class=['login-form', (inModal ? '_modal' : '_inline')] data-form="login") + +e('form').form(action="#") + + if inModal + +e.line.__header + +e('h4').title Вход в сайт + +e.header-aside + +e('button').button-link.__register(type="button" data-switch="register-form") регистрация + else + p + | Если у вас еще нет аккаунта, вы можете  + +e('button').button-link.__register(type="button" data-switch="register-form") зарегистрироваться + + +e.body + + //- + +e.line.__notification + +e.info + | Авторизация работает в тестовом режиме. О любых проблемах и странностях сообщайте, пожалуйста, на github. + + +e.line.__notification(data-notification) + + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" autofocus) + +e.line + +e('label').label(for="auth-password") Пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e('button').aside.__forgot.__button-link(type="button" data-switch="forgot-form") Забыли? + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + if inModal + +e('a').close-link.tablet-only.modal__close Отмена + include login-form-footer + diff --git a/handlers/auth/templates/login.jade b/handlers/auth/templates/login.jade new file mode 100644 index 000000000..049389351 --- /dev/null +++ b/handlers/auth/templates/login.jade @@ -0,0 +1,18 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + - var sitetoolbar = true + - var title = 'Вход в систему' + +block append head + !=js("auth", {defer: true}) + +block content + + script window.authOptions = !{JSON.stringify(authOptions)} + + div#auth-form + diff --git a/handlers/auth/templates/popup-failure.jade b/handlers/auth/templates/popup-failure.jade new file mode 100755 index 000000000..a9a507762 --- /dev/null +++ b/handlers/auth/templates/popup-failure.jade @@ -0,0 +1,49 @@ +doctype html +html + title Отказ в авторизации +body + script var reason = !{JSON.stringify(reason)} + + //- window.authProvider for login is AuthModal, + //- for managing providers AuthProvidersManager + script. + + !(function() { + if (!window.opener) { + window.close(); + return; + } + var accessAllowed = true; + try { + window.opener.document.documentElement; + } catch (e) { + accessAllowed = false; + } + if (accessAllowed) { + // https and same domain in both windows + if (window.opener.authForm) { + window.opener.focus(); + window.opener.authForm.onAuthFailure(reason); + } + window.close(); + } else { + // probably the opener is http:// and we're https:// ? + if (location.protocol == 'https:') { + var form = document.createElement('form'); + form.method = 'POST'; + form.action = 'http://' + location.host + '/auth/popup-failure'; + var input = document.createElement('input'); + input.name = 'reason'; + input.value = reason; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + } else { + // we're http and access disallowed, probably the opener window navigated away from the site + window.close(); + } + } + })(); + + + diff --git a/handlers/auth/templates/popup-success.jade b/handlers/auth/templates/popup-success.jade new file mode 100755 index 000000000..41217dd00 --- /dev/null +++ b/handlers/auth/templates/popup-success.jade @@ -0,0 +1,39 @@ +doctype html +html + title Успешная авторизация +body + script var user = !{escapeJSON(user.getInfoFields())}; + + script. + + !(function() { + if (!window.opener) { + window.close(); + return; + } + + var accessAllowed = true; + try { + window.opener.document.documentElement; + } catch(e) { + accessAllowed = false; + } + + if (accessAllowed) { + // https and same domain in both windows + if (window.opener.authForm) { + window.opener.focus(); + window.opener.authForm.onAuthSuccess(user); + } + window.close(); + } else { + // probably the opener is http:// and we're https:// ? + if (location.protocol == 'https:') { + window.location.replace('http://' + location.host + '/auth/popup-success'); + } else { + // we're http and access disallowed, probably the opener window navigated away from the site + window.close(); + } + } + + })(); diff --git a/handlers/auth/templates/providers.jade b/handlers/auth/templates/providers.jade new file mode 100755 index 000000000..e79b6519d --- /dev/null +++ b/handlers/auth/templates/providers.jade @@ -0,0 +1,10 @@ + ++b('button').social-login._facebook.__social-login(data-provider="facebook") Facebook +=" " ++b('button').social-login._google.__social-login(data-provider="google") Google+ +=" " ++b('button').social-login._vkontakte.__social-login(data-provider="vkontakte") Вконтакте +=" " ++b('button').social-login._github.__social-login(data-provider="github") Github +=" " ++b('button').social-login._yandex.__social-login(data-provider="yandex") Яндекс diff --git a/handlers/auth/templates/register-form.jade b/handlers/auth/templates/register-form.jade new file mode 100644 index 000000000..ccb0a8752 --- /dev/null +++ b/handlers/auth/templates/register-form.jade @@ -0,0 +1,47 @@ +include /bem + ++b(class=['login-form', (inModal ? '_modal' : '_inline')]) + +e('form').form(action="#" data-form="register") + + if inModal + + +e.line.__header + +e('h4').title Регистрация + +e.header-aside + +e('button').button-link(type="button" data-switch="login-form") вход + + else + p + | Если у вас уже есть аккаунт, вы можете  + +e('button').button-link(type="button" data-switch="login-form") войти + + +e.body + + +e.line.__notification(data-notification) + + +e.line + +e('label').label(for="register-email") Email: + +b('span').text-input.__input + +e('input').control#register-email(name="email" type="email" required autofocus) + +e.line + +e('label').label(for="register-displayName") Имя пользователя: + +b('span').text-input.__input + +e('input').control#register-displayName(name="displayName" required) + +e.line + +e('label').label(for="register-password") Пароль: + +b('span').text-input.__input + +e('input').control#register-password(type="password" name="password" required minlength="4") + +e.line.__footer + +b('button').button._action.submit(type="submit") + +e('span').text Зарегистрироваться + if inModal + +e('a').close-link.tablet-only.modal__close Отмена + + +e.line.__agreement + | Регистрируясь, вы принимаете + = ' ' + a(href="/agreement") пользовательское соглашение + | . + include login-form-footer + + diff --git a/handlers/auth/templates/verify-registration-email.jade b/handlers/auth/templates/verify-registration-email.jade new file mode 100755 index 000000000..48506f022 --- /dev/null +++ b/handlers/auth/templates/verify-registration-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Подтверждение email на javascript.ru + p Для завершения регистрации перейдите, пожалуйста, по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/auth/test/.jshintrc b/handlers/auth/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/auth/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/auth/test/e2e/facebook.js b/handlers/auth/test/e2e/facebook.js new file mode 100755 index 000000000..0a4e1f22e --- /dev/null +++ b/handlers/auth/test/e2e/facebook.js @@ -0,0 +1,100 @@ +const webdriver = require('selenium-webdriver'); +const path = require('path'); +const app = require('app'); +const db = require('lib/dataUtil'); +const config = require('config'); +const By = require('selenium-webdriver').By; +const until = require('selenium-webdriver').until; +const tunnel = require('lib/e2eTunnel'); +const browser = require('lib/selenium/browser'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); + + +// disabled until fixed +describe('facebook', function() { + + var driver, server; + + before(function*() { + yield* db.loadModels(fixtures, {reset: true}); + + yield* tunnel(); + + driver = browser(); + + server = app.listen(config.server.port); + server.unref(); + }); + + it('logs in', function*() { + + var i = 0; + driver.get(config.test.e2e.siteHost + '/folder'); + + driver.findElement(By.css('button.sitetoolbar__login')).click(); + + var btn = By.css('button[data-provider="facebook"]'); + driver.wait(until.elementLocated(btn)); + driver.findElement(btn).click(); + + driver.getAllWindowHandles().then(function(handles) { + driver.switchTo().window(handles[1]); // new window + }); + + driver.wait(until.elementLocated(By.id('pass'))); + + driver.findElement(By.id('email')).sendKeys(config.auth.providers.facebook.testCredentials.email); + driver.findElement(By.id('pass')).sendKeys(config.auth.providers.facebook.testCredentials.pass); + driver.findElement(By.id('pass')).sendKeys(webdriver.Key.RETURN); + + // after login there are 2 possibilities + // 1) First time login to facebook => need to click __CONFIRM__ + // 2) Already authorized app => will proceed (usually this is the case) + + // if 1) is correct, the window will close, + // driver.wait(until.elementLocated(By.name('__CONFIRM__')) will not work + + driver.wait(function() { + return driver.getAllWindowHandles().then(function(handles) { + return handles.length == 1; // wait to see if only 1 window left (works) + }); + }, 5000).then(function() { + // only 1 window means we're done w/ logging in + return webdriver.promise.fulfilled(); + + }, function() { + // 2 windows means we need to press __CONFIRM__ (or something went wrong?) + + // if 2 windows => confirm and wait again + driver.findElement(By.name('__CONFIRM__')).click(); + + return driver.wait(function() { + return driver.getAllWindowHandles().then(function(handles) { + return handles.length == 1; // wait to see if only 1 window left (works) + }); + }, 5000); + + }); + + driver.getAllWindowHandles().then(function(handles) { + driver.switchTo().window(handles[0]); // main window + }); + + + // wait for either success or throw + yield function(callback) { + driver.wait(until.elementLocated(By.css('.sitetoolbar__user'))).then(function() { + callback(); + }, function(err) { + throw err; + }); + }; + + }); + + // callback after makes sure that the browser actually closed + after(function(callback) { + driver.quit().then(callback); + }); + +}); diff --git a/handlers/auth/test/fixtures/db.js b/handlers/auth/test/fixtures/db.js new file mode 100755 index 000000000..c91de9820 --- /dev/null +++ b/handlers/auth/test/fixtures/db.js @@ -0,0 +1,50 @@ +require('users').User; +require('tutorial').Article; + + +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "profileName": "iliakan", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000002", + "created": new Date(2014,0,1), + "displayName": "tester", + "email": "tester@mail.com", + "profileName": "tester", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000003", + "created": new Date(2014,0,1), + "displayName": "vasya", + "profileName": "vasya", + "email": "vasya@mail.com", + "password": "1234", + "verifiedEmail": false + } +]; + + +exports.Article = [ + { + "_id": '000000000000000000000010', + "isFolder" : true, + "content" : "# Введение\n\nПро язык JavaScript и окружение для разработки на нём.", + "weight" : 1, + "slug" : "folder", + "title" : "Введение" + }, + { + "parent" : '000000000000000000000010', + "_id": '000000000000000000000011', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 1, + "slug" : "article", + "title" : "Введение в JavaScript" + } +]; diff --git a/handlers/auth/test/server/local.js b/handlers/auth/test/server/local.js new file mode 100755 index 000000000..6c6f607c4 --- /dev/null +++ b/handlers/auth/test/server/local.js @@ -0,0 +1,128 @@ +/* globals describe, it, before */ + +const db = require('lib/dataUtil'); +const mongoose = require('mongoose'); +const path = require('path'); +const request = require('supertest'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); +const app = require('app'); +const assert = require('better-assert'); + +describe('Authorization', function() { + + var server; + before(function*() { + + yield* db.loadModels(fixtures, {reset: true}); + + // APP.LISTEN() USES A RANDOM PORT, + // which superagent gets as server.address().port + server = app.listen(); + }); + + after(function() { + server.close(); + }); + + describe('login', function() { + + it('should require verified email', function(done) { + request(server) + .post('/auth/login/local') + .send({ + email: fixtures.User[2].email, + password: fixtures.User[2].password + }) + .expect(401, done); + }); + }); + + describe('login flow', function() { + var agent; + + before(function() { + agent = request.agent(server); + }); + + it('should log in when email is verified', function(done) { + agent + .post('/auth/login/local') + .send({ + email: fixtures.User[0].email, + password: fixtures.User[0].password + }) + .expect(200, done); + }); + + it('should log out', function(done) { + agent + .post('/auth/logout') + .send() + .expect(302, done); + }); + + it('should return error when repeat logout (the session is incorrect)', function(done) { + agent + .post('/auth/logout') + .send() + .expect(401, done) + }); + }); + + describe("register", function() { + var agent; + + before(function() { + agent = request.agent(server); + }); + + var userData = { + email: Math.random() + "@gmail.com", + displayName: "Random guy", + password: "somepass" + }; + + it('should create a new user', function(done) { + agent + .post('/auth/register') + .send(userData) + .expect(201, done); + }); + + it('should not be logged in', function(done) { + agent + .post('/auth/logout') + .send('') + .expect(401, done); + }); + /* + + .end(function(err, res) { + res.body.email.should.be.eql(userData.email); + res.body.displayName.should.be.eql(userData.displayName); + done(err); + }); + it('should be log in the new user', function(done) { + request(server) + .post('/auth/login/local') + .send({email: userData.email, password: userData.password}) + .expect(200, done); + }); + */ + + it('should fail to create a new user with same email', function(done) { + request(server) + .post('/auth/register') + .send(userData) + .set('Accept', 'application/json') + .expect(400) + .end(function(err, res) { + if (err) return done(err); + res.body.errors.email.should.exist; + done(); + }); + }); + + }); + +}); diff --git a/handlers/bodyParser.js b/handlers/bodyParser.js new file mode 100755 index 000000000..c536bbe70 --- /dev/null +++ b/handlers/bodyParser.js @@ -0,0 +1,42 @@ +const bodyParser = require('koa-bodyparser'); +const PathListCheck = require('pathListCheck'); + +function BodyParser() { + this.ignore = new PathListCheck(); + + // default limits are: + // formLimit: limit of the urlencoded body. If the body ends up being larger than this limit, a 413 error code is returned. + // Default is 56kb + // jsonLimit: limit of the json body. + // Default is 1mb + this.parser = bodyParser({ + formLimit: '1mb', // 56kb is not enough for mandrill webhook which is urlencoded + jsonLimit: '1mb' + }); +} + +BodyParser.prototype.middleware = function() { + var self = this; + + return function*(next) { + + if (!self.ignore.check(this.path)) { + this.log.debug("bodyParser will parse"); + + yield* self.parser.call(this, next); + + this.log.debug("bodyParser done parse"); + } else { + this.log.debug("bodyParser skip"); + } + + yield* next; + }; +}; + + +exports.init = function(app) { + + app.bodyParser = new BodyParser(); + app.use(app.bodyParser.middleware()); +}; diff --git a/handlers/cache/index.js b/handlers/cache/index.js new file mode 100755 index 000000000..8d398505a --- /dev/null +++ b/handlers/cache/index.js @@ -0,0 +1,7 @@ +exports.CacheEntry = require('./models/cacheEntry'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/cache', __dirname)); +}; diff --git a/handlers/cache/models/cacheEntry.js b/handlers/cache/models/cacheEntry.js new file mode 100755 index 000000000..3ebac1334 --- /dev/null +++ b/handlers/cache/models/cacheEntry.js @@ -0,0 +1,182 @@ +// A mongoose entry for generic tagged caching + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +// by default a generation may took that long ms maximally +const GENERATING_TIME_LIMIT_DEFAULT = 3000; + +const schema = new Schema({ + key: { + type: String, + required: true, + unique: true + }, + + tags: { + type: [String], + index: true + }, + + value: { + type: {}, + validate: [ + { + validator: function(value) { + return !!(this.generatingStartTimestamp || value !== undefined); + }, + msg: "Must have value." + } + ] + + }, + + generatingStartTimestamp: Date, + generatingTimeLimit: Number, + + // when to expire? + // no expireAt means it won't expire + // mongo autoclears the documents every minute + expireAt: { + type: Date + } + +}); + +schema.index({ "expireAt": 1 }, { expireAfterSeconds: 0 }); + + +// get value in a non-waiting way +// skip generating values +schema.statics.get = function* (key) { + // try to find it + var result = yield this.findOne({key: key}).exec(); + + // no value - fine.. + if (!result) return result; + + // if it's actually a generating value, consider that as no-value (yet) + if (result.generatingStartTimestamp) return null; + + return result.value; +}; + +// generate the value using *generator +// or get it from db (if someone else has generated it) +// --> never runs generators in parallel +// --> never returns stale values +schema.statics.getOrGenerate = function* (doc, generator) { + var CacheEntry = this; + // try to find it + var result; + + // disable cache for development + if (process.env.NODE_ENV == 'development') { + return yield generator(); + } + + result = yield CacheEntry.findOne({key: doc.key}).exec(); + + var generatingStartTimestamp; + + // no value - fine.. + if (!result) { + generatingStartTimestamp = Date.now(); + try { + yield new CacheEntry({ key: doc.key, generatingStartTimestamp: generatingStartTimestamp }).persist(); + } catch (e) { + // lost the race, someone has already persisted it and started generating + if (e.code == 11000) { + // let's try again + return yield CacheEntry.getOrGenerate(doc, generator); + } else { + throw e; + } + } + + var value = yield generator(); + + // in the case + // -> we started to generate + // -> set or remove is called for the key (!) + // -> we finished generating + // we consider set/remove here to be more important because this decision is taken LATER than the generation + // maybe something important has changed + // so we ditch the generated value and retry + var old = yield this.findOneAndUpdate( + // replace the very exact record we've made + // it's possible that someone called set(doc, value) and replaced it while we were generating + { key: doc.key, generatingStartTimestamp: generatingStartTimestamp }, + // $set every field of the document (to fully replace, not update) + // setting to undefined doesn't work here (mongoose bug?) + { + key: doc.key, + tags: doc.tags || [], + value: value, + expireAt: doc.expireAt || null, + generatingStartTimestamp: null, + generatingTimeLimit: null + }, + // don't generate a new document, return the old one + { new: false, upsert: false } + ).exec(); + + if (!old) { + // while we were generating, someone called set on the value (ouch!) or removed it (ouch ouch!) + // that's because something has changed. + // let's regenerate the value + return yield CacheEntry.getOrGenerate(doc, generator); + } + + return value; + } + + // otherwise check if it's actually a generating value + generatingStartTimestamp = result.generatingStartTimestamp; + + // not generating - fine.. + if (!generatingStartTimestamp) return result.value; + + // now check if we're waiting for too long + var timeLimit = result.generatingTimeLimit || GENERATING_TIME_LIMIT_DEFAULT; + +// console.log("Compare", +Date.now(), +generatingStartTimestamp + timeLimit, Date.now() > generatingStartTimestamp + timeLimit); + if (Date.now() > +generatingStartTimestamp + timeLimit) { + // too long wait, consider the value absent + // delete this very record: not just any of this key, but actually the outdated one + // (maybe someone else has done that already) + yield CacheEntry.destroy({key: doc.key, generatingStartTimestamp: result.generatingStartTimestamp}); + // ...and try again + return yield CacheEntry.getOrGenerate(doc, generator); + } + + // waiting for not very long, someone is working on it, + // let's pause a little bit + yield function(callback) { + setTimeout(callback, 100); + }; + + // ...and retry + return yield CacheEntry.getOrGenerate(doc, generator); +}; + + +// cache set, replaces and returns the old value if exists +schema.statics.set = function* (doc) { + return yield this.findOneAndUpdate( + { key: doc.key }, + { + key: doc.key, + tags: doc.tags || [], + value: doc.value, + expireAt: doc.expireAt || null, + generatingStartTimestamp: null, + generatingTimeLimit: null + }, + {new: false, upsert: true} + ).exec(); +}; + + +module.exports = mongoose.model('CacheEntry', schema); + diff --git a/handlers/cache/router.js b/handlers/cache/router.js new file mode 100755 index 000000000..0e82792f8 --- /dev/null +++ b/handlers/cache/router.js @@ -0,0 +1,14 @@ +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var CacheEntry = require('./models/cacheEntry'); +var mustBeAdmin = require('auth').mustBeAdmin; +var _ = require('lodash'); + +var router = module.exports = new Router(); + +router.get('/destroy', mustBeAdmin, function*() { + yield CacheEntry.destroy(); + + this.body = 'done ' + new Date(); +}); + diff --git a/handlers/cache/test/.jshintrc b/handlers/cache/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/cache/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/cache/test/cacheEntry.js b/handlers/cache/test/cacheEntry.js new file mode 100755 index 000000000..7a60cb5a9 --- /dev/null +++ b/handlers/cache/test/cacheEntry.js @@ -0,0 +1,97 @@ +var CacheEntry = require('../models/cacheEntry'); +var co = require('co'); + +describe('CacheEntry', function() { + + describe('getOrGenerate', function() { + + before(function*() { + yield CacheEntry.destroy({}); + }); + + var value = Math.random(); + + var called = 0; + + function* generateLong() { + yield function(callback) { + setTimeout(callback, 150); + }; + + called++; + + return value; + } + + it("Inserts the new value instantly", function*() { + var result = yield CacheEntry.getOrGenerate({ key: 'test' }, generateLong); + called.should.be.eql(1); + result.should.be.eql(value); + }); + + it("Can find it", function*() { + var result = yield CacheEntry.getOrGenerate({ key: 'test' }, generateLong); + called.should.be.eql(1); + result.should.be.eql(value); + }); + + + }); + + + describe('getOrGenerate', function() { + + beforeEach(function*() { + yield CacheEntry.destroy({}); + }); + + var value = Math.random(); + + var called = 0; + + function* generateLong() { + yield function(callback) { + setTimeout(callback, 150); + }; + + called++; + + return value; + } + + describe("when many cache requests", function() { + it("Should run the generator only once", function*() { + + var results = yield [ + CacheEntry.getOrGenerate({ key: 'test' }, generateLong), + CacheEntry.getOrGenerate({ key: 'test' }, generateLong), + CacheEntry.getOrGenerate({ key: 'test' }, generateLong) + ]; + called.should.be.eql(1); + results.forEach(function(result) { + result.should.be.eql(value); + }); + + }); + }); + + describe("when set while generating", function() { + it("Should use the latest (generated) value", function*() { + var result = yield [ + CacheEntry.getOrGenerate({key: 'test'}, generateLong), + co(function*() { + yield function(callback) { + setTimeout(callback, 50); + }; + + yield* CacheEntry.set({key: 'test', value: 'set'}); + }) + ]; + result[0].should.be.eql('set'); + }); + }); + + }); + + +}); diff --git a/handlers/conditional.js b/handlers/conditional.js new file mode 100755 index 000000000..70b19f766 --- /dev/null +++ b/handlers/conditional.js @@ -0,0 +1,27 @@ +var conditional = require('koa-conditional-get'); +var etag = require('koa-etag'); + +exports.init = function(app) { + // use it upstream from etag so + // that they are present + + // conditional triggers AFTER other middleware and returns 304/empty body if etag/modified matches + app.use(conditional()); + + // add etags AFTER every request (even POST), using file/body content and crc32 + app.use(etag()); + + // set expires to this.expires + app.use(function* (next) { + yield *next; + + if (!this.expires) return; + + if (process.env.NODE_ENV == 'development') { + // override any value with 2 secs, we don't need long expires to block our changes in dev + this.expires = 2; + } + this.set('Expires', new Date(Date.now() + this.expires*1e3).toUTCString()); + }); +}; + diff --git a/handlers/courses/client/course/index.js b/handlers/courses/client/course/index.js new file mode 100644 index 000000000..1d201b7c8 --- /dev/null +++ b/handlers/courses/client/course/index.js @@ -0,0 +1,53 @@ +var newsletter = require('newsletter/client'); +var Spinner = require('client/spinner'); +var xhr = require('client/xhr'); + +initNewsletterForm(); + +initSignupButton(); + +function initNewsletterForm() { + + var form = document.querySelector('[data-newsletter-subscribe-form]'); + if (!form) return; + + form.onsubmit = function(event) { + event.preventDefault(); + newsletter.submitSubscribeForm(form); + }; + +} + +function initSignupButton() { + + var link = document.querySelector('[data-group-signup-link]'); + if (!link) return; + + link.onclick = function(event) { + + if (window.currentUser) { + return; + } + + event.preventDefault(); + + var spinner = new Spinner({ + elem: link, + size: 'small', + class: 'submit-button__spinner', + elemClass: 'submit-button_progress' + }); + spinner.start(); + + require.ensure('auth/client/authModal', function() { + spinner.stop(); + var AuthModal = require('auth/client/authModal'); + new AuthModal({ + callback: function() { + window.location.href = link.href; + } + }); + }, 'authClient'); + }; + +} diff --git a/handlers/courses/client/feedback/index.js b/handlers/courses/client/feedback/index.js new file mode 100644 index 000000000..be11a7e03 --- /dev/null +++ b/handlers/courses/client/feedback/index.js @@ -0,0 +1,64 @@ +var thumb = require('client/image').thumb; +var PhotoLoadWidget = require('../lib/photoLoadWidget'); +var notification = require('client/notification'); + +function init() { + + initFeedbackForm(); + +} + + + + +function initFeedbackForm() { + + var feedbackForm = document.querySelector('[data-feedback-form]'); + + feedbackForm.onsubmit = function(event) { + var starInput = [].filter.call( + feedbackForm.querySelectorAll('[name="stars"]'), + function(input) { return input.checked; } + ); + + if (!starInput.length) { + // actually an error, but let's show it nicely + new notification.Success("Поставьте, пожалуйста, курсу оценку."); + document.querySelector('.rating-chooser').parentNode.scrollIntoView(); + window.scrollBy(0, -100); + event.preventDefault(); + return; + } + + if (!feedbackForm.elements.content.value) { + new notification.Error("Вы забыли написать текст отзыва."); + feedbackForm.elements.content.scrollIntoView(); + window.scrollBy(0, -100); + event.preventDefault(); + return; + } + + }; + + + var photoWidgetElem = feedbackForm.querySelector('[data-photo-load]'); + + new PhotoLoadWidget({ + elem: photoWidgetElem, + onSuccess: function(imgurImage) { + feedbackForm.querySelector('.course-feedback__userpic-img').src = thumb(imgurImage.link, 86, 86); + photoWidgetElem.querySelector('i').style.backgroundImage = `url('${thumb(imgurImage.link, 64, 64)}')`; + photoWidgetElem.querySelector('input').value = imgurImage.imgurId; + }, + onLoadStart: function() { + photoWidgetElem.classList.add('modal-overlay_light'); + feedbackForm.querySelector('button[type="submit"]').disabled = true; + }, + onLoadEnd: function() { + photoWidgetElem.classList.remove('modal-overlay_light'); + feedbackForm.querySelector('button[type="submit"]').disabled = false; + } + }); +} + +init(); diff --git a/handlers/courses/client/frontpage/index.js b/handlers/courses/client/frontpage/index.js new file mode 100644 index 000000000..771e02203 --- /dev/null +++ b/handlers/courses/client/frontpage/index.js @@ -0,0 +1,45 @@ + +initParticipantsSlider(); + +function initParticipantsSlider() { + var slider = document.querySelector('[data-participants-slider]'); + + var list = slider.querySelector('ul'); + var arrowLeft = slider.querySelector('.participants-logos__arr_left'); + var arrowRight = slider.querySelector('.participants-logos__arr_right'); + + var transformX = 0; + + render(); + + arrowLeft.onclick = function() { + transformX -= list.clientWidth; + if (transformX < 0) transformX = 0; + + render(); + }; + + arrowRight.onclick = function() { + transformX = Math.min(transformX + list.clientWidth, list.scrollWidth - list.clientWidth); + render(); + }; + + function render() { + + list.style.transform = `translateX(${-transformX}px)`; + + if (transformX === 0) { + slider.classList.add('participants-logos__slider_disable_left'); + } else { + slider.classList.remove('participants-logos__slider_disable_left'); + } + + if (transformX == list.scrollWidth - list.clientWidth) { + slider.classList.add('participants-logos__slider_disable_right'); + } else { + slider.classList.remove('participants-logos__slider_disable_right'); + } + + } + +} diff --git a/handlers/courses/client/lib/photoLoadWidget.js b/handlers/courses/client/lib/photoLoadWidget.js new file mode 100644 index 000000000..4b7b19ff3 --- /dev/null +++ b/handlers/courses/client/lib/photoLoadWidget.js @@ -0,0 +1,64 @@ +var promptSquarePhoto = require('photoCut').promptSquarePhoto; +var notification = require('client/notification'); +var xhr = require('client/xhr'); +var Spinner = require('client/spinner'); + +function PhotoLoadWidget({elem, onSuccess, onLoadStart, onLoadEnd}) { + var link = elem.querySelector('a'); + var photoDiv = elem.querySelector('i'); + var input = elem.querySelector('input'); + + link.onclick = function(e) { + e.preventDefault(); + promptSquarePhoto({ + minSize: 160, + onSuccess: uploadPhoto + }); + }; + + + var spinner = new Spinner({ + elem: photoDiv, + size: 'small' + }); + + function uploadPhoto(file) { + + var formData = new FormData(); + formData.append("photo", file); + + var request = xhr({ + method: 'POST', + url: '/imgur/upload', + body: formData, + noDocumentEvents: true, + normalStatuses: [200, 400] + }); + + request.addEventListener('loadstart', function() { + spinner.start(); + onLoadStart(); + }); + request.addEventListener('loadend', function() { + spinner.stop(); + onLoadEnd(); + }); + + request.addEventListener('fail', (event) => { + new notification.Error("Ошибка загрузки: " + event.reason); + }); + + request.addEventListener('success', (event) => { + if (request.status == 400) { + new notification.Error("Неверный тип файла или изображение повреждено."); + } else { + onSuccess(event.result); + } + }); + + } + + +} + +module.exports = PhotoLoadWidget; diff --git a/handlers/courses/client/materials/index.js b/handlers/courses/client/materials/index.js new file mode 100644 index 000000000..3a7a0e2e1 --- /dev/null +++ b/handlers/courses/client/materials/index.js @@ -0,0 +1,36 @@ +var xhr = require('client/xhr'); +var notification = require('client/notification'); + +function init() { + + initShouldNotifyMaterials(); + +} + + + + +function initShouldNotifyMaterials() { + + var checkbox = document.querySelector('[data-should-notify-materials]'); + var form = checkbox.closest('form'); + + checkbox.onchange = function() { + + var request = xhr({ + method: 'PATCH', + url: form.action, + body: { + id: form.elements.id.value, + shouldNotifyMaterials: form.elements.shouldNotifyMaterials.checked + } + }); + + request.addEventListener('success', function(event) { + new notification.Success("Настройка сохранена."); + }); + }; + +} + +init(); diff --git a/handlers/courses/client/participantDetails/index.js b/handlers/courses/client/participantDetails/index.js new file mode 100644 index 000000000..57a9b53b9 --- /dev/null +++ b/handlers/courses/client/participantDetails/index.js @@ -0,0 +1,29 @@ +var thumb = require('client/image').thumb; +var PhotoLoadWidget = require('../lib/photoLoadWidget'); + +function init() { + + initPhotoLoadWidget(); + +} + + +function initPhotoLoadWidget() { + var photoWidgetElem = document.querySelector('[data-photo-load]'); + if (!photoWidgetElem) return; + new PhotoLoadWidget({ + elem: photoWidgetElem, + onSuccess: function(imgurImage) { + photoWidgetElem.querySelector('i').style.backgroundImage = `url('${thumb(imgurImage.link, 64, 64)}')`; + photoWidgetElem.querySelector('input').value = imgurImage.imgurId; + }, + onLoadStart: function() { + photoWidgetElem.classList.add('modal-overlay_light'); + }, + onLoadEnd: function() { + photoWidgetElem.classList.remove('modal-overlay_light'); + } + }); +} + +init(); diff --git a/handlers/courses/client/signup/contactForm.js b/handlers/courses/client/signup/contactForm.js new file mode 100644 index 000000000..dc5b053e1 --- /dev/null +++ b/handlers/courses/client/signup/contactForm.js @@ -0,0 +1,33 @@ +var delegate = require('client/delegate'); + +class ContactForm { + constructor(options) { + this.elem = options.elem; + + this.elems = {}; + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + this.elem.onsubmit = this.onSubmit.bind(this); + } + + focus() { + this.elems.contactName.focus(); + } + + onSubmit(event) { + event.preventDefault(); + + this.elem.dispatchEvent(new CustomEvent('contact-submit', { + detail: { + name: this.elems.contactName.value, + phone: this.elems.contactPhone.value + } + })); + + } + +} + +module.exports = ContactForm; diff --git a/handlers/courses/client/signup/index.js b/handlers/courses/client/signup/index.js new file mode 100644 index 000000000..ee1ee77f1 --- /dev/null +++ b/handlers/courses/client/signup/index.js @@ -0,0 +1,14 @@ +var SignupWidget = require('./signupWidget'); + +initSignupWidget(); + +function initSignupWidget() { + + var signupWidget = document.querySelector('[data-elem="signup"]'); + if (!signupWidget) return; + + new SignupWidget({ + elem: signupWidget + }); +} + diff --git a/handlers/courses/client/signup/participantsForm.js b/handlers/courses/client/signup/participantsForm.js new file mode 100644 index 000000000..2c3ea09e0 --- /dev/null +++ b/handlers/courses/client/signup/participantsForm.js @@ -0,0 +1,183 @@ +var delegate = require('client/delegate'); +var participantsItem = require('../../templates/blocks/participantsItem.jade'); +var notification = require('client/notification'); + +var clientRender = require('client/clientRender'); + +class ParticipantsForm { + constructor(options) { + this.elem = options.elem; + + this.elems = {}; + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + this.elem.onsubmit = this.onSubmit.bind(this); + + this.elems.participantsDecreaseButton.onclick = this.onParticipantsDecreaseButtonClick.bind(this); + this.elems.participantsDecreaseButton.onmousedown = () => { return false; }; + this.elems.participantsIncreaseButton.onclick = this.onParticipantsIncreaseButtonClick.bind(this); + this.elems.participantsIncreaseButton.onmousedown = () => { return false; }; + + this.elems.participantsCountInput.onkeydown = (e) => { + // Enter does not submit the form + if (e.keyCode == 13 && e.target.tagName == 'INPUT') { + e.preventDefault(); + e.target.blur(); + } + }; + + this.elems.participantsCountInput.onchange = this.onParticipantsCountInputChange.bind(this); + this.elems.participantsIsSelf.onchange = this.onParticipantsIsSelfChange.bind(this); + + this.elems.participantsAddList.onchange = (e) => { + this.validateParticipantItemInput(e.target); + }; + + + this.elems.participantsAddList.onkeydown = (e) => { + // Enter does not submit the form + if (e.keyCode == 13 && e.target.tagName == 'INPUT') { + e.preventDefault(); + e.target.blur(); + } + }; + + } + + validateParticipantItemInput(input) { + var valid = /^[-.\w]+@([\w-]+\.)+[\w-]{2,12}$/.test(input.value); + if (valid) { + input.parentNode.classList.remove('text-input_invalid'); + } else { + input.parentNode.classList.add('text-input_invalid'); + } + } + + + onParticipantsDecreaseButtonClick(event) { + this.setCount(this.elems.participantsCountInput.value - 1); + } + + onParticipantsIncreaseButtonClick(event) { + this.setCount(+this.elems.participantsCountInput.value + 1); + } + + onParticipantsCountInputChange(event) { + this.setCount(this.elems.participantsCountInput.value); + } + + onParticipantsIsSelfChange(event) { + this.setCount(this.elems.participantsCountInput.value); + } + + setCount(count) { + count = parseInt(count) || 0; + + var max = +this.elems.participantsCountInput.getAttribute('max'); + this.elems.participantsDecreaseButton.disabled = (count <= 1); + this.elems.participantsIncreaseButton.disabled = (count >= max); + + this.elems.participantsCountInput.value = count; + + var invalid = count < 1 || count > max; + if (invalid) { + this.elems.participantsCountInput.parentNode.classList.add('text-input_invalid'); + return; + } + + // render price + this.elems.participantsAmount.innerHTML = window.groupInfo.price * count; + this.elems.participantsAmountUsd.innerHTML = Math.round(window.groupInfo.price * count / window.rateUsdRub); + this.elems.participantsCountInput.parentNode.classList.remove('text-input_invalid'); + + // show/hide participants box + if (!this.elems.participantsIsSelf.checked || count > 1) { + this.elems.participantsAddBox.classList.add('course-add-participants_visible'); + } else { + this.elems.participantsAddBox.classList.remove('course-add-participants_visible'); + } + + // add/remove participant items + while(this.elems.participantsAddList.children.length > count) { + this.elems.participantsAddList.lastElementChild.remove(); + } + + while(this.elems.participantsAddList.children.length < count) { + var item = clientRender(participantsItem); + this.elems.participantsAddList.insertAdjacentHTML("beforeEnd", item); + } + + // current visitor is the first item + let firstParticipantItem = this.elems.participantsAddList.firstElementChild.querySelector('input'); + + if (this.elems.participantsIsSelf.checked) { + firstParticipantItem.disabled = true; + firstParticipantItem.value = window.currentUser.email; + } else { + firstParticipantItem.disabled = false; + firstParticipantItem.value = ''; + } + + + } + + onSubmit(event) { + event.preventDefault(); + + try { + if (this.elems.participantsCountInput.parentNode.classList.contains('text-input_invalid')) { + throw new InvalidError(); + } + + var count = +this.elems.participantsCountInput.value; + + var emails = []; + if (this.elems.participantsListEnabled.checked) { + [].forEach.call(this.elems.participantsAddList.querySelectorAll('input'), function(input) { + if (!input.value) return; + if (input.parentNode.classList.contains('text-input_invalid')) { + throw new InvalidError(); + } + emails.push(input.value); + }); + } else { + if (this.elems.participantsIsSelf.checked) { + emails.push(window.currentUser.email); + } + } + + + this.elem.dispatchEvent(new CustomEvent('select', { + detail: { + count: count, + emails: emails + } + })); + + + } catch(e) { + if (e instanceof InvalidError) { + new notification.Error("Исправьте, пожалуйста, ошибки."); + } else { + throw e; + } + + } + } + +} + +function InvalidError(message) { + this.name = "InvalidError"; + this.message = message; +} + +InvalidError.prototype = Object.create(Error.prototype); +InvalidError.prototype.constructor = InvalidError; + + +delegate.delegateMixin(ParticipantsForm.prototype); + +module.exports = ParticipantsForm; diff --git a/handlers/courses/client/signup/signupWidget.js b/handlers/courses/client/signup/signupWidget.js new file mode 100644 index 000000000..52a41c437 --- /dev/null +++ b/handlers/courses/client/signup/signupWidget.js @@ -0,0 +1,162 @@ +var xhr = require('client/xhr'); +var notification = require('client/notification'); +var delegate = require('client/delegate'); +var FormPayment = require('payments/common/client').FormPayment; +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); +var ParticipantsForm = require('./participantsForm'); +var ContactForm = require('./contactForm'); +var pluralize = require('textUtil/pluralize'); + +class SignupWidget { + + constructor(options) { + this.elem = options.elem; + + this.product = 'course'; + + this.elems = {}; + + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + if (this.elems.participants) { + var participantsForm = new ParticipantsForm({ + elem: this.elems.participants + }); + + participantsForm.elem.addEventListener('select', this.onParticipantsFormSelect.bind(this)); + + this.elems.receiptParticipantsEditLink.onclick = (e) => { + e.preventDefault(); + this.goStep1(); + }; + } + + if (this.elems.contact) { + + var contactForm = this.contactForm = new ContactForm({ + elem: this.elems.contact + }); + + contactForm.elem.addEventListener('contact-submit', this.onContactFormSelect.bind(this)); + + this.elems.receiptContactEditLink.onclick = (e) => { + e.preventDefault(); + this.goStep2(); + }; + + } + + this.elems.payment.onsubmit = this.onPaymentSubmit.bind(this); + + /* + this.delegate('[data-order-payment-change]', 'click', (e) => { + e.preventDefault(); + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_3'); + }); + */ + + } + + onPaymentSubmit(event) { + event.preventDefault(); + new FormPayment(this, this.elem.querySelector('[data-elem="payment"]')).submit(); + } + + goStep1() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_1'); + } + + goStep2() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_2'); + + this.elems.receiptTitle.innerHTML = `Участие в курсе для ${this.participantsInfo.count} + ${pluralize(this.participantsInfo.count, 'человека', 'человек', 'человек')}`; + + this.elems.receiptAmount.innerHTML = window.groupInfo.price * this.participantsInfo.count; + + this.contactForm.focus(); + } + + goStep3() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_3'); + + this.elems.receiptContactName.innerHTML = this.contactInfo.name; + this.elems.receiptContactPhone.innerHTML = this.contactInfo.phone; + } + + onParticipantsFormSelect(event) { + this.participantsInfo = event.detail; + this.goStep2(); + } + + onContactFormSelect(event) { + this.contactInfo = event.detail; + this.goStep3(); + } + + + // return orderData or nothing if validation failed + getOrderData() { + + var orderData = { }; + + if (window.orderNumber) { + orderData.orderNumber = window.orderNumber; + } else { + orderData.slug = window.groupInfo.slug; + orderData.orderTemplate = 'course'; + orderData.contactName = this.contactInfo.name; + orderData.contactPhone = this.contactInfo.phone; + orderData.count = this.participantsInfo.count; + orderData.emails = this.participantsInfo.emails; + } + + + return orderData; + } + + + request(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; + } + + startRequestIndication() { + + var paymentMethodElem = this.elem.querySelector('.pay-method'); + paymentMethodElem.classList.add('modal-overlay_light'); + + var spinner = new Spinner({ + elem: paymentMethodElem, + size: 'medium', + class: 'pay-method__spinner' + }); + spinner.start(); + + return function onEnd() { + paymentMethodElem.classList.remove('modal-overlay_light'); + if (spinner) spinner.stop(); + }; + + } + + +} + + +delegate.delegateMixin(SignupWidget.prototype); + +module.exports = SignupWidget; diff --git a/handlers/courses/controller/course.js b/handlers/courses/controller/course.js new file mode 100644 index 000000000..6615e777a --- /dev/null +++ b/handlers/courses/controller/course.js @@ -0,0 +1,35 @@ +var moment = require('momentWithLocale'); +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var money = require('money'); + +exports.get = function*() { + + this.locals.course = yield Course.findOne({ + slug: this.params.course + }).exec(); + + if (!this.locals.course) { + this.throw(404); + } + + this.locals.title = this.locals.course.title; + this.locals.rateUsdRub = money.convert(1, {from: 'USD', to: 'RUB'}); + + + this.locals.formatGroupDate = function(date) { + return moment(date).format('D MMM YYYY').replace(/[а-я]/, function(letter) { + return letter.toUpperCase(); + }); + }; + + this.locals.groups = yield CourseGroup.find({ + isListed: true, + isOpenForSignup: true, + course: this.locals.course._id + }).sort({ + dateStart: 1 + }).exec(); + + this.body = this.render('courses/' + this.locals.course.slug); +}; diff --git a/handlers/courses/controller/coursesByUser.js b/handlers/courses/controller/coursesByUser.js new file mode 100644 index 000000000..c0c201004 --- /dev/null +++ b/handlers/courses/controller/coursesByUser.js @@ -0,0 +1,115 @@ +"use strict"; + +const CourseInvite = require('../models/courseInvite'); +const CourseParticipant = require('../models/courseParticipant'); +const CourseGroup = require('../models/courseGroup'); +const CourseFeedback = require('../models/courseFeedback'); +const _ = require('lodash'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.get = function*(next) { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + // active invites + var invites = yield CourseInvite.find({ + email: user.email, + accepted: false + }).populate('group').exec(); + + // plus groups where participates + var userParticipants = yield CourseParticipant.find({ + user: user._id, + isActive: true + }).populate('group').exec(); + + var groups; + if (userParticipants) { + // plus groups where participates + groups = _.pluck(userParticipants, 'group'); + } else { + groups = []; + } + + var groupInfoItems = []; + + for (let i = 0; i < invites.length; i++) { + let group = invites[i].group; + yield CourseGroup.populate(group, {path: 'course'}); + let groupInfo = formatGroup(group); + groupInfo.links = [{ + url: group.course.getUrl(), + title: 'Описание курса' + }]; + groupInfo.inviteUrl = '/courses/invite/' + invites[i].token; + groupInfo.status = 'invite'; + groupInfoItems.push(groupInfo); + } + + for (let i = 0; i < groups.length; i++) { + let group = groups[i]; + yield CourseGroup.populate(group, {path: 'course'}); + + let participant = userParticipants.filter(function(userParticipant) { + return String(userParticipant.group._id) == String(group._id); + })[0]; + + let hasFeedback = yield CourseFeedback.findOne({ + group: group._id, + participant: participant._id + }).exec(); + + let groupInfo = formatGroup(group); + if (!hasFeedback) { + groupInfo.feedbackLink = `/courses/groups/${group.slug}/feedback`; + } + + groupInfo.links = [{ + url: group.course.getUrl(), + title: 'Описание курса' + }, { + url: `/courses/groups/${group.slug}/info`, + title: 'Инструкции по настройке окружения' + }]; + + if (groups[i].materials) { + groupInfo.links.push({ + url: `/courses/groups/${group.slug}/materials`, + title: 'Материалы для обучения' + }); + } + + groupInfo.status = (groupInfo.dateStart > new Date()) ? 'accepted' : + (groupInfo.dateEnd > new Date()) ? 'started' : 'ended'; + + + if (groupInfo.status == 'ended') { + groupInfo.certificateLink = `/courses/download/participant/${participant._id}/certificate.jpg`; + } + groupInfoItems.push(groupInfo); + + } + + this.body = groupInfoItems; + +}; + + + +function formatGroup(group) { + return { + title: group.title, + dateStart: group.dateStart, + dateEnd: group.dateEnd, + timeDesc: group.timeDesc + }; +} diff --git a/handlers/courses/controller/frontpage.js b/handlers/courses/controller/frontpage.js new file mode 100644 index 000000000..13ed52d2c --- /dev/null +++ b/handlers/courses/controller/frontpage.js @@ -0,0 +1,29 @@ +var Course = require('../models/course'); +var moment = require('momentWithLocale'); + +exports.get = function*() { + + var courses = yield Course.find({ + isListed: true + }).sort({weight: 1}).exec(); + + this.locals.coursesInfo = []; + for (var i = 0; i < courses.length; i++) { + var course = courses[i]; + this.locals.coursesInfo.push({ + url: course.getUrl(), + title: course.title, + shortDescription: course.shortDescription, + hasOpenGroups: yield* course.hasOpenGroups() + }); + } + + + this.locals.formatGroupDate = function(date) { + return moment(date).format('D MMM YYYY').replace(/[а-я]/, function(letter) { + return letter.toUpperCase(); + }); + }; + + this.body = this.render('frontpage'); +}; diff --git a/handlers/courses/controller/groupFeedbackEdit.js b/handlers/courses/controller/groupFeedbackEdit.js new file mode 100644 index 000000000..82f82a0e6 --- /dev/null +++ b/handlers/courses/controller/groupFeedbackEdit.js @@ -0,0 +1,98 @@ +const countries = require('countries'); +const ImgurImage = require('imgur').ImgurImage; +const CourseFeedback = require('../models/courseFeedback'); +const CourseParticipant = require('../models/courseParticipant'); +const _ = require('lodash'); + +exports.all = function*() { + + var group = this.locals.group = this.groupBySlug; + + this.locals.title = "Отзыв\n" + group.title; + + this.locals.participant = this.participant; + + this.locals.countries = countries.all; + + var courseFeedback = yield CourseFeedback.findOne({ + participant: this.participant._id + }).exec(); + + if (!courseFeedback) { + courseFeedback = new CourseFeedback({ + recommend: true, + isPublic: true, + country: this.participant.country, + photo: this.participant.photo, + aboutLink: this.participant.aboutLink, + city: this.participant.city, + occupation: this.participant.occupation + }); + } + + if (this.method == 'POST') { + var feedbackData = _.pick(this.request.body, + 'stars content country city isPublic recommend aboutLink occupation'.split(' ') + ); + + feedbackData.participant = this.participant._id; + feedbackData.group = group._id; + feedbackData.recommend = Boolean(+feedbackData.recommend); + feedbackData.isPublic = Boolean(+feedbackData.isPublic); + + //console.log(this.request.body.photoId, feedbackData.photo, '!!!'); + + _.assign(courseFeedback, feedbackData); + + if (this.request.body.photoId) { + var photo = yield ImgurImage.findOne({imgurId: this.request.body.photoId}).exec(); + if (photo) { + courseFeedback.photo = photo.link; + } + } + + try { + yield courseFeedback.persist(); + } catch (e) { + var errors = {}; + for (var key in e.errors) { + errors[key] = e.errors[key].message; + } + + this.body = this.render('feedback/edit', { + errors: errors, + form: courseFeedback + }); + + return; + } + + // make the new picture user avatar + if (courseFeedback.photo && !this.user.photo) { + yield this.user.persist({ + photo: courseFeedback.photo + }); + } + + if (courseFeedback.isPublic) { + this.addFlashMessage("success", "Ваш отзыв успешно сохранен. При желании, вы можете поделиться им в соц сетях."); + } else { + this.addFlashMessage("success", "Ваш отзыв успешно сохранен. Он будет виден только нам."); + } + + this.redirect(`/courses/feedback/${courseFeedback.number}`); + return; + + + } else if (this.method == 'GET') { + + this.locals.form = courseFeedback; + + this.body = this.render('feedback/edit'); + } + +}; + +exports.post = function*() { + +}; diff --git a/handlers/courses/controller/groupFeedbackShow.js b/handlers/courses/controller/groupFeedbackShow.js new file mode 100644 index 000000000..3675765f0 --- /dev/null +++ b/handlers/courses/controller/groupFeedbackShow.js @@ -0,0 +1,42 @@ +const mongoose = require('mongoose'); +const countries = require('countries'); +const CourseFeedback = require('../models/courseFeedback'); +const CourseGroup = require('../models/courseGroup'); +const User = require('users').User; +const _ = require('lodash'); + +exports.get = function*() { + + var number = +this.params.feedbackNumber; + + var courseFeedback = this.locals.courseFeedback = yield CourseFeedback.findOne({number: number}).populate('group participant').exec(); + + if (!courseFeedback) { + this.throw(404); + } + + yield CourseGroup.populate(courseFeedback.group, 'course'); + + var authorOrAdmin = this.user.isAdmin || String(this.user._id) == String(courseFeedback.participant.user); + this.locals.authorOrAdmin = authorOrAdmin; + + if (!courseFeedback.isPublic && !authorOrAdmin) { + this.throw(403, "Отзыв не публичный"); + } + + this.locals.participantUser = yield User.findById(courseFeedback.participant.user).exec(); + + var group = this.locals.group = courseFeedback.group; + + this.locals.title = "Отзыв\n" + group.title; + + this.locals.countries = countries.all; + + if (authorOrAdmin) { + this.locals.editLink = `/courses/groups/${courseFeedback.group.slug}/feedback`; + } + + this.body = this.render('feedback/show'); + +}; + diff --git a/handlers/courses/controller/groupInfo.js b/handlers/courses/controller/groupInfo.js new file mode 100644 index 000000000..fe0d6887a --- /dev/null +++ b/handlers/courses/controller/groupInfo.js @@ -0,0 +1,11 @@ +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var _ = require('lodash'); + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var group = this.locals.group = this.groupBySlug; + + this.body = this.render('groupInfo/' + group.course.slug); +}; diff --git a/handlers/courses/controller/groupMaterials.js b/handlers/courses/controller/groupMaterials.js new file mode 100644 index 000000000..5736da5c9 --- /dev/null +++ b/handlers/courses/controller/groupMaterials.js @@ -0,0 +1,34 @@ +var bytes = require('bytes'); +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var CourseParticipant = require('../models/courseParticipant'); +var _ = require('lodash'); + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var group = this.locals.group = this.groupBySlug; + + if (!group.materials) { + this.throw(404); + } + + this.locals.title = "Материалы для обучения\n" + group.title; + + this.locals.participant = this.participant; + + var materials = this.locals.materials = []; + for (var i = 0; i < group.materials.length; i++) { + var material = group.materials[i]; + materials.push({ + title: material.title, + created: material.created, + url: group.getMaterialUrl(material), + size: bytes(yield* group.getMaterialFileSize(material)) + }); + } + + this.body = this.render('groupMaterials', { + videoKey: this.participant.videoKey + }); +}; diff --git a/handlers/courses/controller/groupMaterialsDownload.js b/handlers/courses/controller/groupMaterialsDownload.js new file mode 100644 index 000000000..2ab7133e6 --- /dev/null +++ b/handlers/courses/controller/groupMaterialsDownload.js @@ -0,0 +1,26 @@ +var bytes = require('bytes'); +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var _ = require('lodash'); +var path = require('path'); + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var group = this.groupBySlug; + + var material = _.where(group.materials, {filename: this.params.filename})[0]; + + // ensure the path to material is valid + if (!material) { + this.throw(404); + } + + this.set({ + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment; filename=' + path.basename(material.filename), + 'X-Accel-Redirect': '/_download/' + group.getMaterialFileRelativePath(material) + }); + + this.body = ''; +}; diff --git a/handlers/courses/controller/invite.js b/handlers/courses/controller/invite.js new file mode 100644 index 000000000..a8fb7f2b9 --- /dev/null +++ b/handlers/courses/controller/invite.js @@ -0,0 +1,300 @@ +const Course = require('../models/course'); +const CourseInvite = require('../models/courseInvite'); +const config = require('config'); +const CourseGroup = require('../models/courseGroup'); +const registerParticipants = require('../lib/registerParticipants'); +const User = require('users').User; +const VideoKey = require('videoKey').VideoKey; +const _ = require('lodash'); +const countries = require('countries'); +const LOGIN_SUCCESSFUL = 1; +const LOGGED_IN_ALREADY = 2; +const NO_SUCH_USER = 3; +const CourseParticipant = require('../models/courseParticipant'); +const ImgurImage = require('imgur').ImgurImage; +const log = require('log')(); + + +exports.all = function*() { + + if (this.method != 'POST' && this.method != 'GET') { + this.throw(404); + } + + var invite = yield CourseInvite.findOne({ + token: this.params.inviteToken || this.request.body && this.request.body.inviteToken + }).populate('group order').exec(); + + this.locals.mailto = "mailto:orders@javascript.ru"; + + if (!invite) { + this.throw(404); + } + + if (invite.order) { + this.locals.mailto += "?subject=" + encodeURIComponent('Заказ ' + invite.order.number); + } + + if (invite.accepted) { + if (this.user && this.user.email == invite.email) { + this.addFlashMessage("success", "Поздравляем, вы присоединились к курсу. Ниже, рядом с курсом, вы найдёте инструкцию."); + this.redirect(this.user.getProfileUrl() + '/courses'); + } else { + this.status = 403; + this.body = this.render('/notification', { + title: "Это приглашение уже принято", + message: { + type: 'success', + html: "Это приглашение уже принято. Зайдите в учётную запись участника для доступа к курсу." + } + }); + } + return; + } + + // invite is also a login token, so we limit it's validity + if (invite.validUntil < Date.now()) { + this.status = 404; + this.body = this.render('/notification', { + title: "Ссылка устарела", + message: { + type: 'success', + html: ` + Извините, ссылка по которой вы перешли, устарела. + Если у вас возникли какие-либо вопросы – пишите на orders@javascript.ru + ` + } + }); + return; + } + + yield CourseGroup.populate(invite.group, 'course'); + + var userByEmail = yield User.findOne({ + email: invite.email + }).exec(); + + if (userByEmail) { + var participantByEmail = yield CourseParticipant.findOne({ + isActive: true, + group: invite.group._id, + user: userByEmail._id + }).exec(); + + // invite was NOT accepted, but this guy is a participant (added manually?), + // so show the same as accepted + if (participantByEmail) { + if (this.user && this.user.email == invite.email) { + this.addFlashMessage("success", "Вы уже участник курса. Ниже, рядом с курсом, вы найдёте инструкцию."); + this.redirect(this.user.getProfileUrl() + '/courses'); + } else { + this.status = 403; + this.body = this.render('/notification', { + title: "Это приглашение уже принято", + message: { + type: 'success', + html: "Это приглашение уже принято. Зайдите в учётную запись участника для доступа к курсу." + } + }); + } + return; + } + } + + // invalid invite, person not in list + if (!~invite.order.data.emails.indexOf(invite.email)) { + this.body = this.render('invite/deny', { + email: invite.email, + contactName: invite.order.data.contactName, + orderNumber: invite.order.number + }); + + return; + } + + // ----------- INVITE IS VALID ---------------- + + this.locals.title = invite.group.title; + this.locals.invite = invite; + + var isLoggedIn = yield* loginByInvite.call(this, invite); + + if (isLoggedIn == NO_SUCH_USER) { + if (this.user) this.logout(); + yield* register.call(this, invite); + + } else { + if (isLoggedIn == LOGIN_SUCCESSFUL) { + this.locals.wasLoggedIn = true; + } + yield* askParticipantDetails.call(this, invite); + } + +}; + +function* askParticipantDetails(invite) { + + // NB: this.user is the right user, guaranteed by loginByInvite + + var selectCountries = Object.create(countries.all); + selectCountries[""] = { + "co": "", + "ph": "", + "na": " выберите страну " + }; + + this.locals.title = "Анкета участника\n" + invite.group.title; + this.locals.countries = selectCountries; + + if (this.method == 'POST') { + var participantData = _.pick(this.request.body, + 'photoId firstName surname country city aboutLink occupation purpose wishes'.split(' ') + ); + participantData.user = this.user._id; + participantData.group = invite.group._id; + + if (participantData.photoId) { + var photo = yield ImgurImage.findOne({imgurId: this.request.body.photoId}).exec(); + if (photo) { // no photo if stale form (?) or just bad post + participantData.photo = photo.link; + } + } + + if (!participantData.photo && this.user.photo) { + participantData.photo = this.user.photo; + } + + + var participant = new CourseParticipant(participantData); + + try { + + yield participant.persist(); + + } catch (e) { + var errors = {}; + for (var key in e.errors) { + errors[key] = e.errors[key].message; + } + + this.body = this.render('invite/askParticipantDetails', { + errors: errors, + form: participantData + }); + + return; + } + + this.log.debug(participant.toObject(), "participant is accepted"); + + // make the new picture user avatar + if (participant.photo && !this.user.photo) { + yield this.user.persist({ + photo: participant.photo + }); + } + + yield* acceptParticipant.call(this, invite, participant); + + // will show "welcome" cause the invite is accepted + this.redirect('/courses/invite/' + invite.token); + + } else if (this.method == 'GET') { + + this.body = this.render('invite/askParticipantDetails', { + errors: {}, + form: { + country: 'ru' + } + }); + + } + +} + +function* acceptParticipant(invite) { + + this.user.profileTabsEnabled.addToSet('courses'); + yield this.user.persist(); + + yield invite.accept(); + + invite.group.decreaseParticipantsLimit(); + + yield invite.group.persist(); + + yield* registerParticipants(invite.group); + +} + + +function* register(invite) { + + if (this.method == 'POST') { + + // do register the man, email is verified + var user = new User({ + email: invite.email, + displayName: this.request.body.displayName, + password: this.request.body.password, + verifiedEmail: true + }); + + try { + yield user.persist(); + } catch (e) { + var errors = {}; + for (var key in e.errors) { + errors[key] = e.errors[key].message; + } + + this.body = this.render('invite/register', { + errors: e.errors, + form: { + displayName: this.request.body.displayName, + password: this.request.body.password + } + }); + return; + } + + yield this.login(user); + + this.redirect('/courses/invite/' + invite.token); + + } else { + this.body = this.render('invite/register', { + errors: {}, + form: {} + }); + } +} + +/** + * Logs in the current user using invite data + * Makes email verified + * @param invite + * @returns LOGIN_SUCCESSFUL / NO_SUCH_USER / LOGGED_IN_ALREADY + */ +function* loginByInvite(invite) { + + if (this.user && this.user.email == invite.email) { + return LOGGED_IN_ALREADY; + } + + var userByEmail = yield User.findOne({ + email: invite.email + }).exec(); + + if (!userByEmail) return NO_SUCH_USER; + + if (!userByEmail.verifiedEmail) { + // if pending verification => invite token confirms email + yield userByEmail.persist({ + verifiedEmail: true + }); + } + + yield this.login(userByEmail); + return LOGIN_SUCCESSFUL; +} diff --git a/handlers/courses/controller/participantCertificateDownload.js b/handlers/courses/controller/participantCertificateDownload.js new file mode 100644 index 000000000..a40864790 --- /dev/null +++ b/handlers/courses/controller/participantCertificateDownload.js @@ -0,0 +1,72 @@ +var config = require('config'); +var CourseParticipant = require('../models/courseParticipant'); +var CourseGroup = require('../models/courseGroup'); +var _ = require('lodash'); +var moment = require('momentWithLocale'); +var path = require('path'); +var mongoose = require('mongoose'); +var exec = require('child_process').exec; + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var id = this.params.participantId; + try { + new mongoose.Types.ObjectId(id); + } catch (e) { + // cast error (invalid id) + this.throw(404); + } + + var participant = yield CourseParticipant.findOne({ + _id: id, + isActive: true + }).populate('group').exec(); + + if (!participant) { + this.throw(404); + } + + if (String(participant.user) != String(this.user._id)) { + this.throw(403); + } + yield CourseGroup.populate(participant.group, {path: 'course'}); + + var dateStart = moment(participant.group.dateStart).format('DD.MM.YYYY'); + var dateEnd = moment(participant.group.dateEnd).format('DD.MM.YYYY'); + + var cmd = `/usr/bin/convert ${config.projectRoot}/extra/courses/cert-blank-300dpi.jpg \ + -font ${config.projectRoot}/extra/courses/font/calibri.ttf -pointsize 70 \ + -annotate +900+1050 'Настоящим удостоверяется, что с ${dateStart} по ${dateEnd}' \ + -fill "#7F0000" -pointsize 140 -annotate +900+1250 '${participant.fullName}' \ + -fill black -pointsize 70 -annotate +900+1400 'прошёл(а) обучение по программе' \ + -fill black -pointsize 70 -annotate +900+1500 '"${participant.group.course.title}"' \ + jpeg:-`; + + //console.log(cmd); + /* + var cmd = `/opt/local/bin/convert ${config.projectRoot}/extra/courses/cert-blank-600dpi.jpg \ + -font ${config.projectRoot}/extra/courses/font/calibri.ttf -pointsize 140 \ + -annotate +1800+2100 'Настоящим удостоверяется, что с ${dateStart} по ${dateEnd}' \ + -fill "#7F0000" -pointsize 280 -annotate +1800+2500 '${participant.fullName}' \ + -fill black -pointsize 140 -annotate +1800+2800 'прошёл(а) обучение по программе' \ + -fill black -pointsize 140 -annotate +1800+3000 '${participant.group.course.title}' \ + -`;*/ + + var buffer = yield function(callback) { + exec(cmd, { + encoding: 'buffer', + timeout: 10000, + maxBuffer: 50 * 1024 * 1025 + }, + function(error, stdout, stderr) { + callback(error ? stderr : null, stdout); + }); + }; + + this.set({ + 'Content-Type': 'image/jpeg' + }); + + this.body = buffer; +}; diff --git a/handlers/courses/controller/participants.js b/handlers/courses/controller/participants.js new file mode 100644 index 000000000..3c6de94cc --- /dev/null +++ b/handlers/courses/controller/participants.js @@ -0,0 +1,32 @@ +var CourseParticipant = require('../models/courseParticipant'); +var mongoose = require('mongoose'); + +exports.patch = function*() { + + var id = this.request.body.id; + try { + new mongoose.Types.ObjectId(id); + } catch (e) { + // cast error (invalid id) + this.throw(404); + } + + var participant = yield CourseParticipant.findById(id).exec(); + + if (!participant) { + this.throw(404); + } + + if (String(participant.user) != String(this.user._id) && !this.isAdmin) { + this.throw(403); + } + + if ("shouldNotifyMaterials" in this.request.body) { + participant.shouldNotifyMaterials = Boolean(this.request.body.shouldNotifyMaterials); + } + + yield participant.persist(); + + this.body = {message: "Данные обновлены."}; + +}; diff --git a/handlers/courses/controller/registerParticipants.js b/handlers/courses/controller/registerParticipants.js new file mode 100644 index 000000000..efb10e18a --- /dev/null +++ b/handlers/courses/controller/registerParticipants.js @@ -0,0 +1,10 @@ +var registerParticipants = require('../lib/registerParticipants'); + +exports.get = function*() { + + this.nocache(); + + yield* registerParticipants(this.groupBySlug); + this.body = "OK " + this.requestId; + +}; diff --git a/handlers/courses/controller/signup.js b/handlers/courses/controller/signup.js new file mode 100644 index 000000000..0aeaab145 --- /dev/null +++ b/handlers/courses/controller/signup.js @@ -0,0 +1,127 @@ +const payments = require('payments'); +var getOrderInfo = payments.getOrderInfo; +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var CourseInvite = require('../models/courseInvite'); +var config = require('config'); +var moment = require('momentWithLocale'); +var money = require('money'); +var pluralize = require('textUtil/pluralize'); + + +exports.get = function*() { + this.nocache(); + + this.locals.sitetoolbar = true; + + var group; + + if (!this.isAuthenticated()) { + this.authAndRedirect(this.originalUrl); + return; + } + + var discount; + + if (this.query.code) { + discount = yield* payments.Discount.findByCodeAndModule(this.query.code, 'courses'); + } + + + if (this.params.orderNumber) { + yield* this.loadOrder({ + ensureSuccessTimeout: 10000 + }); + + this.locals.order = this.order; + this.locals.title = 'Заказ №' + this.order.number; + + this.locals.changePaymentRequested = Boolean(this.query.changePayment); + + group = this.locals.group = yield CourseGroup.findById(this.order.data.group).populate('course').exec(); + + if (!group) { + this.throw(404, "Нет такой группы."); + } + + if (this.order.status == payments.Order.STATUS_SUCCESS) { + var invite = yield CourseInvite.findOne({email: this.user.email, accepted: false}).exec(); + if (invite) this.locals.hasInvite = true; + + if (this.order.data.count > 1 || this.order.data.emails[0] != this.user.email) { + this.locals.hasOtherParticipants = true; + } + } + + } else { + + group = this.locals.group = this.groupBySlug; + + // a visitor can't reach this page through UI, only by direct link + // if the group is full + if (!group.isOpenForSignup && !discount) { + this.statusCode = 403; + this.body = this.render('/notification', { + title: 'Запись в эту группу завершена', + message: { + type: 'error', + html: ` + Запись в эту группу завершена. + Перейдите на страницу курса, чтобы увидеть открытые группы. + ` + } + }); + return; + } + + this.locals.title = "Регистрация\n" + group.title; + } + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + this.locals.breadcrumbs = [ + {title: 'Учебник', url: '/'}, + {title: 'Курсы', url: '/courses'} + ]; + + if (this.order) { + this.locals.orderInfo = yield* getOrderInfo(this.order); + this.locals.receiptTitle = `Участие в курсе для ${this.order.data.count} + ${pluralize(this.order.data.count, 'человека', 'человек', 'человек')}`; + + this.locals.receiptAmount = this.order.amount; + this.locals.receiptContactPhone = this.order.data.contactPhone; + this.locals.receiptContactName = this.order.data.contactName; + + } else { + this.locals.orderInfo = {}; + } + + this.locals.mailto = "mailto:orders@javascript.ru"; + if (this.order) { + this.locals.mailto += '?subject=' + encodeURIComponent('Заказ ' + this.order.number); + } + + + this.locals.formatGroupDate = function(date) { + return moment(date).format('D MMM YYYY').replace(/[а-я]/, function(letter) { + return letter.toUpperCase(); + }); + }; + + this.locals.rateUsdRub = money.convert(1, {from: 'USD', to: 'RUB'}); + + var price = group.price; + + if (discount && discount.data.slug == group.slug) { + price = discount.adjustAmount(price); + } + + this.locals.groupInfo = { + price: price, + participantsMax: group.participantsLimit, + slug: group.slug + }; + + this.body = this.render('signup'); +}; diff --git a/handlers/courses/index.js b/handlers/courses/index.js new file mode 100755 index 000000000..586f7616d --- /dev/null +++ b/handlers/courses/index.js @@ -0,0 +1,23 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/courses', __dirname)); +}; + +exports.Course = require('./models/course'); +exports.CourseGroup = require('./models/courseGroup'); +exports.CourseInvite = require('./models/courseInvite'); +exports.CourseFeedback = require('./models/courseFeedback'); + +exports.onPaid = require('./lib/onPaid'); +exports.cancelIfPendingTooLong = require('./lib/cancelIfPendingTooLong'); + +exports.getAgreement = require('./lib/getAgreement'); + +exports.createOrderFromTemplate = require('./lib/createOrderFromTemplate'); + +exports.patch = require('./lib/patch'); +exports.formatOrderForProfile = require('./lib/formatOrderForProfile'); + +require('./lib/registerParticipants'); // registers middleware for user save diff --git a/handlers/courses/lib/cancelIfPendingTooLong.js b/handlers/courses/lib/cancelIfPendingTooLong.js new file mode 100644 index 000000000..afce32efa --- /dev/null +++ b/handlers/courses/lib/cancelIfPendingTooLong.js @@ -0,0 +1,70 @@ +var Order = require('payments').Order; +var User = require('users').User; +var CourseGroup = require('../models/courseGroup'); +var assert = require('assert'); +const mailer = require('mailer'); +var gutil = require('gulp-util'); +var path = require('path'); +var config = require('config'); + +// pending for a week => cancel without a notice +module.exports = function*(order) { + + assert(order.user); + + // wait for a week, do nothing + + var ordersSameGroupAndUser = yield Order.find({ + user: order.user, + 'data.group': order.data.group + }).exec(); + + var orderSuccessSameGroupAndUser = ordersSameGroupAndUser.filter(function(order) { + return order.status == Order.STATUS_SUCCESS; + })[0]; + + gutil.log("order " + order.number); + + if (orderSuccessSameGroupAndUser) { + // 2 days if has success order to same group + if (order.created > Date.now() - 2 * 86400 * 1e3) { + //console.log(order.created, Date.now() - 2 * 24 * 86400 * 1e3, +order.created); + gutil.log(`...created ${order.created} less than 2 days, return`); + return; + } + } else { + // 7 days wait otherwise + if (order.created > Date.now() - 7 * 86400 * 1e3) { + gutil.log(`...created ${order.created} less than 7 days, return`); + return; + } + } + + gutil.log("Canceling " + order.number); + + var orderUser = yield User.findById(order.user).exec(); + var orderGroup = yield CourseGroup.findById(order.data.group).exec(); + + assert(orderGroup); + assert(orderUser); + + yield* mailer.send({ + from: 'orders', + templatePath: path.join(__dirname, '../templates/email/orderCancel'), + to: [{email: orderUser.email}], + orderSuccessSameGroupAndUser: orderSuccessSameGroupAndUser, + orderUser: orderUser, + orderGroup: orderGroup, + profileOrdersLink: config.server.siteHost + orderUser.getProfileUrl() + '/orders', + order: order, + subject: "[Курсы, система регистрации] Отмена заказа " + order.number + " на сайте javascript.ru" + }); + + gutil.log("Sent letter to " + orderUser.email); + + + yield order.persist({ + status: Order.STATUS_CANCEL + }); + +}; diff --git a/handlers/courses/lib/createOrderFromTemplate.js b/handlers/courses/lib/createOrderFromTemplate.js new file mode 100755 index 000000000..9111520b9 --- /dev/null +++ b/handlers/courses/lib/createOrderFromTemplate.js @@ -0,0 +1,76 @@ +var Order = require('payments').Order; +var Discount = require('payments').Discount; +var OrderCreateError = require('payments').OrderCreateError; +var CourseGroup = require('../models/courseGroup'); +var pluralize = require('textUtil/pluralize'); +var _ = require('lodash'); + +// middleware +// create order from template, +// use the incoming data if needed +module.exports = function*(orderTemplate, user, requestBody) { + + var group = yield CourseGroup.findOne({slug: requestBody.slug}).exec(); + + var orderData = { + group: group._id + }; + orderData.count = +requestBody.count; + + if (group.participantsLimit === 0) { + throw new OrderCreateError("Извините, в этой группе уже нет мест."); + } + + if (orderData.count > group.participantsLimit) { + throw new OrderCreateError("Извините, уже нет такого количества мест. Уменьшите количество участников до " + group.participantsLimit + '.'); + } + + orderData.contactName = String(requestBody.contactName); + + if (!orderData.contactName) { + throw new OrderCreateError("Не указано контактное лицо."); + } + + orderData.contactPhone = String(requestBody.contactPhone || ''); + + var emails = requestBody.emails; + if (!Array.isArray(emails)) { + throw new OrderCreateError("Отсутствуют участники."); + } + orderData.emails = _.unique(emails.filter(Boolean).map(String)); + + if (!user) { + throw new OrderCreateError("Вы не авторизованы."); + } + + + var price = group.price; + var discount; + if (requestBody.discountCode) { + discount = yield* Discount.findByCodeAndModule(requestBody.discountCode, 'courses'); + if (discount && discount.data.slug == group.slug) { + price = discount.adjustAmount(price); + } + } + + if (!group.isOpenForSignup && !discount) { + this.throw(403, "Запись в эту группу завершена, извините"); + } + + + var order = new Order({ + title: group.title, + amount: orderData.count * price, + module: orderTemplate.module, + data: orderData, + email: user.email, + user: user._id + }); + + yield order.persist(); + + return order; + +}; + + diff --git a/handlers/courses/lib/createOrderInvites.js b/handlers/courses/lib/createOrderInvites.js new file mode 100644 index 000000000..9712ebca5 --- /dev/null +++ b/handlers/courses/lib/createOrderInvites.js @@ -0,0 +1,60 @@ +const sendMail = require('mailer').send; +const CourseInvite = require('../models/courseInvite'); +const _ = require('lodash'); +const log = require('log')(); +const sendInvite = require('./sendInvite'); +const CourseGroup = require('../models/courseGroup'); +const User = require('users').User; + +/** + * create invites for the order + * except those that already exist + * @param order + */ +module.exports = function*(order) { + + var emails = order.data.emails; + + // get existing invites, so that we won't recreate them + var existingInvites = yield CourseInvite.find({ order: order._id }).exec(); + var existingInviteByEmails = _.indexBy(existingInvites, 'email'); + + log.debug("existing invites", existingInviteByEmails); + + // get existing participants, they don't need invites + var group = yield CourseGroup.findById(order.data.group).exec(); + yield CourseGroup.populate(group, 'participants'); + yield User.populate(group, 'participants.user'); + + var participantsByEmail = _.indexBy(_.pluck(group.participants, 'user'), 'email'); + + var invites = []; + for (var i = 0; i < emails.length; i++) { + var email = emails[i]; + if (participantsByEmail[email]) continue; // in group already + if (existingInviteByEmails[email]) continue; // invite exists already + + log.debug("create invite for email", email); + + var invite = new CourseInvite({ + order: order._id, + group: group._id, + // max(now + 7 days, course start + 7 days) + validUntil: new Date( Math.max(Date.now(), group.dateStart) + 7 * 24 * 86400 * 1e3), + email: email + }); + invites.push(invite); + + yield invite.persist(); + + // not only send invite, but enable the tab so that the user can manually accept it + yield User.update({ + email: email + }, { + $addToSet: {profileTabsEnabled: 'courses'} + }); + + } + + return invites; +}; diff --git a/handlers/courses/lib/doc/agreement.docx b/handlers/courses/lib/doc/agreement.docx new file mode 100644 index 000000000..c747f33f2 Binary files /dev/null and b/handlers/courses/lib/doc/agreement.docx differ diff --git a/handlers/courses/lib/formatOrderForProfile.js b/handlers/courses/lib/formatOrderForProfile.js new file mode 100644 index 000000000..caec0fadf --- /dev/null +++ b/handlers/courses/lib/formatOrderForProfile.js @@ -0,0 +1,54 @@ +var CourseGroup = require('courses').CourseGroup; +var User = require('users').User; +var _ = require('lodash'); +var getOrderInfo = require('payments').getOrderInfo; +var paymentMethods = require('./paymentMethods'); + +module.exports = function* formatCourseOrder(order) { + + var group = yield CourseGroup.findById(order.data.group).populate('course participants').exec(); + + if (!group) { + this.log.error("Not found group for order", order.toObject()); + this.throw(404); + } + + var users = yield User.find({ + email: { + $in: order.data.emails + } + }).exec(); + + var usersByEmail = _.indexBy(users, 'email'); + + var groupParticipantsByUser = _.indexBy(group.participants, 'user'); + + var orderToShow = { + created: order.created, + title: group.title, + number: order.number, + module: order.module, + amount: order.amount, + count: order.data.count, + contactName: order.data.contactName, + contactPhone: order.data.contactPhone, + courseUrl: group.course.getUrl(), + participants: order.data.emails.map(function(email) { + return { + email: email, + inGroup: Boolean(usersByEmail[email] && groupParticipantsByUser[usersByEmail[email]._id]) + }; + }) + + }; + + var orderInfo = yield* getOrderInfo(order); + + orderToShow.orderInfo = _.pick(orderInfo, ['status', 'statusText', 'descriptionProfile']); + + if (orderInfo.transaction) { + orderToShow.paymentMethod = paymentMethods[orderInfo.transaction.paymentMethod].title; + } + + return orderToShow; +}; diff --git a/handlers/courses/lib/getAgreement.js b/handlers/courses/lib/getAgreement.js new file mode 100644 index 000000000..f0982d1c6 --- /dev/null +++ b/handlers/courses/lib/getAgreement.js @@ -0,0 +1,55 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const CourseGroup = require('../models/courseGroup'); + +// Load the docx file as a binary +// @see https://github.com/open-xml-templating/docxtemplater +var docContent = fs.readFileSync(path.join(__dirname, "doc/agreement.docx"), "binary"); + +// this.transaction exists +module.exports = function*(transaction) { + + var invoiceDoc = new Docxtemplater(docContent); + + var group = yield CourseGroup.findById(transaction.order.data.group).exec(); + + if (!group) { + this.throw(400, "Нет группы"); + } + + invoiceDoc.setData({ + COMPANY_NAME: invoiceConfig.COMPANY_NAME, + INN: invoiceConfig.INN, + ACCOUNT: invoiceConfig.ACCOUNT, + BANK: invoiceConfig.BANK, + CORR_ACC: invoiceConfig.CORR_ACC, + BIK: invoiceConfig.BIK, + OGRNIP: invoiceConfig.OGRNIP, + PHONE: invoiceConfig.PHONE, + SIGN_TITLE: invoiceConfig.SIGN_TITLE, + SIGN_NAME: invoiceConfig.SIGN_NAME, + SIGN_SHORT_NAME: invoiceConfig.SIGN_SHORT_NAME, + ORDER_NUMBER: String(transaction.order.number), + ORDER_DATE: moment(transaction.order.created).format('DD.MM.YYYY'), + INVOICE_CONTRACT_HEAD: transaction.paymentDetails.contractHead || "... В ЛИЦЕ ... НА ОСНОВАНИИ ...", + COMPANY_INVOICE_HEAD: invoiceConfig.COMPANY_INVOICE_HEAD, + GROUP_DURATION_DATE: moment(group.dateStart).format('DD.MM.YYYY') + ' - ' + moment(group.dateEnd).format('DD.MM.YYYY'), + END_DATE: moment(group.dateEnd).format('DD.MM.YYYY'), + GROUP_TIME: group.timeDesc, + TRANSACTION_NUMBER: String(transaction.number), + TRANSACTION_DATE: moment(transaction.created).format('DD.MM.YYYY'), + INVOICE_COMPANY_NAME: transaction.paymentDetails.companyName, + INVOICE_COMPANY_ADDRESS: transaction.paymentDetails.companyAddress, + INVOICE_BANK_DETAILS: transaction.paymentDetails.bankDetails, + AMOUNT: transaction.amount + }); + + // apply replacements + invoiceDoc.render(); + + return invoiceDoc; +}; + diff --git a/handlers/courses/lib/mustBeParticipant.js b/handlers/courses/lib/mustBeParticipant.js new file mode 100644 index 000000000..254637dd8 --- /dev/null +++ b/handlers/courses/lib/mustBeParticipant.js @@ -0,0 +1,25 @@ +const _ = require('lodash'); +const CourseParticipant = require('../models/courseParticipant'); + +module.exports = function*(next) { + + var group = this.groupBySlug; + + if (!this.user) { + this.throw(401); + } + + var participant = yield CourseParticipant.findOne({ + isActive: true, + group: group._id, + user: this.user._id + }).exec(); + + if (!participant) { + this.throw(403, "Вы не являетесь участником этой группы."); + } + + this.participant = participant; + + yield* next; +}; diff --git a/handlers/courses/lib/onPaid.js b/handlers/courses/lib/onPaid.js new file mode 100755 index 000000000..50bae8145 --- /dev/null +++ b/handlers/courses/lib/onPaid.js @@ -0,0 +1,73 @@ +const Order = require('payments').Order; +const assert = require('assert'); +const path = require('path'); +const log = require('log')(); +const config = require('config'); +const sendMail = require('mailer').send; +const CourseInvite = require('../models/courseInvite'); +const CourseGroup = require('../models/courseGroup'); +const createOrderInvites = require('./createOrderInvites'); +const VideoKey = require('videoKey').VideoKey; +const sendInvite = require('./sendInvite'); + +// not a middleware +// can be called from CRON +module.exports = function* (order) { + + yield Order.populate(order, {path: 'user'}); + + var group = yield CourseGroup.findById(order.data.group).exec(); + + var emails = order.data.emails; + + // order.user is the only one registered person, we know all about him + var orderUserIsParticipant = emails.indexOf(order.user.email) != -1; + + // is there anyone except the user? + var orderHasParticipantsExceptUser = order.data.count > 1 || emails[0] != order.user.email; + + var orderHasParticipants = emails.length > 0; + + log.debug("orderHasParticipants:", orderHasParticipants, "orderUserIsParticipant:", orderUserIsParticipant, "orderHasParticipantsExceptUser:", orderHasParticipantsExceptUser); + + var invites = yield* createOrderInvites(order); + + var orderUserInvite; + // send current user's invite in payment confirmation letter + if (orderUserIsParticipant) { + // probably generated above, but maybe(?) not, ensure we get it anyway + orderUserInvite = yield CourseInvite.findOne({email: order.user.email}).exec(); + assert(orderUserInvite); + invites = invites.filter(function(invite) { + return invite.email != order.user.email; + }); + } + + yield group.persist(); + + yield sendMail({ + templatePath: path.join(__dirname, '../templates/email/paymentConfirmation'), + from: 'orders', + to: order.email, + profileOrdersUrl: order.user.getProfileUrl() + '/orders', + orderNumber: order.number, + subject: "Подтверждение оплаты за курс, заказ " + order.number, + orderHasParticipants: orderHasParticipants, + orderUserInviteLink: orderUserIsParticipant && (config.server.siteHost + '/courses/invite/' + orderUserInvite.token), + orderUserIsParticipant: orderUserIsParticipant, + orderHasOtherParticipants: orderHasParticipantsExceptUser + }); + + // send invites in parallel, for speed + yield invites.map(function(invite) { + return sendInvite(invite); + }); + + order.status = Order.STATUS_SUCCESS; + + yield order.persist(); + + log.debug("Order success: " + order.number); +}; + + diff --git a/handlers/courses/lib/patch.js b/handlers/courses/lib/patch.js new file mode 100644 index 000000000..d48955b60 --- /dev/null +++ b/handlers/courses/lib/patch.js @@ -0,0 +1,81 @@ +"use strict"; + +const CourseGroup = require('../models/courseGroup'); +const CourseParticipant = require('../models/courseParticipant'); +const CourseInvite = require('../models/courseInvite'); +const _ = require('lodash'); +const sendOrderInvites = require('./sendOrderInvites'); +const Order = require('payments').Order; +const User = require('users').User; + +// called by payments/common/order +module.exports = function*() { + + //var group = yield CourseGroup.findById(this.order.data.group).exec(); + var participants = yield CourseParticipant.find({ + group: this.order.data.group + }).populate('user').exec(); + + var participantsByEmail = _.indexBy(participants, function(participant) { + return participant.user.email; + }); + + if ("emails" in this.request.body) { + + let emails = _.unique(this.request.body.emails.split(',').filter(Boolean)); + + this.log.debug("Incoming emails", emails); + + // ignore the email if it's a participant + emails = emails.filter(function throwAwayParticipantsInSubmitted(email) { + return !(email in participantsByEmail); + }); + + this.log.debug("Incoming emails except participants", emails); + + // create a new emails list + // first, take participants from the order: + var newEmails = this.order.data.emails.filter(function keepParticipantsInOrder(email) { + return email in participantsByEmail; + }); + + this.log.debug("Order participant emails", newEmails); + + // second, add new (non-participating see above) emails + newEmails = newEmails.concat(emails); + + this.log.debug("Order new emails", newEmails); + + // should never happen (handwired request) + if (newEmails.length > this.order.data.count) { + this.throw(400, "Too many emails."); + } + + this.order.data.emails = newEmails; + } + + if ("contactName" in this.request.body) { + this.order.data.contactName = this.request.body.contactName; + } + + if ("contactPhone" in this.request.body) { + this.order.data.contactPhone = this.request.body.contactPhone; + } + + this.order.markModified('data'); + yield this.order.persist(); + + + var invites = []; + if (this.order.status == Order.STATUS_SUCCESS) { + invites = yield* sendOrderInvites(this.order); + } + + if (invites.length) { + let emails = _.pluck(invites, 'email'); + this.body = 'Информация обновлена, приглашения высланы на адреса: ' + emails.join(", ") + '.'; + } else { + this.body = 'Информация об участниках обновлена.'; + } +}; + diff --git a/handlers/courses/lib/paymentMethods.js b/handlers/courses/lib/paymentMethods.js new file mode 100755 index 000000000..454452fad --- /dev/null +++ b/handlers/courses/lib/paymentMethods.js @@ -0,0 +1,11 @@ +const payments = require('payments'); + +var paymentMethods = {}; + +var methodsEnabled = [ 'paypal', 'webmoney', 'yandexmoney', 'payanyway', 'interkassa', 'banksimple', 'invoice']; + +methodsEnabled.forEach(function(key) { + paymentMethods[key] = payments.methods[key].info; +}); + +module.exports = paymentMethods; diff --git a/handlers/courses/lib/registerParticipants.js b/handlers/courses/lib/registerParticipants.js new file mode 100644 index 000000000..2137a7a0f --- /dev/null +++ b/handlers/courses/lib/registerParticipants.js @@ -0,0 +1,127 @@ +const CourseGroup = require('../models/courseGroup'); +const log = require('log')(); +const CourseParticipant = require('../models/courseParticipant'); +const config = require('config'); +const XmppClient = require('xmppClient'); +const VideoKey = require('videoKey').VideoKey; +const User = require('users').User; +const co = require('co'); + +module.exports = grantKeysAndChatToGroup; + +function* grantKeysAndChatToGroup(group) { + yield CourseGroup.populate(group, 'course'); + + var participants = yield CourseParticipant.find({ + group: group._id, + isActive: true + }).populate('user').exec(); + + yield* grantXmppChatMemberships(group, participants); + + if (group.course.videoKeyTag) { + yield *grantVideoKeys(group, participants); + } +} + + +function* grantVideoKeys(group, participants) { + + var participantsWithoutKeys = participants.filter(function(participant) { + return !participant.videoKey; + }); + + // everyone has the key => exit + if (!participantsWithoutKeys.length) return; + + var videoKeys = yield VideoKey.find({ + tag: group.course.videoKeyTag, + used: false + }).limit(participantsWithoutKeys.length).exec(); + + log.debug("Keys selected", videoKeys && videoKeys.toArray()); + + if (!videoKeys || videoKeys.length != participantsWithoutKeys.length) { + throw new Error("Недостаточно серийных номеров " + participantsWithoutKeys.length); + } + + for (var i = 0; i < participantsWithoutKeys.length; i++) { + var participant = participantsWithoutKeys[i]; + participant.videoKey = videoKeys[i].key; + yield participant.persist(); + videoKeys[i].used = true; + yield videoKeys[i].persist(); + } + +} + + + + +function* grantXmppChatMemberships(group, participants) { + log.debug("Grant xmpp chat membership"); + // grant membership in chat + var client = new XmppClient({ + jid: config.xmpp.admin.login + '/host', + password: config.xmpp.admin.password + }); + + yield client.connect(); + + + + var roomJid = yield client.createRoom({ + roomName: group.webinarId, + membersOnly: 1 + }); + + var jobs = []; + for (var i = 0; i < participants.length; i++) { + var participant = participants[i]; + + log.debug("grant " + roomJid + " to", participant.user.profileName, participant.firstName, participant.surname); + + jobs.push(client.grantMember(roomJid, participant.user.profileName + '@' + config.xmpp.server, participant.fullName)); + } + + // grant all in parallel + yield jobs; + + client.disconnect(); +} + +// when user updates his details, regrant his groups, just in case he changed his name +User.schema.pre('save', function(next) { + var user = this; + co(function*() { + + var paths = user.modifiedPaths(); + + next(); + + if (paths.indexOf('profileName') == -1) return; + + // wait 1 sec for db to save all changes, + // that's for grant calls to populate user correctly + yield function(callback) { + setTimeout(callback, 1000); + }; + + var participants = yield CourseParticipant.find({ + user: user._id + }).populate('group').exec(); + + var groups = participants.map(function(participant) { + return participant.group; + }); + + for (var i = 0; i < groups.length; i++) { + var group = groups[i]; + yield grantKeysAndChatToGroup(group); + } + + }).catch(function(err) { + log.error("Grant error", err); + }); + +}); diff --git a/handlers/courses/lib/routeGroupBySlug.js b/handlers/courses/lib/routeGroupBySlug.js new file mode 100644 index 000000000..e0a210067 --- /dev/null +++ b/handlers/courses/lib/routeGroupBySlug.js @@ -0,0 +1,18 @@ +const CourseGroup = require('../models/courseGroup'); +const CourseParticipant = require('../models/courseGroup'); + +module.exports = function*(slug, next) { + + var group = yield CourseGroup.findOne({ + slug: slug + }).populate('course').exec(); + + if (!group) { + this.throw(404, "Нет такой группы."); + } + + this.groupBySlug = group; + + yield* next; + +}; diff --git a/handlers/courses/lib/sendInvite.js b/handlers/courses/lib/sendInvite.js new file mode 100644 index 000000000..1f1f75ecb --- /dev/null +++ b/handlers/courses/lib/sendInvite.js @@ -0,0 +1,28 @@ +const sendMail = require('mailer').send; +const path = require('path'); +const CourseInvite = require('../models/courseInvite'); +const config = require('config'); +const User = require('users').User; + +module.exports = function*(invite) { + + yield CourseInvite.populate(invite, [{path: 'order'}, {path: 'group'}]); + + var userExists = yield User.findOne({ + email: invite.email + }).exec(); + + yield sendMail({ + templatePath: path.join(__dirname, '../templates/email/invite'), + from: 'orders', + to: invite.email, + contactName: invite.order.data.contactName, + subject: "Приглашение на курс, в группу " + invite.group.title, + order: invite.order, + group: invite.group, + userExists: userExists, + link: config.server.siteHost + '/courses/invite/' + invite.token + }); + + +}; diff --git a/handlers/courses/lib/sendOrderInvites.js b/handlers/courses/lib/sendOrderInvites.js new file mode 100644 index 000000000..a496487fe --- /dev/null +++ b/handlers/courses/lib/sendOrderInvites.js @@ -0,0 +1,83 @@ +// DEPRECATED + +const sendMail = require('mailer').send; +const CourseInvite = require('../models/courseInvite'); +const _ = require('lodash'); +const log = require('log')(); +const sendInvite = require('./sendInvite'); +const CourseGroup = require('../models/courseGroup'); +const User = require('users').User; + +/** + * create and send invites for the order + * except those that already exist + * @param order + */ +module.exports = function*(order) { + + // first create invites, (in case if mailer dies we have them all) + var invites = yield createInvites(order); + + yield sendInvites(invites); + + return invites; + +}; + +function* createInvites(order) { + + var emails = order.data.emails; + + // get existing invites, so that we won't recreate them + var existingInvites = yield CourseInvite.find({ order: order._id }).exec(); + var existingInviteByEmails = _.indexBy(existingInvites, 'email'); + + log.debug("existing invites", existingInviteByEmails); + + // get existing participants, they don't need invites + var group = yield CourseGroup.findById(order.data.group).exec(); + yield CourseGroup.populate(group, 'participants'); + yield User.populate(group, 'participants.user'); + + var participantsByEmail = _.indexBy(_.pluck(group.participants, 'user'), 'email'); + + var invites = []; + for (var i = 0; i < emails.length; i++) { + var email = emails[i]; + if (participantsByEmail[email]) continue; // in group already + if (existingInviteByEmails[email]) continue; // invite exists already + + log.debug("create invite for email", email); + + var invite = new CourseInvite({ + order: order._id, + group: group._id, + // max(now + 7 days, course start + 7 days) + validUntil: new Date( Math.max(Date.now(), group.dateStart) + 7 * 24 * 86400 * 1e3), + email: email + }); + invites.push(invite); + + yield invite.persist(); + + // not only send invite, but enable the tab so that the user can manually accept it + yield User.update({ + email: email + }, { + $addToSet: {profileTabsEnabled: 'courses'} + }); + + } + + return invites; +} + +function* sendInvites(invites) { + + // send invites in parallel, for speed + yield invites.map(function(invite) { + return sendInvite(invite); + }); + +} + diff --git a/handlers/courses/models/course.js b/handlers/courses/models/course.js new file mode 100755 index 000000000..378aa87ec --- /dev/null +++ b/handlers/courses/models/course.js @@ -0,0 +1,65 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var CourseGroup = require('./courseGroup'); + +var schema = new Schema({ + // like "nodejs", same as template + slug: { + type: String, + unique: true, + required: true + }, + + // "Курс JavaScript/DOM/интерфейсы" + title: { + type: String, + required: true + }, + + // short description to show in the list + shortDescription: { + type: String + }, + + videoKeyTag: { + // may be 2 adjacent courses have same video tag + type: String + }, + + weight: { + type: Number, + required: true + }, + + // is this course in the open course list (otherwise hidden)? + // even if not, the course is accessible by a direct link + isListed: { + type: Boolean, + required: true, + default: false + }, + + + created: { + type: Date, + default: Date.now + } +}); + + +schema.methods.getUrl = function() { + return '/courses/' + this.slug; +}; + +schema.methods.hasOpenGroups = function*() { + var anyGroup = yield CourseGroup.findOne({ + isOpenForSignup: true, + isListed: true, + course: this._id + }).exec(); + + return Boolean(anyGroup); +}; + +module.exports = mongoose.model('Course', schema); + diff --git a/handlers/courses/models/courseFeedback.js b/handlers/courses/models/courseFeedback.js new file mode 100644 index 000000000..78cc33e67 --- /dev/null +++ b/handlers/courses/models/courseFeedback.js @@ -0,0 +1,81 @@ +var mongoose = require('mongoose'); +var autoIncrement = require('mongoose-auto-increment'); +var Schema = mongoose.Schema; +var countries = require('countries'); + +var schema = new Schema({ + + group: { + type: Schema.Types.ObjectId, + ref: 'CourseGroup', + required: true + }, + + stars: { + type: Number, + required: "Не стоит оценка.", + min: 1, + max: 5 + }, + + content: { + type: String, + required: "Отсутствует текст отзыва." + }, + + participant: { + type: Schema.Types.ObjectId, + ref: 'CourseParticipant', + required: true + }, + + // todo (not used now) + // for selected reviews, to show at the courses main, cut them at this point + // todo: add an intellectual cutting function like jQuery dotdotdot, but w/o jquery + cutAtLength: { + type: Number + }, + + // copy from avatar if exists + photo: { + type: String + }, + + country: { + type: String, + enum: Object.keys(countries.all), + required: "Страна не указана." + }, + + city: { + type: String + }, + + isPublic: { + type: Boolean, + required: true + }, + + recommend: { + type: Boolean, + required: true + }, + + aboutLink: { + type: String + }, + + occupation: { + type: String + }, + + created: { + type: Date, + default: Date.now + } +}); + +schema.plugin(autoIncrement.plugin, {model: 'CourseFeedback', field: 'number', startAt: 1}); + +module.exports = mongoose.model('CourseFeedback', schema); + diff --git a/handlers/courses/models/courseGroup.js b/handlers/courses/models/courseGroup.js new file mode 100644 index 000000000..96f0e4695 --- /dev/null +++ b/handlers/courses/models/courseGroup.js @@ -0,0 +1,123 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var config = require('config'); +var fs = require('mz/fs'); +var path = require('path'); +var log = require('log')(); +var validate = require('validate'); +var CourseParticipant = require('./courseParticipant'); +var CourseMaterial = require('./courseMaterial'); + +var schema = new Schema({ + // 01.01.2015 + dateStart: { + type: Date, + required: true + }, + // 05.05.2015 + dateEnd: { + type: Date, + required: true + }, + + // like "nodejs-0402", for urls + slug: { + type: String, + required: true, + unique: true + }, + + price: { + type: Number, + required: true + }, + + // Every mon and thu at 19:00 GMT+3 + timeDesc: { + type: String, + required: true + }, + + // currently available places + // decrease onPaid + participantsLimit: { + type: Number, + required: true + }, + + // group w/o materials can set this to undefined + // otherwise there will be a link to the page (maybe without files yet) + materials: { + type: [CourseMaterial.schema], + default: [] + }, + + // is this group in the open course list (otherwise hidden)? + // even if not, the group is accessible by a direct link + isListed: { + type: Boolean, + required: true, + default: false + }, + + // is it possible to register? + isOpenForSignup: { + type: Boolean, + required: true, + default: false + }, + + // room jid AND gotowebinar id + // an offline group may not have this + webinarId: { + type: String + }, + + course: { + type: Schema.Types.ObjectId, + ref: 'Course', + required: true + }, + + // JS/UI 10.01 + // a user-friendly group title + title: { + type: String, + required: true + }, + + created: { + type: Date, + default: Date.now + } +}); + + +schema.methods.getMaterialUrl = function(material) { + return `/courses/download/${this.slug}/${material.filename}`; +}; + +schema.methods.getFeedbackUrl = function(material) { + return `/courses/groups/${this.slug}/feedback`; +}; + +schema.methods.getMaterialFileRelativePath = function(material) { + return `courses/${this.slug}/${material.filename}`; +}; + +schema.methods.getMaterialFileSize = function* (material) { + var stat = yield fs.stat(path.join(config.downloadRoot, this.getMaterialFileRelativePath(material))); + return stat.size; +}; + +schema.methods.decreaseParticipantsLimit = function(count) { + count = count === undefined ? 1 : count; + this.participantsLimit -= count; + if (this.participantsLimit < 0) this.participantsLimit = 0; + if (this.participantsLimit === 0) { + this.isOpenForSignup = false; // we're full! + } +}; + +module.exports = mongoose.model('CourseGroup', schema); + diff --git a/handlers/courses/models/courseInvite.js b/handlers/courses/models/courseInvite.js new file mode 100644 index 000000000..001948a0e --- /dev/null +++ b/handlers/courses/models/courseInvite.js @@ -0,0 +1,65 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var schema = new Schema({ + + // invite page visited + // -> check order if the person is in the list (not removed) + // -> add to participants and accept + // invite belongs to the order, not group, + // so we can check it agains order actual participants + order: { + type: Schema.Types.ObjectId, + ref: 'Order' + // not required, invite may exist without an order ("free second time" for people who had problems) + }, + + // when order is null, + // this field is the only way to get the group to join + group: { + type: Schema.Types.ObjectId, + ref: 'CourseGroup', + required: true + }, + + token: { + type: String, + required: true, + default: function() { + return Math.random().toString(36).slice(2, 10); + } + }, + + email: { + type: String, + required: true + }, + + accepted: { + type: Boolean, + required: true, + default: false + }, + + validUntil: { + type: Date, + required: true + // invite is also a login token, so limit it + // max(group + 7 days, created + 7 days) + }, + + created: { + type: Date, + default: Date.now + } +}); + + +schema.methods.accept = function*() { + yield this.persist({ + accepted: true + }); +}; + +module.exports = mongoose.model('CourseInvite', schema); + diff --git a/handlers/courses/models/courseMaterial.js b/handlers/courses/models/courseMaterial.js new file mode 100644 index 000000000..6fe5ce368 --- /dev/null +++ b/handlers/courses/models/courseMaterial.js @@ -0,0 +1,28 @@ +var mongoose = require('lib/mongoose'); +var Schema = mongoose.Schema; +var fs = require('mz/fs'); +var config = require('config'); +var path = require('path'); + +var schema = new Schema({ + // Введение в JavaScript + title: { + type: String, + required: true + }, + + // 2015_05_05_1930.zip + filename: { + type: String, + required: true + }, + + created: { + type: Date, + default: Date.now + } + +}); + + +module.exports = mongoose.model('CourseMaterial', schema); diff --git a/handlers/courses/models/courseParticipant.js b/handlers/courses/models/courseParticipant.js new file mode 100644 index 000000000..5a9db035a --- /dev/null +++ b/handlers/courses/models/courseParticipant.js @@ -0,0 +1,105 @@ +var mongoose = require('lib/mongoose'); +var Schema = mongoose.Schema; +var config = require('config'); +var fs = require('mz/fs'); +var path = require('path'); +var log = require('log')(); +var validate = require('validate'); +var countries = require('countries'); + +// make sure ref:User is resolved when a gulp task wants this model +require('users').User; + +var schema = new Schema({ + + group: { + type: Schema.Types.ObjectId, + ref: 'CourseGroup', + required: true + }, + + // participation cancelled? + isActive: { + type: Boolean, + required: true, + default: true + }, + + firstName: { + type: String, + validate: [ + {validator: /\S/, msg: "Имя отсутствует."}, + {validator: validate.patterns.singleword, msg: "Имя дожно состоять из одного слова."} + ], + default: "", + maxlength: 128 + }, + surname: { + type: String, + validate: [ + {validator: /\S/, msg: "Фамилия отсутствует."}, + {validator: validate.patterns.singleword, msg: "Фамилия должна состоять из одного слова."} + ], + default: "", + maxlength: 128 + }, + photo: { + type: String + }, + country: { + type: String, + enum: Object.keys(countries.all), + required: "Страна не указана." + }, + city: { + type: String, + maxlength: 128 + }, + aboutLink: { + type: String, + validate: [ + function(value) { return value ? validate.patterns.webpageUrl.test(value) : true; }, + "Некорректный URL страницы." + ], + maxlength: 4 * 1024 + }, + occupation: { + type: String, + maxlength: 2 * 1024 + }, + purpose: { + type: String, + maxlength: 16 * 1024 + }, + + wishes: { + type: String, + maxlength: 16 * 1024 + }, + + user: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true, + required: true + }, + + shouldNotifyMaterials: { + type: Boolean, + default: true + }, + + videoKey: { + type: String + // there may be groups without video & keys + // for those with videos, video key is stored in participant + } +}); + +schema.index({group: 1, user: 1}, {unique: true}); + +schema.virtual('fullName').get(function () { + return this.firstName + ' ' + this.surname; +}); + +module.exports = mongoose.model('CourseParticipant', schema); diff --git a/handlers/courses/router.js b/handlers/courses/router.js new file mode 100755 index 000000000..529cee3a0 --- /dev/null +++ b/handlers/courses/router.js @@ -0,0 +1,39 @@ +var Router = require('koa-router'); +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var mustBeParticipant = require('./lib/mustBeParticipant'); +var mustBeAdmin = require('auth').mustBeAdmin; +var router = module.exports = new Router(); + +router.param('userById', require('users').routeUserById); +router.param('groupBySlug', require('./lib/routeGroupBySlug')); + +router.get('/register-participants/:groupBySlug', mustBeAdmin, require('./controller/registerParticipants').get); + + +router.get('/', require('./controller/frontpage').get); +router.get('/:course', require('./controller/course').get); + +// same controller for new signups & existing orders +router.get('/groups/:groupBySlug/signup', require('./controller/signup').get); +router.get('/orders/:orderNumber(\\d+)', require('./controller/signup').get); + +router.get('/groups/:groupBySlug/info', mustBeParticipant, require('./controller/groupInfo').get); +router.get('/groups/:groupBySlug/materials', mustBeParticipant, require('./controller/groupMaterials').get); + +// not groups/:groupBySlug/* url, +// because the prefix /course/download must be constant for nginx to proxy *.zip to node +router.get('/download/:groupBySlug/:filename', mustBeParticipant, require('./controller/groupMaterialsDownload').get); + +router.all('/groups/:groupBySlug/feedback', mustBeParticipant, require('./controller/groupFeedbackEdit').all); + +router.get('/feedback/:feedbackNumber', require('./controller/groupFeedbackShow').get); + +router.patch('/participants', require('./controller/participants').patch); +router.get('/download/participant/:participantId/certificate.jpg', mustBeAuthenticated, require('./controller/participantCertificateDownload').get); + + +router.all('/invite/:inviteToken?', require('./controller/invite').all); + +// for profile +router.get('/profile/:userById', mustBeAuthenticated, require('./controller/coursesByUser').get); + diff --git a/handlers/courses/tasks/groupSend.js b/handlers/courses/tasks/groupSend.js new file mode 100644 index 000000000..3c3a4ce1a --- /dev/null +++ b/handlers/courses/tasks/groupSend.js @@ -0,0 +1,102 @@ +"use strict"; + +var co = require('co'); +var fs = require('fs'); +var _ = require('lodash'); +var log = require('log')(); +var gutil = require('gulp-util'); +const path = require('path'); +const CourseGroup = require('../models/courseGroup'); +const CourseParticipant = require('../models/courseParticipant'); +const User = require('users').User; +const mailer = require('mailer'); +const config = require('config'); + +module.exports = function() { + + return function() { + + var args = require('yargs') + .example("gulp courses:group:send --group nodejs --templatePath ./mail.jade --subject 'Тема письма'") + .example("gulp courses:group:send --group js-1 --templatePath ./extra/groupLetters/js-1-end.jade --subject 'Завершение курса JavaScript'") + .example("gulp courses:group:send --group js-1405 --templatePath ./js-1405.jade --subject 'Курс JavaScript: напоминание о собрании' --test iliakan@gmail.com") + .example("gulp courses:group:send --group js-1405 --templatePath ./js-1405.jade --subject 'Курс JavaScript: напоминание о собрании'") + .describe('group', 'Название группы') + .describe('templatePath', 'Шаблон для рассылки, имя файла становится меткой для письма. При повторной посылке файла с тем же именем те email, которым отправлялись письма с этой меткой раньше, игноируются') + .describe('subject', 'Тема письма') + .describe('test', 'Email, на который выслать тестовое письмо.') + .demand(['group', 'templatePath', 'subject']) + .argv; + + return co(function* () { + var group = yield CourseGroup + .findOne({slug: args.group}) + .exec(); + + if (!group) { + throw new Error("No group:" + args.group); + } + + var participants = yield CourseParticipant.find({ + isActive: true, + group: group._id + }).populate('user').exec(); + + var recipients = participants + .map(function(participant) { + return {email: participant.user.email, name: participant.fullName}; + }); + + // filter out already received + var recipientsByEmail = _.indexBy(recipients, 'email'); + + var label = path.basename(args.templatePath); + + let letters = yield mailer.Letter.find({label: label}).exec(); + for (let i = 0; i < letters.length; i++) { + let to = letters[i].message.to; + for (let j = 0; j < to.length; j++) { + let previousRecepient = to[j]; + delete recipientsByEmail[previousRecepient.email]; + } + } + + var recipientsToSend = _.values(recipientsByEmail); + + if (args.test) { + recipientsToSend = [{email: args.test}]; + } + + var usersByEmail = {}; + for (var i = 0; i < recipientsToSend.length; i++) { + var recipient = recipientsToSend[i]; + var user = yield User.findOne({email: recipient.email}).exec(); + if (!user) { + throw new Error("No user for email: " + recipient.email); + } + usersByEmail[recipient.email] = user; + } + + for (var i = 0; i < recipientsToSend.length; i++) { + + var recipient = recipientsToSend[i]; + yield* mailer.send({ + from: 'informer', + templatePath: args.templatePath, + to: [recipient], + user: usersByEmail[recipient.email], + group: group, + subject: args.subject, + label: args.test ? undefined : label + }); + + gutil.log("Sent letter to " + JSON.stringify(recipient)); + } + + if (!recipients.length) { + gutil.log(`No recipients (was ${recipients.length} before label exclusion)`); + } + + }); + }; +}; diff --git a/handlers/courses/tasks/inviteRemind.js b/handlers/courses/tasks/inviteRemind.js new file mode 100644 index 000000000..5822d2020 --- /dev/null +++ b/handlers/courses/tasks/inviteRemind.js @@ -0,0 +1,54 @@ +var co = require('co'); +var _ = require('lodash'); +var log = require('log')(); +var gutil = require('gulp-util'); +const path = require('path'); +const CourseGroup = require('../models/courseGroup'); +const CourseInvite = require('../models/courseInvite'); +const mailer = require('mailer'); +const config = require('config'); + +// send mail to all successful order participants +module.exports = function() { + + return function() { + + var args = require('yargs') + .example("gulp courses:invite:remind --group nodejs-1") + .describe('group', 'URL-название группы') + .demand(['group']) + .argv; + + return co(function* () { + var group = yield CourseGroup + .findOne({slug: args.group}) + .exec(); + + if (!group) { + throw new Error("No group:" + args.group); + } + + var invitesPending = yield CourseInvite.find({ + group: group._id, + accepted: false + }); + + for (var i = 0; i < invitesPending.length; i++) { + var invite = invitesPending[i]; + + log.info("Pending invite", invite.toObject()); + + yield* mailer.send({ + from: 'orders', + templatePath: path.join(__dirname, '../templates/email/inviteRemind'), + link: config.server.siteHost + '/courses/invite/' + invite.token, + to: [{email: invite.email}], + group: group, + subject: group.title + ' - вы не присоединились к группе' + }); + + } + + }); + }; +}; diff --git a/handlers/courses/tasks/materialAdd.js b/handlers/courses/tasks/materialAdd.js new file mode 100644 index 000000000..423bbab22 --- /dev/null +++ b/handlers/courses/tasks/materialAdd.js @@ -0,0 +1,88 @@ +const path = require('path'); +const sendMail = require('mailer').send; +const config = require('config'); +const co = require('co'); +const fs = require('mz/fs'); +const gutil = require('gulp-util'); +const yargs = require('yargs'); +const CourseMaterial = require('../models/courseMaterial'); +const CourseGroup = require('../models/courseGroup'); +const CourseParticipant = require('../models/courseParticipant'); +const User = require('users').User; +const _ = require('lodash'); + +module.exports = function() { + + return function() { + + const argv = require('yargs') + // file should be in download/courses/js-1/js-basic.zip + .usage('gulp courses:material:add --group js-1 --title "Введение" --file js-basic.zip --comment "Welcome."') + .describe('group', 'Group slug') + .describe('title', 'The name of the file to be shown on the group page') + .describe('file', 'File name (must be in the group materials folder)') + .describe('comment', 'A paragraph of text to append to the letter') + .demand(['group', 'file']) + .argv; + + + return co(function*() { + var group = yield CourseGroup + .findOne({slug: argv.group}) + .exec(); + + if (!group) { + throw new Error("No group:" + argv.group); + } + + var participants = yield CourseParticipant.find({ + isActive: true, + shouldNotifyMaterials: true, + group: group._id + }).populate('user').exec(); + + if (_.some(group.materials, {filename: argv.file})) { + throw new Error(`Material ${argv.file} already exists in group ${argv.group}`); + } + + var material = { + title: argv.title || argv.file, + filename: argv.file + }; + + var filePath = `${config.downloadRoot}/courses/${group.slug}/${material.filename}`; + + var fileExists = yield fs.exists(filePath); + + if (!fileExists) { + throw new Error("No such file: " + fileExists); + } + + group.materials.push(material); + + yield group.persist(); + + gutil.log(`Added ${argv.file} to group ${argv.group}`); + + var recipients = participants + .map(function(participant) { + return {email: participant.user.email, name: participant.fullName}; + }); + + yield sendMail({ + templatePath: path.join(__dirname, '../templates/email/materials'), + subject: "Добавлены материалы курса", + to: recipients, + comment: argv.comment, + link: config.server.siteHost + `/courses/groups/${group.slug}/materials`, + fileLink: config.server.siteHost + `/courses/download/${group.slug}/${material.filename}`, + fileTitle: material.title + }); + + gutil.log("Sent notification to", recipients); + + }); + + }; + +}; diff --git a/handlers/courses/templates/blocks/contacts.jade b/handlers/courses/templates/blocks/contacts.jade new file mode 100644 index 000000000..77df110d7 --- /dev/null +++ b/handlers/courses/templates/blocks/contacts.jade @@ -0,0 +1,34 @@ ++b('form')(data-elem="contact").complex-form._step_2 + +e.step._current + +b.course-register-contacts.courses-register-common + +e('h2').title.courses-register-common__title Контактная информация + +e('p').note + | Оставьте ваши контактные данные, чтобы мы могли связаться с вами + | в случае необходимости + + +e.body + +b.contact-form + +e.content + +e.fields + +e.name + label(for="contact-name") Имя и Фамилия: + +b.text-input._small.__name-input + +e('input')(data-elem="contactName", required).control#contact-name + +e.tel + label(for="contact-phone") Телефон: + +b.full-phone.__full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control#contact-phone(data-elem="contactPhone" type='tel', pattern=validate.patterns.phone, placeholder='+X (XXX) XXX-XXXX') + +e.note + +e('h5').note-title Ваши данные в безопасности + p + | Никакие ваши личные данные + | не будут переданы третьим лицам, кроме как по вашему желанию или для + | целей выполнения заключенного с вами договора. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + diff --git a/handlers/courses/templates/blocks/frontpage/coursesList.jade b/handlers/courses/templates/blocks/frontpage/coursesList.jade new file mode 100644 index 000000000..6ce9bc3c7 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/coursesList.jade @@ -0,0 +1,15 @@ ++b.courses-programm-register + + +e('a').anchor#courses + +e('h2').title Программа курсов и запись + +e('ul').courses + + for course in coursesInfo + +e('li').course + +e('h3').course-title + +b('a').link(href=course.url)= course.title + if course.hasOpenGroups + +e('span').course-badge Идёт набор в группы + +e('div').course-text + != course.shortDescription + diff --git a/handlers/courses/templates/blocks/frontpage/faq.jade b/handlers/courses/templates/blocks/frontpage/faq.jade new file mode 100644 index 000000000..51540cf00 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/faq.jade @@ -0,0 +1,21 @@ +- var questions = []; +- questions.push({ title: 'А это все правда? Действительно ли курсы такие хорошие?', answer: ['

Вам решать.

Здесь нет курсов по HTML/CSS/PHP/Photoshop и прочему разному.

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

'] }); +- questions.push({ title: 'Какие есть способы оплаты? Можно ли от организации?', answer: ['

Все основные способы предусмотрены. Есть оплата через банк, Webmoney, Paypal, Я.Деньги, банковской карточкой и от компании.

Подписанные документы при необходимости высылаются в виде сканов или по почте.

Выбор способа оплаты – в процессе оформления.

'] }); +- questions.push({ title: 'А можно ваши курсы скачать?', answer: ['

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

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

'] }); +- questions.push({ title: 'Что, если кто-то не будет успевать за программой?', answer: ['

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

Кривая обучения растёт постепенно, сначала медленнее, потом быстрее, так что, если полноценно участвовать, то всё у всех получается.

Если же кто-то не успевает и пишет мне об этом, то я помогаю, если нужно – даже организую дополнительное занятие. Это не проблема. Если такое произойдёт с вами – обязательно напишите. Отстающих не бросаем и группу не тормозим. В крайнем случае можно перевестись в другую группу и освоить программу ещё раз с ней.

'] }); +- questions.push({ title: 'Планируются ли дополнительно курсы по...? Когда?', answer: ['

Сложно предсказать. Зачастую планы возникают неожиданно. Если вы хотели бы узнавать о них, то можете запросить уведомление здесь.

'] }); + ++b.courses-faq.courses-mix + +e('h2').title Часто задаваемые вопросы + +e.body + + +e('ul').questions + for question, index in questions + +e('li').question + +e('input').input(type="checkbox" id= 'q' + index) + +e('label').question-title(for= 'q' + index)!= question.title + +e.answer!= question.answer + + p У вас другой вопрос? Напишите его в комментариях внизу этой страницы или мне на почту mk@javascript.ru (проверяется регулярно), а если совсем срочно — по телефону +7-903-5419441. + + p Почитать предыдущие комментарии к этой странице можно в старом движке. diff --git a/handlers/courses/templates/blocks/frontpage/features.jade b/handlers/courses/templates/blocks/frontpage/features.jade new file mode 100644 index 000000000..ce274d602 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/features.jade @@ -0,0 +1,16 @@ +- var features = []; + +- features.push({ name: 'quality', title: 'Качество', text: 'Это самое главное. Мы изучаем разработку на профессиональном уровне' }); +- features.push({ name: 'online', title: 'Дистанционность', text: 'На практике это оказывается удобнее, чем очные курсы' }); +- features.push({ name: 'support', title: 'Поддержка', text: 'Вы получите советы по развитию именно для вас' }); +- features.push({ name: 'result', title: 'Результат', text: 'Цель курсов - получить конкретные результаты в плане знаний и умений' }); +- features.push({ name: 'guarantees', title: 'Гарантии', text: 'Возврат денег, если что-то не так' }); + ++b.courses-features + +e('h2').title Особенности курсов + +e('ul').features + for feature in features + +e('li')(class=['feature', '_' + feature.name]) + +e('h3').feature-title!= feature.title + +e('p').feature-text!= feature.text + diff --git a/handlers/courses/templates/blocks/frontpage/guarantee.jade b/handlers/courses/templates/blocks/frontpage/guarantee.jade new file mode 100644 index 000000000..2849e134b --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/guarantee.jade @@ -0,0 +1,18 @@ ++b.courses-guarantee.courses-mix + h2 Гарантии + + p Всем участникам курсов, независимо от пола, возраста, ориентации и религиозной принадлежности… + + +e('p').list-wrap Гарантия: + + ul + li + strong Если объяснения будут вам непонятны + li + strong Если курсы не дадут вам новых знаний и умений + li + strong Если вы не сможете подключиться к системе онлайн-обучения + + +e('p').list-wrap …то вы сможете получить деньги назад. + + p Для этого достаточно не позже окончания первой недели курса написать мне, указать причину из этого списка и что именно вас не устраивает, удостоверить свою личность, чтобы возврат не потребовал хакер, и тогда ваше участие будет прекращено, а вы получите деньги обратно, удобным для вас способом. diff --git a/handlers/courses/templates/blocks/frontpage/master.jade b/handlers/courses/templates/blocks/frontpage/master.jade new file mode 100644 index 000000000..c7fab5c3c --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/master.jade @@ -0,0 +1,12 @@ ++b.courses-master + +e('h2').title Ведущий + +e('p').text Веду курсы я сам, Илья Кантор (Github), разработчик, создатель сайта javascript.ru. + +e('p').text Более 20 лет общего опыта программирования, из них более 12 лет JavaScript-разработки и консультирования в области Frontend. + +e('p').text. + Начиная с 2007 года вёл мастер-классы для опытных разработчиков, + в которых участвовали сотрудники ведущих IT-компаний России и Украины + (страница проекта, отзывы). + С января 2011 года открыты курсы (отзывы). + + + diff --git a/handlers/courses/templates/blocks/frontpage/participants-logos.jade b/handlers/courses/templates/blocks/frontpage/participants-logos.jade new file mode 100644 index 000000000..c1922facc --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/participants-logos.jade @@ -0,0 +1,25 @@ ++b.participants-logos.courses-mix + +e('h2').title У нас обучались + + p. + Мастер-классы для профессионалов в области JavaScript я проводил давно, примерно с 2006 года, а курсы – с 2011 года. + За это время обучились тысячи человек из сотен компаний, всех их перечислить сложно. + В частности, проходили обучение сотрудники этих компаний: + + //- _disable_left | _disable_right + +e.slider(data-participants-slider) + +e('i').arr._left + +e('i').arr._right + +e.slider-i + - var i = 1 + //- change translateX for list + +e('ul').list + while i <= 27 + +e('li').item + +e('img').logo(src="/courses/logos/"+i+".png" height="60px") + - i++ + + p. + За время обучения были оставлены сотни отзывов, + которые пока можно найти здесь и здесь, + скоро мы добавим их в улучшенном виде и на эту страницу. Некоторые отзывы профессионалов вы можете прямо сейчас увидеть ниже. diff --git a/handlers/courses/templates/blocks/frontpage/phone-toggler.jade b/handlers/courses/templates/blocks/frontpage/phone-toggler.jade new file mode 100644 index 000000000..fba847d65 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/phone-toggler.jade @@ -0,0 +1,3 @@ +input(type="checkbox" id="phone-toggler").phone-toggler__input.phone-only ++b('label').phone-toggler.phone-only(for="phone-toggler") + | Информация о ведущем и особенностях курсов. diff --git a/handlers/courses/templates/blocks/frontpage/professionals.jade b/handlers/courses/templates/blocks/frontpage/professionals.jade new file mode 100644 index 000000000..d20d6c504 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/professionals.jade @@ -0,0 +1,44 @@ ++b.courses-professionals.courses-mix + +e('section').feedbacks + +e('h2').title Мнение профессионалов + + +e('article').feedback + +e.userpic + +e('img').userpic-img(src="/img/courses/dmitryx.jpg" width="86" height="86") + +e('h3').feedback-title Дмитрий Поляков + +e('a').homepage(href="https://www.linkedin.com/in/dmitryx" target="_blank") LinkedIn + +e('p').about Frontend-разработчик в Google, делает Youtube, общий опыт работы архитектором и ведущим разработчиком различных проектов более 15 лет. + + p. + Участвовал в мастер-классах Ильи несколько раз, узнал много полезного. + Очень нравится профессиональное и отлично организованное изложение и структуризация материала, + приводимые примеры и паттерны применения в настоящей разработке. + Считаю Илью одним из лучших JS разработчиков и ведущих. + Крайне рекомендую курсы для тех, кто хочет отточить свои знания и стать профессионалом. + + +e('article').feedback + +e.userpic + +e('img').userpic-img(src="/img/courses/tyv.jpg" width="86" height="86") + +e('h3').feedback-title Юрий Ткаченко + +e('a').homepage(href="https://ua.linkedin.com/in/tkachenkoyuri" target="_blank") LinkedIn + +e('p').about Frontend-разработчик, в Яндекс 3 года руководил одной из команд верстальщиков, общий опыт Frontend-разработки более 10 лет . + + p. + Во время работы руководителем одной из групп верстки в Яндексе + передо мной встала задача повышения квалификации большой команды верстальщиков. + После длительного анализа я выбрал курс Ильи Кантора и остался очень доволен результатом, + считаю этот курс лучшим из существующих на русском языке. + + + +e('article').feedback + +e.userpic + +e('img').userpic-img(src="/img/courses/andrewsumin.jpg" width="86" height="86") + +e('h3').feedback-title Андрей Сумин + +e('a').homepage(href="https://ru.linkedin.com/in/andrewsumin" target="_blank") LinkedIn + +e('p').about Главный по Frontend в компании Mail.ru, также принимал участие в таких проектах как hh.ru и yandex.ru. + + p. + В далёком 2006 году, будучи frontend-разработчиком в Яндекс, я посетил курс Ильи. + Уже тогда его занятия отличались сильной базой, подробным разбором важных и сложных аспектов + и грамотной организацией. + Я искренне рекомендую Илью как учителя всем кто хочет знать всё о языке JavaScript. diff --git a/handlers/courses/templates/blocks/frontpage/tabbedPane.jade b/handlers/courses/templates/blocks/frontpage/tabbedPane.jade new file mode 100644 index 000000000..4b40c1493 --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/tabbedPane.jade @@ -0,0 +1,95 @@ ++b.tabbed-pane._01.courses-tabbed-pane + + +e('ul').tabs + +e('li').tab._01 Чем эти курсы отличаются от других? + +e('li').tab._02 Зачем курсы, когда есть книги и статьи на javascript.ru? + +e('li').tab._03 Зачем курсы, если можно научиться на работе? + + +e.body._01 + + +e('h2').title.phone-only Чем эти курсы отличаются от других? + + p В интернет есть много различных курсов, но, к сожалению, большинство из них не выдерживают никакой критики. Скорее всего, вы и сами понимаете это, а если нет – спросите знакомого специалиста, он подтвердит. + p Курсы, которые находятся здесь — эффективны и не похожи ни на один из них. + + ul + li Цель — полноценная профессиональная разработка. Курс идёт с расчетом на современную разработку уровня мировых стандартов. Это немного другой уровень, чем «кнопка на коленке», и другой подход к знаниям. Понятно, что «гуру» шлифуют мастерство годами, но мы можем достаточно сильно продвинуться и научиться грамотной разработке за время курса. Для участников «с нуля» существует вводный видеокурс, который позволяет освоить самые базовые моменты заранее. + li Курс построен на примерах и задачах. Программировать — это как плавать, одной теории маловато, нужна практика, и чем больше — тем лучше. Значит – много примеров и задач. Ведь умение их решать, основанное на понимании и прямых руках — и есть реальная цель. + li Правильное понимание языка. JavaScript — особенный язык. Если взять все часы «среднего» JavaScript-разработчика, потерянные на вопросы на форумах, на отладку кривого кода… То важность этого становится очевидной. + li Актуальность… То, как делаются современные проекты, а не как это было 5 лет назад. + li Качество кода — это важно, т.к. большинство времени тратится не на изначальное написание кода, а на его развитие и поддержку. На курсах ему уделяется особое внимание. + li Непрерывная обратная связь — на любые вопросы вы получаете ответы, на ваши решения — грамотный ответ, можно ли так писать и когда возможны проблемы. + + p Курсы возникли в результате долгого опыта разработки и преподавания, очного, заочного и совмещенного, и сочетают преимущества обоих технологий. + + ul + li У вас на руках будут лекционные материалы для изучения и выполнения заданий. + li Ваши вопросы, результаты выполнения заданий, способы сделать лучше и правильнее мы обсуждаем при видео-общении онлайн. + + + +e.body._02 + + +e('h2').title.phone-only Зачем курсы, когда есть книги и статьи на javascript.ru? + + p Практика показывает, что язык программирования, как и обычные языки, все же лучше изучаются на курсах. + + p JavaScript в этом смысле особенный язык. На нём очень легко начать что-то делать. Но при этом разница между человеком, который нахватался по верхам и профессионалом, постигшим JS-дзен — колоссальна. Один делает три кнопки, другой пишет Gmail и покоряет мир. + + p Цель курсов — упростить и спрямить вторую дорогу, и пройтись по ее началу вместе, чтобы не свернуть ненароком куда не следует. А уж что вы потом захотите делать — новый Gmail или меню на сайте — вам решать. Главное это скорость и качество разработки. + + blockquote Курсы JavaScript — мощный и быстрый способ обучения. При полноценном участии они гарантируют актуальные, глубокие знания. + + p Наша цель — не просто выучить, какие есть функции. Да, методы знать нужно, но главное — уметь «думать на javascript» и разрабатывать понятный, хороший код, без ошибок и с правильной структурой. + + p Возможность участников общаться онлайн друг с другом и с ведущим, выполнение заданий также даёт более глубокое и эффективное усвоение практических навыков. + + p Ниже находится классическая «пирамида обучения». Слева указаны полученные в результате исследований средние проценты усвоения знаний. Четыре верхние ступени относятся к индивидуальному обучению. Три нижние — к групповому и, в частности, курсам. + + +b.image-with-text + + +e.img + img(src="/courses/pyramid.png" alt="пирамида обучения") + + +e.text + p На текущий момент в курсах уже участвовало более 1000 человек. Могло бы быть гораздо больше, но моя цель — не количество, а качество. Группы веду только я один, мест в них не так много. + + p Все участники как и вы, имеют доступ к гугл, книгам и javascript.ru. Но каждый имеет право на лучшее, они выбрали поход на курсы и, похоже, не пожалели. + + p Курсы — это вложение в себя. Это усилия, которые позволят быстро продвинуться. А где вы хотите быть через несколько месяцев/лет? + + p Может быть, имеет смысл level up? + + +e.body._03 + + +e('h2').title.phone-only Зачем курсы, если можно научиться на работе? + + p Забавный совет, который дают многим начинающим, такой: «читай книги, иди работай, пиши скрипты и научишься». Он отчасти правилен — действительно, нужно разрабатывать, получать опыт. + + p Но вот что касается «научиться» — на практике все не так просто. Люди могут работать долго, но качество кода при этом не всегда растёт. + + p Это и видно, мы все знаем, что компаниям нужны результаты. Им нужны хорошие разработчики, очень нужны. В современном интернет всё решают люди. За них постоянно идет борьба. На поиск выделяются ресурсы, деньги... + + p Если бы люди быстро вырастали в процессе работы — не было бы огромных трат ресурсов на поиск разработчиков. + + p Для компании обучать людей самостоятельно — гораздо затратнее, чем брать уже учёных. Поэтому предпочитают заплатить хорошему разработчику побольше, чем самостоятельно «допиливать» среднего. + + p Всё это объективные реалии, которые можно наблюдать в мире. Именно поэтому существуют курсы. Хорошие курсы могут дать очень многое, если, конечно, это — действительно хорошие курсы. + + + +script. + var className = 'tabbed-pane', + block = document.querySelector('.' + className); + + block + .querySelector('.' + className + '__tabs') + .addEventListener('click', function(e) { + + block.className = className + ' ' + + className + '_' + + e.target.className.split('_').pop(); + + }); + + + diff --git a/handlers/courses/templates/blocks/frontpage/testimonials.jade b/handlers/courses/templates/blocks/frontpage/testimonials.jade new file mode 100644 index 000000000..3a652f81b --- /dev/null +++ b/handlers/courses/templates/blocks/frontpage/testimonials.jade @@ -0,0 +1,44 @@ +- var testimonials = []; +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '5', location: { country: 'ru', text: 'Россия, Москва' }, name: 'Бендер Константинопольский', text: 'При облучении инфракрасным лазером кондуктометрия захватывает сернистый газ, даже если нанотрубки меняют свою межплоскостную ориентацию. Изомерия, как следует из совокупности экспериментальных наблюдений', text2: 'редко активирует окисленный серный эфир' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '4', location: { country: 'ua', text: 'Украина, Киев' }, name: 'Иван Пупкин', text: 'Чо норм курсы' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '3', location: { country: 'ru', text: 'Россия, Усть-Каменогороск'}, name: 'Пьер Безухов', text: 'Продукт реакции разъедает жидкофазный раствор. Упаривание активирует серный эфир' }) + ++b.courses-testimonials.courses-mix + +e('h2').title Что говорят о курсах люди + + +e.wrapper + +e('i').arr._prev + +e('i').arr._next + +e('a').all(href="/123") Все отзывы + + +e.body + +e('ul').testimonials + for testimonial in testimonials + +e('li').testimonial + +e.main + +b(class=["rating", "_" + testimonial.rating]) + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e('p').testimonial-text= renderSimpledown(testimonial.content.slice(0, testimonial.cutAtLength || Infinity), {trusted: false}) + if testimonial.cutAtLength + =' ' + +e('span').cut … + +e('span').cuted= renderSimpledown(testimonial.content.slice(testimonial.cutAtLength), {trusted: false}) + +e.user + +e.userpic + +e('img').userpic-img(src=testimonial.photoLink) + +e.username + +e('a').username-link(href="/123") !{ testimonial.name } + +e.country + +e('img').country-flag(src='/img/flags/' + testimonial.location.country + '.svg' width=16 height=12) + +e('span').country-text !{ testimonial.location.text } + +script. + + document.addEventListener('click', function(event) { + if (event.target.className != 'courses-testimonials__cut') return; + var cutElem = event.target; + cutElem.nextElementSibling.style.display = 'inline' + cutElem.remove(); + }); diff --git a/handlers/courses/templates/blocks/grayedList.jade b/handlers/courses/templates/blocks/grayedList.jade new file mode 100644 index 000000000..be5e674cc --- /dev/null +++ b/handlers/courses/templates/blocks/grayedList.jade @@ -0,0 +1,4 @@ ++b('ul').grayed-list + +e('li').item._step_2 Контактная информация + +e('li').item._step_3 Оплата + +e('li').item._step_4 Подтверждение diff --git a/handlers/courses/templates/blocks/js/how.jade b/handlers/courses/templates/blocks/js/how.jade new file mode 100644 index 000000000..fbe6edcb5 --- /dev/null +++ b/handlers/courses/templates/blocks/js/how.jade @@ -0,0 +1,19 @@ ++b.courses-how.courses-mix + +e('h2').title Как проходит обучение? + + +e.body + p Время обучения: 2 месяца, включая 10 дней каникул с самостоятельно выполняемым заданием, плюс видеокурс за неделю до начала занятий. + + p За это время мы планируем освоить очень многое. + + p Это подразумевает не ленивое ковыряние в носу во время лекции, а довольно-таки активный режим обучения. + + ol + li До начала курса вы получаете вводный видео-курс.
К основному курсу необходимо с ним ознакомиться. Там раскрыты самые базовые темы, которые можно дать в таком формате. Это введение нужно, чтобы мы на занятиях не разбирали ну уж совсем простые темы (но вы сможете задавать вопросы по ним, если будут, в том числе и до начала курса). + li Далее, к каждому занятию выдаются материалы для освоения и задачи. Если это текст - читаете, если видео - смотрите в удобное для вас время. Делаете задачи. + li Мы встречаемся два раза в неделю онлайн, я рассказываю важные и тонкие моменты, на которые следует обратить внимание в материале (простые вы изучили по лекциям дома), вы задаете вопросы, показываете решения. Мы смотрим, как можно сделать лучше. Продолжительность 1.5 часа, в зависимости от темы и количества вопросов. + + p + strong Резюмирую: будьте готовы к тому, что придётся учиться и делать реальные задачи, многие из которых не так уж просты. + + diff --git a/handlers/courses/templates/blocks/js/programAndSignup.jade b/handlers/courses/templates/blocks/js/programAndSignup.jade new file mode 100644 index 000000000..a38ca0294 --- /dev/null +++ b/handlers/courses/templates/blocks/js/programAndSignup.jade @@ -0,0 +1,66 @@ ++b.course-info._program.courses-mix + +e.body.columns.columns_2 + + +e.col.columns__col + +e.content + +e('h3').title Программа + p Курс состоит из трёх частей: + ol + li + +e.text. + Первая часть позволяет хорошо разобраться в языке JavaScript, + получить знания и навыки написания JavaScript-кода, соответствующего современным стандартам. + li + +e.text. + Вторая часть позволяет научиться работать со страницей и посетителем, + создавать меню, слайдеры, Drag’n’Drop и прочие интерфейсные компоненты. + li + +e.text. + Третья часть посвящена более сложным интерфейсам. + На ней мы изучаем, как построить архитектуру, взаимодействие между компонентами, + как организовать проект и код, систему сборки с использованием ES6. + + p Большое внимание на этом курсе уделяется стилю кода. Это важно. Хороший стиль кода позволяет писать более быстро, красиво и делать меньше ошибок. А на серьёзных проектах он просто необходим. + + +e.col.columns__col + +e.content + +e('h3').title Набор в группы + + if groups.length + +b.courses-recruitment + +e('a').anchor#signup + +e('ul').list + + each group in groups + +e('li').course + +e.info + +e('h4').title #{formatGroupDate(group.dateStart)} — #{formatGroupDate(group.dateEnd)} + +e('p').text!= group.timeDesc + + +e.apply + +b.price + +e('span') #{group.price} RUB + +e('span').secondary  ≈ #{Math.round(group.price / rateUsdRub)}$ + +e.submit + +b('a')(data-group-signup-link href='/courses/groups/' + group.slug + '/signup' type="button").button._action + +e('span').text Записаться + + p В стоимость входит 2 месяца обучения, включая одну неделю каникул с самостоятельно выполняемым заданием и организационное собрание. Также участники получают вводный видеокурс за неделю до начала занятий. + p Вы также можете подписаться на уведомления по набору новых групп по этой программе: + + include ../subscribe + + else + +b.courses-recruitment._no-groups + +e('a').anchor#signup + //- +e('h3').info-title В текущих группах мест нет. + p У нас большой апдейт курса! Больше современного JavaScript, другие разделы курса также обновлены. Следующая группа будет открыта в августе. + + p Вы можете запросить уведомление о наборе: + + include ../subscribe + + p Стоимость обучения 21000 руб. Время обучения: 2 месяца, включая одну неделю каникул с самостоятельно выполняемым заданием и организационное собрание. + + p Также участники получают вводный видеокурс за неделю до начала занятий. + diff --git a/handlers/courses/templates/blocks/js/programDetails.jade b/handlers/courses/templates/blocks/js/programDetails.jade new file mode 100644 index 000000000..f38201181 --- /dev/null +++ b/handlers/courses/templates/blocks/js/programDetails.jade @@ -0,0 +1,83 @@ ++b.courses-parts.courses-mix + +e('h2').title Основные темы программы + + +b.tabbed-pane._01 + + +e('ul').tabs + +e('li').tab._01 Первая часть курса + +e('li').tab._02 Вторая часть курса + +e('li').tab._03 Третья часть курса + + +e.body._01 + +e('h2').title.phone-only Первая часть курса + ol + li + strong Основной JavaScript. + p Здесь мы изучим сам язык, его конструкции и особенности, которые позволяют "разговаривать" на JavaScript коротко, понятно, а главное - без ошибок. + ul + li IDE, настройка, полезные приёмы использования, средства для автопроверки кода. + li Основные структуры данных, работа с числами, строками, датами, массивами, объектами. + li Инструменты разработки, отладка в браузерах. + li Автоматизированное тестирование, инструменты и их применение. + li Современный стандарт ES-2015 (ES6), его кросс-браузерное использование сейчас. + li + strong Более глубокое понимание языка. + p Чтобы писать хороший код, а также грамотно пользоваться современными фреймворками, мы изучим JavaScript лучше, включая тонкости и продвинутое применение языковых конструкций. + ul + li Замыкания и их грамотное применение. + li Внутреннее устройство движка JavaScript. + li Контекст this в деталях. + li Форвардинг, одалживание и делегирование функций. + li Прототипы, классы, прототипное и функциональное ООП, детали использования. + + p По окончанию первой части курса вы свободно пользуетесь языком JavaScript, с учётом его особенностей и новых возможностей стандарта ES-2015. Мы улучшим эти навыки в последующих частях курса. + + + + +e.body._02 + +e('h2').title.phone-only Вторая часть курса + ol + li + strong Документ, генерация интерфейса. + p Здесь мы учимся работать с документом, решать всевозможные задачи в браузере. + ul + li Внутреннее устройство браузера, оптимальная организация страницы со скриптами. + li Дерево DOM, особенности разработки в современных браузерах с отмирающей, но иногда нужной поддержкой старых. + li Динамическая генерация интерфейса - методы DOM, их грамотное использование. + li + strong События, взаимодействие с посетителем. + ul + li Основы и тонкости работы с различными событиями для решения основных интерфейсных задач. + li Drag'n'Drop, по окну и внутри элемента + li Паттерн "делегирование", оптимизация производительности и архитектуры, чтобы интерфейсы не тормозили. + li Объектно-ориентированная разработка, компонентная архитектура с использованием ООП, событий и DOM. + + p По окончании второй части вы можете создавать интерфейсные компоненты, но нужно больше практики. + + +e.body._03 + +e('h2').title.phone-only Третья часть курса + ol + li + strong Архитектура и сборка кода. + ul + li Node.JS как средство запуска полезных утилит. + li Организация скриптов, стилей и других компонентов проекта на диске. + li Современные технологии Frontend-сборки. + li Шаблонизация, системы организации шаблонов и детали их работы. + li Архитектура сложных интерфейсов. + li + strong Куда дальше? + ul + li Обзор AJAX-технологий и фреймворков (Angular.JS, React.js), куда двигаться дальше. + + p На практике эти части не так чтобы резко отделены друг от друга, переход между ними плавный. Продвинутые темы используют элементы предыдущих. + + script. + var className = 'tabbed-pane', block = document.querySelector('.' + className); + + block.querySelector('.' + className + '__tabs').addEventListener('click', function(e) { + block.className = className + ' ' + className + '_' + e.target.className.split('_').pop(); + }); + + + diff --git a/handlers/courses/templates/blocks/js/result.jade b/handlers/courses/templates/blocks/js/result.jade new file mode 100644 index 000000000..931180f98 --- /dev/null +++ b/handlers/courses/templates/blocks/js/result.jade @@ -0,0 +1,11 @@ ++b.courses-result.courses-mix + +e('h2').title Результат обучения + + +e.body + ol + li Вы хорошо знаете JavaScript, свободно разрабатываете и отлаживаете программы на этом языке. + li Вы умеете организовать JavaScript-проект, шаблоны и стили в файлах на диске в удобную структуру, собирать и оптимально подключать их к странице. + li Ваши интерфейсы работают стабильно, без глюков, их можно удобно дорабатывать и развивать. + li Мы идём от основ и до довольно-таки сложных штук. Успешное прохождение обучения гарантировано в том случае, если вы будете регулярно заниматься и делать домашнее задание. + + diff --git a/handlers/courses/templates/blocks/js/systemRequirements.jade b/handlers/courses/templates/blocks/js/systemRequirements.jade new file mode 100644 index 000000000..940b38e96 --- /dev/null +++ b/handlers/courses/templates/blocks/js/systemRequirements.jade @@ -0,0 +1,12 @@ ++b.courses-system-req.courses-mix + +e('h2').title Системные требования + + +e.body + p. + Windows или Mac поддерживаются полностью. + + P. + Под Linux доступно участие онлайн, но для просмотра скачиваемых видео-лекций рассмотрите вариант Dual Boot в Win/MacOS. + + p. + Для онлайн-общения желателен интернет от 256kb/s. diff --git a/handlers/courses/templates/blocks/nodejs/how.jade b/handlers/courses/templates/blocks/nodejs/how.jade new file mode 100644 index 000000000..4c3ebf551 --- /dev/null +++ b/handlers/courses/templates/blocks/nodejs/how.jade @@ -0,0 +1,14 @@ ++b.courses-how.courses-mix + +e('h2').title Как проходит обучение? + + +e.body + p Время обучения: месяц, плюс неделя каникул с самостоятельно выполняемым заданием, плюс собрание до начала занятий. + + p На каждой встрече мы изучаем что-то новое, я задаю вопросы, мы обсуждаем, как сделать лучше, и даётся задание. + + p На следующей встрече мы смотрим, что и как у вас получилось, как сделать лучше. Чтобы получить от курса максимум результата, нужно это что-то делать и показывать. Только тогда вы будете действительно понимать, что и как. + + p + strong Резюме – этот курс требует вашего активного участия, просто смотреть и слушать недостаточно, надо делать. + + diff --git a/handlers/courses/templates/blocks/nodejs/programAndSignup.jade b/handlers/courses/templates/blocks/nodejs/programAndSignup.jade new file mode 100644 index 000000000..81596e7aa --- /dev/null +++ b/handlers/courses/templates/blocks/nodejs/programAndSignup.jade @@ -0,0 +1,69 @@ ++b.course-info._program.courses-mix + +e.body.columns.columns_2 + + +e.col.columns__col + +e.content + +e('h3').title Программа + + p Этот курс посвящён профессиональной Node.JS-разработке. + + p В результате курса вы создадите реальный проект и, главное, хорошо разберётесь в Node.JS. + + p Вы будете слушать теорию, писать код, показывать его мне через Dropbox, получать обратную связь и советы, и затем двигаться дальше. + + p Мы будем использовать современные практики разработки, а не те, которые, хоть и широко представлены в интернете, но давно устарели. + + p Вы получите комплексное понимание, как строить архитектуру для Node.JS, какие задачи и как решать. + + p Курс можно условно разделить на три части. + + ol + li + +e.text Node.JS, главные "строительные блоки" разработчика. + li + +e.text Разработка веб-сервисов на современных технологиях и фреймворках. + li + +e.text Архитектура сложных приложений, организация проекта. + + p Детали программы смотрите далее. + + +e.col.columns__col + +e.content + +e('h3').title Набор в группы + + if groups.length + +b.courses-recruitment + +e('a').anchor#signup + +e('ul').list + + each group in groups + +e('li').course + +e.info + +e('h4').title #{formatGroupDate(group.dateStart)} — #{formatGroupDate(group.dateEnd)} + +e('p').text!= group.timeDesc + + +e.apply + +b.price + +e('span') #{group.price} RUB + +e('span').secondary  ≈ #{Math.round(group.price / rateUsdRub)}$ + +e.submit + +b('a')(data-group-signup-link href='/courses/groups/' + group.slug + '/signup' type="button").button._action + +e('span').text Записаться + + p Вы также можете подписаться на уведомления по набору новых групп по этой программе: + + include ../subscribe + + else + +b.courses-recruitment._no-groups + +e('a').anchor#signup + +e('h3').info-title В текущих группах мест нет. + + p Этот курс уникален, места заканчиваются очень быстро, извините. + + p Вы можете запросить уведомления о наборе новых групп по этой программе. + + include ../subscribe + + p Стоимость обучения 13500 руб. Время обучения: 1 месяц (9 занятий, 18 ак. часов) плюс неделя каникул с самостоятельно выполняемым заданием. + diff --git a/handlers/courses/templates/blocks/nodejs/programDetails.jade b/handlers/courses/templates/blocks/nodejs/programDetails.jade new file mode 100644 index 000000000..02b121246 --- /dev/null +++ b/handlers/courses/templates/blocks/nodejs/programDetails.jade @@ -0,0 +1,119 @@ ++b.courses-parts.courses-mix + +e('h2').title Основные темы программы + + +b.tabbed-pane._01 + + +e('ul').tabs + +e('li').tab._01 Первая часть курса + +e('li').tab._02 Вторая часть курса + +e('li').tab._03 Третья часть курса + + +e.body._01 + +e('h2').title.phone-only Первая часть курса + ol + li + strong Фундаментальный Node.JS + p Здесь мы изучим особенности работы Node.JS, его важнейшие модули и приёмы разработки. + ul + li Настройка окружения, редактора, инструменты для разработки и отладки. + li Модули: организация, подключение. + li Встроенные модули http, path, fs, events и другие. + li Все стадии жизни Node.JS-процесса, важные для разработчика. + li Потоки в Node.JS, частые ошибки при работе с ними. + li + strong Сервер на Node.JS + p Создадим код и тесты для веб-сервера на Node.JS, а также разберёмся с асинхронностью. + ul + li Создание чат-сервера на Node.JS, частые ошибки. + li Тестирование с использованием mocha, supertest и других фреймворков. + li + strong Асинхронный код + p Изучим способы работы с асинхронным кодом, делающие его простым и удобным. + ul + li Асинхронность через callback'и, модуль async (old school). + li Promises, Iterators, Generators, их особенности в Node.JS + li Объединение Promises + Generators, "плоский" асинхронный код через библиотеку "co". + + p. + По окончанию первой части курса вы понимаете, как разрабатывать на Node.JS, + как делать сервер и правильно обрабатывать запросы, автоматически тестировать свой код. + + +e.body._02 + +e('h2').title.phone-only Вторая часть курса + ol + li + strong Работа с базой данных + + p Мы будем использовать MongoDB, однако изучаемые принципы применимы к другим базам, в частности MySQL, PostgreSQL, Redis и т.п. + + ul + li База MongoDB, её особенности. + li Объектно-ориентированная работа с MongoDB через Mongoose. + li Полная картина работы с базой: запросы, схемы, валидация, плагины и middleware. + + li + strong Современный веб-сервис + ul + li Фреймворк Koa.JS: основы, роутинг. + li Конфигурация через модуль config. + li Koa.JS: структура middleware, готовые middleware – какие внешние модули для чего использовать. + li CLS и его грамотное использование для получения текущего запроса в любом модуле. + li Правильное логирование и обработка ошибок. + li Улучшенное тестирование, загрузка фикстур. + + li + strong Gulp для запуска задач + ul + li Типы задач gulp, написание своих задач. + li Запуск сервера, загрузка фикстур и тестирование через gulp + + li + strong Авторизация с Passport.JS + ul + li Модель пользователя, регистрация. + li Сессии с Node.JS. + li Passport.JS: сериализация, стратегии, авторизация. + li CSRF-защита от взлома для форм и AJAX. + + li + strong COMET при помощи Socket.IO + ul + li Вебсокеты. + li Обмен сообщениями при помощи Socket.IO. + li Интеграция Socket.IO с Koa.JS, авторизацией и другими сервисами. + + p По окончании этой части мы умеем создавать сервер со страницами, веб-сервисами, авторизацией и чатом. + + p При желании можно дописать к нему новые сервисы и страницы, существующая архитектура позволяет это. + + +e.body._03 + +e('h2').title.phone-only Третья часть курса + ol + li + strong Архитектура проекта + + p В среднем Node.JS-проекте – сотни файлов. Это нормально, и даже вполне удобно, если их правильно организовать. + ul + li Архитектура HMVC для Koa.JS. + li Организация шаблонов, тестов, клиентских скриптов. + li + strong Выкладка Production + ul + li Организация git-репозитария и модулей. + li Запуск с кластеризацией на все ядра процессора через PM2. + li Обзор методов deployment'а. + + p. + После окончания третьей части вы понимаете, + как разрабатывается современный Node.JS-проект и + можете производить такую разработку самостоятельно. + + script. + var className = 'tabbed-pane', block = document.querySelector('.' + className); + + block.querySelector('.' + className + '__tabs').addEventListener('click', function(e) { + block.className = className + ' ' + className + '_' + e.target.className.split('_').pop(); + }); + + + diff --git a/handlers/courses/templates/blocks/nodejs/result.jade b/handlers/courses/templates/blocks/nodejs/result.jade new file mode 100644 index 000000000..f4065ffe0 --- /dev/null +++ b/handlers/courses/templates/blocks/nodejs/result.jade @@ -0,0 +1,11 @@ ++b.courses-result.courses-mix + +e('h2').title Результат обучения + + +e.body + ol + li Вы хорошо разбираетесь в устройстве Node.JS, разрабатываете и отлаживаете программы на нём. + li Вы знаете, как поднять проект малого и среднего размера, чтобы он стабильно работал, не падал, корректно отрабатывал при ошибках. + li Вы можете разработать современный веб-сервис на Node.JS с использованием ES2015 и фреймворков. + li Успешное прохождение обучения гарантировано в том случае, если вы будете регулярно заниматься и делать домашнее задание. + + diff --git a/handlers/courses/templates/blocks/nodejs/systemRequirements.jade b/handlers/courses/templates/blocks/nodejs/systemRequirements.jade new file mode 100644 index 000000000..f3d67aacb --- /dev/null +++ b/handlers/courses/templates/blocks/nodejs/systemRequirements.jade @@ -0,0 +1,12 @@ ++b.courses-system-req.courses-mix + +e('h2').title Системные требования + + +e.body + p. + Windows или Mac поддерживаются полностью. + + P. + Под Linux доступно участие онлайн, но для просмотра записей занятий нужен Dual Boot в Win/MacOS. + + p. + Для онлайн-общения желателен интернет от 256kb/s. diff --git a/handlers/courses/templates/blocks/participants.jade b/handlers/courses/templates/blocks/participants.jade new file mode 100644 index 000000000..2773a0915 --- /dev/null +++ b/handlers/courses/templates/blocks/participants.jade @@ -0,0 +1,56 @@ ++b('form')(data-elem="participants").complex-form._step_1 + +e.step._current + +b.courses-register-participants.courses-register-common + +e('h2').title.courses-register-common__title Места и участники + + +b.course-register-info + +e('p').info._length + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateStart) + |  —  + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateEnd) + +e('p').info!= group.timeDesc + + +b.course-register-settings + +e.number.course-register-settings__cell + +e('h3').title Количество мест + +e.body + +b.number-input + +e('button')(disabled data-elem="participantsDecreaseButton" type="button").btn._dec − + +b.text-input._small.__text + +e('input')(type="number", value="1", min="1", required, max=groupInfo.participantsMax, data-elem="participantsCountInput").control.__input + +e('span').err введите значение от 1 до #{groupInfo.participantsMax} + +e('button')(data-elem="participantsIncreaseButton" type="button").btn._inc + + + +e.is-participant.course-register-settings__cell + +e('h3').title Я являюсь участником + +e.body + +b.switch-input + +e('input').checkbox#request-participant(data-elem="participantsIsSelf" type='checkbox' checked) + +e('i').bg + +e('label').label(for="request-participant") + +e('span').off НЕТ + +e('span').on ДА + + +e.price.course-register-settings__cell + +e('h3').title Стоимость + +e.body + +b.price + +e('span')(data-elem="participantsAmount")= groupInfo.price + +e('span').secondary + | (≈  + span(data-elem="participantsAmountUsd")= Math.round(groupInfo.price / rateUsdRub) + | $) + + +e.add-participants + +b(data-elem="participantsAddBox").course-add-participants + +e('input')(type="checkbox" data-elem="participantsListEnabled" id="add-participants").checkbox + +e('label').add(for="add-participants") Указать участников + +e('p').note (это можно сделать позже) + +e.dropdown + +e('label')(for="add-participants").dropdown-close.close-button + +e('ul')(data-elem="participantsAddList").dropdown-list + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + diff --git a/handlers/courses/templates/blocks/participantsItem.jade b/handlers/courses/templates/blocks/participantsItem.jade new file mode 100644 index 000000000..5fafb3c8c --- /dev/null +++ b/handlers/courses/templates/blocks/participantsItem.jade @@ -0,0 +1,8 @@ +include /bem + ++b('li').course-add-participants-item + +e('label').participant + +e('span').participant-n Участник + +b('span').text-input + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err введите корректный email diff --git a/handlers/courses/templates/blocks/payment.jade b/handlers/courses/templates/blocks/payment.jade new file mode 100644 index 000000000..a680f5ba2 --- /dev/null +++ b/handlers/courses/templates/blocks/payment.jade @@ -0,0 +1,21 @@ + ++b('form')(data-elem="payment").complex-form._step_3 + +e.step._current + +b.course-register-payment.courses-register-common + +e('h2').title.courses-register-common__title Оплата + if orderInfo.status == 'pending' + p Не оплачивайте дважды. Меняйте метод оплаты лишь если уверены, что оплата не произошла. + + +e.body + include ../../../payments/common/templates/payment-methods + + p Регистрируясь на курсы, вы соглашаетесь с договором оферты. + + if orderInfo.status + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Перейти к оплате + + diff --git a/handlers/courses/templates/blocks/receipts.jade b/handlers/courses/templates/blocks/receipts.jade new file mode 100644 index 000000000..79dc83674 --- /dev/null +++ b/handlers/courses/templates/blocks/receipts.jade @@ -0,0 +1,43 @@ ++b.receipts._register + + +e.receipt._step_1 + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title(data-elem="receiptTitle")= receiptTitle + +b.course-register-info + +e('p').info._length + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateStart) + | — + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateEnd) + +e('p').info!= group.timeDesc + + +e.receipt-aside + +e.price + +b('span').price + span(data-elem="receiptAmount")= receiptAmount + |  RUB + if !order + +e('a').edit(href="#" data-elem="receiptParticipantsEditLink") + + +e.receipt._step_2 + +e.receipt-body + +e.receipt-content + +e.type Контактная информация: + +e.title(data-elem="receiptContactName")= receiptContactName + +e.receipt-aside._center + +e('span')(data-elem="receiptContactPhone").title= receiptContactPhone + if !order + +e('a').edit(href="#" data-elem="receiptContactEditLink") + + if ~['paid', 'success', 'pending'].indexOf(orderInfo.status) + +e.receipt._step_3 + +e.receipt-body + +e.receipt-content + +e.type Оплата: + if (orderInfo.status == 'paid' || orderInfo.status == 'success') + +e.status._ok Осуществлена успешно + else if (orderInfo.status == 'pending') + +e.status._ok Ожидается подтверждение + +e.receipt-aside + +e(class=["pay-method", orderInfo.transaction ? ("_" + paymentMethods[orderInfo.transaction.paymentMethod].name) : '']) diff --git a/handlers/courses/templates/blocks/result.jade b/handlers/courses/templates/blocks/result.jade new file mode 100644 index 000000000..6af599fb7 --- /dev/null +++ b/handlers/courses/templates/blocks/result.jade @@ -0,0 +1,37 @@ + + ++b('form').complex-form._step_4 + +e.step._current + + if orderInfo.status == 'success' + + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title Спасибо за заказ! + +e('h3').title.courses-register-common__title В ближайшее время вам придёт уведомление на адрес #{order.email} + + +e.body + if hasInvite + p + | Перейдите в раздел Курсы вашей учетной записи, + | чтобы присоединиться к группе. + + if hasOtherParticipants + p + | Отредактировать данные других участников можно + | в разделе Заказы учетной записи. Им также придёт приглашение. + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + else + + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title!= orderInfo.title + if orderInfo.accent + +e('h3').title.courses-register-common__title!= orderInfo.accent + if orderInfo.description + +e.body + != orderInfo.description + if orderInfo.linkToProfile + != orderInfo.linkToProfile + + diff --git a/handlers/courses/templates/blocks/subscribe.jade b/handlers/courses/templates/blocks/subscribe.jade new file mode 100644 index 000000000..a525cabf6 --- /dev/null +++ b/handlers/courses/templates/blocks/subscribe.jade @@ -0,0 +1,13 @@ ++b("form").text-input-button(data-newsletter-subscribe-form=course.slug onsubmit="return false" action="/newsletter/subscribe" method="POST") + input(type="hidden" value=course.slug name="slug") + if user + input(type="hidden" value=user.email name="email") + else + +e.input + +b.text-input + +e('input').control(type="email", placeholder="me@mail.com", name="email", required) + +e.button + +b("button")(class=["button", groups.length ? "_common" : "_action"] type="submit") + +e("span").text Подписаться + +p(style="font-size: 13px; color: #999") На ваш email придёт письмо с информацией о дате и деталях программы. diff --git a/handlers/courses/templates/courses/js.jade b/handlers/courses/templates/courses/js.jade new file mode 100644 index 000000000..9d34a89bc --- /dev/null +++ b/handlers/courses/templates/courses/js.jade @@ -0,0 +1,31 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit-wide" + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var breadcrumbs = [ { title: 'Учебник', url: '/' }, { title: 'Курсы', url: '/courses' }] + - var content_class = '_center' + - var comments = true + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("coursesCourse", {defer: true}) + +block content + p В первую очередь этот курс для тех, кто либо не разрабатывал на JS, либо разрабатывал на нём эпизодически и теперь хочет освоить профессионально. + + include ../blocks/js/programAndSignup + + input(type="checkbox" id="phone-toggler").phone-toggler__input.phone-only + +b('label').phone-toggler.phone-only(for="phone-toggler") + | Более подробная информация о программе курса + + include ../blocks/js/programDetails + include ../blocks/js/how + include ../blocks/js/result + include ../blocks/js/systemRequirements + + +b.fixed-tab.phone-only.courses-tab + a.courses-tab__link(href="#signup") Записаться на курсы + diff --git a/handlers/courses/templates/courses/nodejs.jade b/handlers/courses/templates/courses/nodejs.jade new file mode 100644 index 000000000..be1852a37 --- /dev/null +++ b/handlers/courses/templates/courses/nodejs.jade @@ -0,0 +1,32 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit-wide" + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var breadcrumbs = [ { title: 'Учебник', url: '/' }, { title: 'Курсы', url: '/courses' }] + - var content_class = '_center' + - var siteToolbarCurrentSection = "courses" + - var comments = true + +block append head + !=js("coursesCourse", {defer: true}) + +block content + + p Курс для тех, кто ранее не разрабатывал на Node.JS или хочет получить хороший level-up в этой технологии. + + include ../blocks/nodejs/programAndSignup + + input(type="checkbox" id="phone-toggler").phone-toggler__input.phone-only + +b('label').phone-toggler.phone-only(for="phone-toggler") + | Более подробная информация о программе курса + + include ../blocks/nodejs/programDetails + include ../blocks/nodejs/how + include ../blocks/nodejs/result + include ../blocks/nodejs/systemRequirements + + +b.fixed-tab.phone-only.courses-tab + a.courses-tab__link(href="#signup") Записаться на курсы + diff --git a/handlers/courses/templates/email/invite.jade b/handlers/courses/templates/email/invite.jade new file mode 100644 index 000000000..e17df31e2 --- /dev/null +++ b/handlers/courses/templates/email/invite.jade @@ -0,0 +1,16 @@ +extends /layouts/email + +block body + + h2 Приглашение на курс + + p На сайте javascript.ru была оформлена запись для вас на курс #{group.title}. + + p Перейдите по ссылке #{link}, чтобы присоединиться к группе. + + if userExists + p При этом вы автоматически будете залогинены на сайте. + + p Контактное лицо, указанное в записи: #{contactName}. + + p Если возникнут какие-либо вопросы – вы всегда можете ответить на это письмо. diff --git a/handlers/courses/templates/email/inviteRemind.jade b/handlers/courses/templates/email/inviteRemind.jade new file mode 100644 index 000000000..8460cec8e --- /dev/null +++ b/handlers/courses/templates/email/inviteRemind.jade @@ -0,0 +1,15 @@ +extends /layouts/email + +block body + + h2 Присоединитесь, пожалуйста, к группе + + p Здравствуйте! + + p Вы – в списке участников, но до сих пор не присоединились к группе #{group.title}. + + p Это нужно сделать, чтобы вы могли участвовать и получать материалы группы. + + p Присоединиться к группе можно по ссылке #{link}. + + p Если возникнут какие-либо вопросы – вы можете ответить на это письмо. diff --git a/handlers/courses/templates/email/materials.jade b/handlers/courses/templates/email/materials.jade new file mode 100644 index 000000000..0a64a6542 --- /dev/null +++ b/handlers/courses/templates/email/materials.jade @@ -0,0 +1,13 @@ +extends /layouts/email + +block body + + h2 Уведомление о материалах курса + + p На страницу #{link} добавлены материалы. + + p Вы можете скачать файл по прямой ссылке (если залогинены на сайте): #{fileTitle}. + + if comment + p= comment + diff --git a/handlers/courses/templates/email/orderCancel.jade b/handlers/courses/templates/email/orderCancel.jade new file mode 100644 index 000000000..0c2f15e23 --- /dev/null +++ b/handlers/courses/templates/email/orderCancel.jade @@ -0,0 +1,23 @@ +extends /layouts/email + +block body + + h2 Ваш заказ #{order.number} аннулирован по истечению времени + + p. + Ваш заказ на Javascript.ru под номером #{order.number} автоматически аннулирован + по истечению времени ожидания, ввиду отсутствия информации о платеже. + + if orderSuccessSameGroupAndUser + p. + У вас есть другой, оплаченный, заказ под номером #{orderSuccessSameGroupAndUser.number} в ту же группу, вероятно, аннулирован лишний, дублирующий, заказ. + + p + | Список активных заказов доступен в личном кабинете:  + a(href=profileOrdersLink)= profileOrdersLink + |  (нужно авторизоваться на сайте). + + p Если вы оплачивали этот заказ или собираетесь это сделать – ответьте на это письмо. + + p Автоматическая отмена неоплаченных заказов предназначена для удаления несостоявшихся заказов. + diff --git a/handlers/courses/templates/email/paymentConfirmation.jade b/handlers/courses/templates/email/paymentConfirmation.jade new file mode 100644 index 000000000..bca3da5fb --- /dev/null +++ b/handlers/courses/templates/email/paymentConfirmation.jade @@ -0,0 +1,26 @@ +extends /layouts/email + +block body + + h2 Подтверждение оплаты + + p Подтверждаем получение оплаты за заказ #{orderNumber}. + + if !orderHasParticipants + p Вы не указали участников. Это можно сделать в разделе учётной записи Заказы, в деталях заказа. + else + if orderUserIsParticipant + p Перейдите по ссылке #{orderUserInviteLink}, чтобы присоединиться к группе. + + if orderHasOtherParticipants + + p. + Приглашённые вами участники также получат письмо на электронную почту с предложением присоединиться. + Письмо придёт с адреса orders@javascript.ru. + + else + p. + Приглашённые вами участники получат письмо на электронную почту с предложением присоединиться к группе. + Письмо придёт с адреса orders@javascript.ru. + + p Если возникнут какие-либо вопросы – вы всегда можете ответить на это письмо. diff --git a/handlers/courses/templates/feedback/edit.jade b/handlers/courses/templates/feedback/edit.jade new file mode 100644 index 000000000..1d3e92170 --- /dev/null +++ b/handlers/courses/templates/feedback/edit.jade @@ -0,0 +1,134 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + - var sitetoolbar = true + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("coursesFeedback", {defer: true}) + +//- + Поле показывается отдельно, если у него не стоит _editable ИЛИ нажата кнопка Редактировать + +block content + +b.course-feedback._form + +e('form')(data-feedback-form method="POST").form + input(type="hidden", name="_csrf", value=csrf()) + +e.line + +e('h2').title Как вы в целом оцениваете курс?* + +b.rating-chooser.clearfix + +e('fieldset').fieldset + +e('input').input(type="radio" id="star5" name="stars" value="5" hidden checked=(form.stars == 5)) + +e('label').label(for="star5" title="Отлично") + +e('span').label-text Отлично + +e('input').input(type="radio" id="star4" name="stars" value="4" hidden checked=(form.stars == 4)) + +e('label').label(for="star4" title="Хорошо") + +e('span').label-text Хорошо + +e('input').input(type="radio" id="star3" name="stars" value="3" hidden checked=(form.stars == 3)) + +e('label').label(for="star3" title="Нормально") + +e('span').label-text Нормально + +e('input').input(type="radio" id="star2" name="stars" value="2" hidden checked=(form.stars == 2)) + +e('label').label(for="star2" title="Так себе") + +e('span').label-text Так себе + +e('input').input(type="radio" id="star1" name="stars" value="1" hidden checked=(form.stars == 1)) + +e('label').label(for="star1" title="Плохо") + +e('span').label-text Плохо + + +e.line + +e('h2').title Порекомендовали бы вы этот курс другим?* + +e('label').label + +e('input').input(type="radio" name="recommend" value="1" checked=(form.recommend === true)) + |  Да + br + +e('label').label + +e('input').input(type="radio" name="recommend" value="0" checked=(form.recommend === false)) + |  Нет + + +e.line + +e('h2').title Отзыв* + +b('textarea').textarea-input.__textarea-head( + name="content" + placeholder="Несколько слов о том, насколько полезным курс оказался для вас, доступно ли излагается материал, устраивает ли квалификация ведущего и т.д." + required + )= form.content + + +e.line + +e('h2').title + +e('input').checkbox(type="checkbox" name="isPublic" value="1" checked=form.isPublic) Публичный отзыв  + +e('span').title-note (будет опубликован на javascript.ru) + + +e('input').edit-input(type="checkbox" id="edit-input" hidden) + + +e.line._defined + +e.user + +e.userpic + +e('img').userpic-img(src=thumb(form.photo || user.getPhotoUrl(), 86, 86)) + + +e('span').username + +e('a').username-link(href=user.getProfileUrl())= participant.fullName + + +e('label').edit(for="edit-input") Редактировать + + if form.aboutLink + +e('span').homepage + +e('a').homepage-link(href=form.aboutLink)= form.aboutLink.replace(/^https?:\/\//, '') + + +e('span').country + +e('img').country-flag(src='/img/flags/#{form.country}.svg' width=16 height=12) + +e('span').country-text + = countries[form.country].na + if form.city + | ,  + = form.city + + if form.occupation + +e('span').occupation= form.occupation + + + +e.line._editable + +e('h2').title Имя + +b.text-input + +e('input').control(value=participant.fullName disabled) + + +e(class=["line", (form.photo || user.photo) ? "_editable": ""]) + +e('h2').title Фото + +b.upload-userpic(data-photo-load) + input(type="hidden" name="photoId") + +e('i').img(style="background-image: url('#{thumb(form.photo || user.getPhotoUrl(), 64, 64)}')") + +e('a').new(href="#") Загрузить новое фото + + + +e.line._editable + +e('h2').title Страна + +b('select').input-select._small(name="country") + each country in countries + option(value=country.co selected=(country.co == form.country))= country.na + + +e(class=["line", form.city ? "_editable" : ""]) + +e('h2').title Город + +b.text-input + +e('input')(name="city" value=form.city).control + + +e(class=["line", form.occupation ? "_editable" : ""]) + +e('h2').title Область работы + +b.text-input + +e('input')(name="occupation" value=form.occupation).control + + +e(class=["line", form.aboutLink ? "_editable" : ""]) + +e('h2').title Профиль в соц. сети или личная страница, где можно узнать о вашей профессиональной деятельности + +b.text-input + +e('input').control( + type="url" + name="aboutLink" + pattern=validate.patterns.webpageUrl + value=form.aboutLink + placeholder="http://linkedin.com" + ) + + +e.note Эта ссылка будет доступна только в контексте вашего отзыва. Пожалуйста, укажите её. + + +e.line + +b('button').button._action(type="submit") Отправить diff --git a/handlers/courses/templates/feedback/show.jade b/handlers/courses/templates/feedback/show.jade new file mode 100644 index 000000000..702d34a8b --- /dev/null +++ b/handlers/courses/templates/feedback/show.jade @@ -0,0 +1,52 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var breadcrumbs = [{ title: 'Учебник', url: '/' }, { title: 'Курсы', url: '/courses' }] + - var content_class = '_center' + - var siteToolbarCurrentSection = "courses" + - var sitetoolbar = true + +block content + + +b.course-feedback._result + +e.user + +e.userpic + +e('img').userpic-img(src=thumb(courseFeedback.photo || participantUser.getPhotoUrl(), 86, 86)) + + +e('span').username + +e('a').username-link(href=participantUser.getProfileUrl())= courseFeedback.participant.fullName + +e('span').country + +e('img').country-flag(src='/img/flags/#{courseFeedback.country}.svg' width=16 height=12) + +e('span').country-text + = countries[courseFeedback.country].na + if courseFeedback.city + | ,  + = courseFeedback.city + + +e('span').date= moment(courseFeedback.created).format('D MMM YYYY') + + if courseFeedback.aboutLink + +e('span').homepage + +e('a').homepage-link(href=courseFeedback.aboutLink, target="_blank")= courseFeedback.aboutLink + + +b(class=["rating", "_" + courseFeedback.stars]) + for raiting in [1,2,3,4,5] + +e('i').star ★ + + if courseFeedback.recommend + +e.name Рекомендует #{group.course.title.replace(/./, function(match) { return match.toLowerCase(); })} + + +e.body!= renderSimpledown(courseFeedback.content, {trusted: false}) + + if editLink + +e('a').edit(href=editLink) редактировать + +e.share + +b.share-icons + +e('span').title Поделиться + include /blocks/social-icons + + + diff --git a/handlers/courses/templates/frontpage.jade b/handlers/courses/templates/frontpage.jade new file mode 100644 index 000000000..da5c2339b --- /dev/null +++ b/handlers/courses/templates/frontpage.jade @@ -0,0 +1,32 @@ +extends /layouts/main + +block append head + !=js("coursesFrontpage", {defer: true}) + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Курсы Javascript' + - var sitetoolbar = true + - var content_class = '_center' + - var comments = true + - var layout_main_class = "main_width-limit-wide" + - var siteToolbarCurrentSection = "courses"; + +block content + p Здесь находятся «правильные» курсы по профессиональному Javascript, цель которых — научить думать на Javascript, писать просто, быстро и красиво. + + +b.flex-column + include blocks/frontpage/phone-toggler + include blocks/frontpage/features + include blocks/frontpage/coursesList + include blocks/frontpage/master + //- include blocks/frontpage/testimonials + include blocks/frontpage/tabbedPane + include blocks/frontpage/guarantee + include blocks/frontpage/participants-logos + include blocks/frontpage/professionals + include blocks/frontpage/faq + +b.fixed-tab.phone-only.courses-tab + a.courses-tab__link(href="#courses") Перейти к списку открытых курсов + diff --git a/handlers/courses/templates/groupInfo/js.jade b/handlers/courses/templates/groupInfo/js.jade new file mode 100644 index 000000000..3d39b1e2d --- /dev/null +++ b/handlers/courses/templates/groupInfo/js.jade @@ -0,0 +1,82 @@ +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Онлайн-курс: настройка окружения'; + - var sitetoolbar = true + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("courses", {defer: true}) + + +block content + + p. + Эта инструкция – о том, как настроить у себя окружение для обучения. + Прочитайте, пожалуйста, ее полностью. Настройте всё и, желательно, протестируйте на собрании. + Это важно, чтобы вы могли сразу же полноценно принимать участие в процессе. + + p Для общения используется одновременно видео, аудио и чат. + + + h2 Общение в чате + + p Для чата мы используем Jabber. Это удобный чат-клиент с открытым протоколом и большим количеством свободно распространяемого программного обеспечения. + + p Если у вас он ещё не стоит Jabber-клиент, нужно будет поставить. Самые популярные клиенты: + + ul + li Для Windows и Linux: Pidgin. + li Для MacOS: Adium. + + p Для входа на сервер вам понадобятся: + + ul + li Логин: #{user.profileName} и сервер javascript.ru (в некоторых клиентах вводятся вместе #{user.profileName}@javascript.ru). + li + | Пароль: тот, которые вы используете для входа в сайт learn.javascript.ru. + | Если вы ранее входили только через Facebook/Github/..., то пароля может не быть, + | тогда создайте его во вкладке профиля Аккаунт. + + p Подключайтесь к чату #{group.webinarId}, конференц-сервер conference.javascript.ru, в качестве своего ника(псевдонима) для комнаты укажите свое имя и фамилию в формате "Имя Фамилия". + + p Есть короткое видео по установке и настройке Pidgin и Adium. + + h2 Система для разделения экрана и общения + + p Мы используем систему GoToWebinar, в ней специальный кодек, который обеспечивает лучшее качество скринкастов, чем более распространённый flv-формат. + + p. + Для использования этой системы в браузере должна быть установлена и включена java. + Если у вас ее нет – скачать можно здесь: + http://java.com/ru/download/. + + p Для захода в систему зайдите на http://joingotowebinar.com и введите: + + ul + li Webinar ID: #{group.webinarId}. + li Email: #{user.profileName}@javascript.ru (именно это, не ваш реальный email). + + p Для запуска системы отвечайте Yes на вопросы. Если ничего не запускается – возможно, нужно нажать кнопку Launch или Download Software и запустить скачанную программу вручную. + + p Когда программа поставится, при следующем заходе скачивать или запускать ее заново будет не нужно. + + :simpledown + [warn] + До собрания попробуйте войти, как указано выше, чтобы вас "зарегистрировало", но работать видео будет только во время онлайн-встречи. + + При попытке зайти во "внеурочное" время видео не запустится. Это нормально. + [/warn] + + p + | Заранее, для проверки, можно подключиться по адресу https://www3.gotomeeting.com/join/406552062, + | людей там нет, но программа должна поставиться и запуститься. + + p Полная инструкция по тестированию входа находится на http://support.citrixonline.com/en_US/GoToMeeting/help_files/GTM140010?title=Test+Your+GoToMeeting+Connection. + + p Практика показывает, что если у вас стоит Java и работает Skype, то и видео тоже нормально заработает. + + include:simpledown ./js.md diff --git a/handlers/courses/templates/groupInfo/js.md b/handlers/courses/templates/groupInfo/js.md new file mode 100644 index 000000000..939bd55d2 --- /dev/null +++ b/handlers/courses/templates/groupInfo/js.md @@ -0,0 +1,130 @@ + +## Общение голосом + +Для того, чтобы задавать вопросы голосом, используется та же система, что и для видео. Понадобится микрофон. + +Заметим, что микрофон не обязателен для участия, так как задавать вопросы можно и в чате. + +### Как задавать вопросы? + +Если вдруг вам что-то непонятно в материале или решении задачи -- обязательно говорите об этом, задавайте вопросы. + +Бывает и так, что вроде бы не всё понятно, но что конкретно -- сформулировать сложно. +В этом случае отличным выходом является вопрос "с этого момента поподробнее". Главное - спрашивайте, участвуйте в занятии. + +Ответы на ваши вопросы могут содержать дополнительные интересные сведения, которые помогут не только вам, но и другим участникам. +Поэтому задавайте, все вам скажут только спасибо. + +Задавать вопросы можно двумя способами. + +
    +
  1. Первый -- написать в общем чате. Настройки чата описаны выше.
  2. +
  3. Второй -- спросить голосом. +Для этого нужно нажать на кнопку "ладонь со стрелкой вверх" ("Raise hand"), которая находится на мини-пульте управления системой разделения экрана. +Обычно он справа-сверху. В этом случае, когда ведущий увидит вашу руку -- он передаст вам "микрофон". + +При получении микрофона значок микрофона на мини-пульте изменится и раздастся голосовое оповещение на английском "unmuted", и вас будет слышно.
  4. +
+ +Бывает, что поднятая рука заметна ведущему не сразу, тогда можно написать об этом в чате -- "вопрос голосом". + +## Решение задач + +Для обмена решениями задач используется онлайн-песочница. Для учебника взят Plunker, + но вы можете использовать и jsbin и CodePen и любую другую. + +Все решения просьба подписывать сверху своим именем, можно комментарий под <html>: + +```html + + +*!* + +*/!* +... +``` + +...И, конечно, решения нужно не только делать, но и показывать их. Но показать не все, а только те, которые отличаются от приведённых в учебнике. + +Рекомендуемый алгоритм действий при решении задачи: + +
+
Если вы решили задачу сами...
+
В этом случае нужно посмотреть решение из учебника -- вдруг там подводные камни где-то, и просто чтобы увидеть альтернативный вариант. + +Если ваше решение чем-то отличается от данного в учебнике -- покажите его на занятии. +
+
Если вы не решили, но разобрались в решении...
+
Включать решение из учебника в домашнюю работу не надо, оно не ваше.
+
Если вы не решили и не понятны какие-то моменты в решении. +
Обязательно спросите на занятии! Любые ваши вопросы определённо стоят того, чтобы их обсудить на занятии.
+
+ +## Дополнительно + +Вам также может понадобиться просмотр PDF. Как правило, для этого используют Acrobat Reader. Скачать можно, например, здесь (выберите OS, язык и уберите галочку Free McAfee). + +Ну и, конечно же, нужны будут браузеры, которые вы собираетесь поддерживать. Обычно это Chrome, Internet Explorer и Firefox. Настройте свое рабочее место. Поставьте редакторы -- я использую Webstorm и Sublime, +но есть и много других, выбор целиком ваш. + +Обязательно выставьте точное время на часах (свериться можно с [google](https://www.google.ru/search?q=время) или любым другим тайм-сервером). Это нужно для координации времени на перерывы и решение задач. + +Все эти приготовления и система задуманы так, чтобы сделать процесс обучения максимально комфортным и эффективным. + +## Возможные проблемы и их решения + + +### Если чат не работает + +
    +
  1. Во-первых, проверьте, что вы вводите в качестве логина именно то, что указано выше. Некоторые клиенты требуют указать логин вместе с сервером: `логин@javascript.ru`.
  2. +
  3. Во-вторых, проверьте пароль -- это должен быть пароль для входа на сайт `learn.javascript.ru`.
  4. +
  5. Это бывает весьма редко, но некоторые провайдеры имеют сложности с правильным разрешением особых ДНС-записей для Jabber. +В результате аккаунт не может подключиться. +Попробуйте поставить DNS-сервер `8.8.8.8` (это открытый сервер от Google), если заработает, значит дело в этом. +Под Windows это делается в настройках сетевого соединения (Свойства соединения, пункт TCP, DNS).
  6. +
  7. Если и это не помогло -- проверьте, открыт ли порт 5222, это можно сделать командой `telnet jabber.javascript.ru 5222` или другой аналогичной. Иногда администраторы в офисах его закрывают.
  8. +
+ +Если всё ещё не работает -- напишите мне на mk@javascript.ru, постараюсь помочь. + +**Чат должен работать в любое время, проверьте его заранее.** + + +### Если не работает видео + +Видео, в отличие от чата, работает только во время занятий. Совершенно нормально, что до занятий вас "не пускает" в вебинар. + +Как правило, оно стартует в течение 1-2 минут после захода ведущего в чат. Ведущий пишет о том, что видео запущено, в чате. + +
    +
  1. Если вход не удаётся -- проверьте, правильные ли данные вы вводите, в точности ли то, что указано выше?
  2. +
  3. Если вебинар пишет "not approved" -- возможно, вы не пробовали войти до собрания, и ведущий не имел возможности подтвердить вашу регистрацию, попросите его об этом в чате.
  4. +
  5. Если всё ещё не работает -- посмотрите системные требования. Операционная система: Windows или MacOS. Нужна Java.
  6. +
  7. Бывает так, что автоматически система не стартует, на этот случай при входе есть предложение скачать (download) программу и запустить её вручную.
  8. +
  9. Если никак не запускается -- убейте процессы с названиями вида `g2*` (то есть, начинающиеся с `g2`) или перезагрузитесь и попробуйте ещё раз. Это сценарий бывает редко.
  10. +
+ +Если всё ещё не работает -- во время онлайн-собрания можно задавать вопросы по Skype, ник: `javascript.ru`. + + +### Форс-мажор: если нет ведущего + +Если вдруг случится что-то непредвиденное (на линии электропередач упало дерево, интернет-провода погрыз ополоумевший барсук, ведущего переехал самосвал) -- занятия всё равно будут, +но, возможно, с опозданием или переносом. + +Подобное бывает очень редко. + +Обычно занятия начинаются по расписанию. Максимально возможное опоздание ведущего -- 15 минут. +Если его нет дольше и нет информации, значит произошло что-то серьезное. + +Можно попытаться узнать, что именно, позвонив по телефону +7(903)541-94-41. Не стесняйтесь -- звоните. +В качестве финального порога отмены занятия устанавливается задержка на 30 минут. + +Разъяснения и соответствующее обновление расписания в этом случае будут в ближайшее возможное время. + +## Поддержка + +Если что-то из этой инструкции непонятно -- задавайте вопросы на mk@javascript.ru, я на них отвечу. + +Во время собрания также доступен Skype: `javascript.ru`. diff --git a/handlers/courses/templates/groupInfo/nodejs.jade b/handlers/courses/templates/groupInfo/nodejs.jade new file mode 100644 index 000000000..f2881cff2 --- /dev/null +++ b/handlers/courses/templates/groupInfo/nodejs.jade @@ -0,0 +1,125 @@ +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Курс по Node.JS: настройка окружения'; + - var sitetoolbar = true + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("courses", {defer: true}) + + +block content + + p. + Эта инструкция – о том, как настроить у себя окружение для обучения. + Прочитайте, пожалуйста, ее полностью. Настройте всё и, желательно, протестируйте на собрании. + Это важно, чтобы вы могли сразу же полноценно принимать участие в процессе. + + p Для общения используется одновременно видео, аудио и чат. + + h2 Общение в чате + + p Для чата мы используем Jabber. Это удобный чат-клиент с открытым протоколом и большим количеством свободно распространяемого программного обеспечения. + + p Если у вас он ещё не стоит Jabber-клиент, нужно будет поставить. Самые популярные клиенты: + + ul + li Для Windows и Linux: Pidgin. + li Для MacOS: Adium. + + p Для входа на сервер вам понадобятся: + + ul + li Логин: #{user.profileName} и сервер javascript.ru (в некоторых клиентах вводятся вместе #{user.profileName}@javascript.ru). + li + | Пароль: тот, которые вы используете для входа в сайт learn.javascript.ru. + | Если вы ранее входили только через Facebook/Github/..., то пароля может не быть, + | тогда создайте его во вкладке профиля Аккаунт. + + p Подключайтесь к чату #{group.webinarId}, конференц-сервер conference.javascript.ru, в качестве своего ника(псевдонима) для комнаты укажите свое имя и фамилию в формате "Имя Фамилия". + + p Есть короткое видео по установке и настройке Pidgin и Adium. + + h2 Система для разделения экрана и общения + + p Мы используем систему GoToWebinar, в ней специальный кодек, который обеспечивает лучшее качество скринкастов, чем более распространённый flv-формат. + + p. + Для использования этой системы в браузере должна быть установлена и включена java. + Если у вас ее нет – скачать можно здесь: + http://java.com/ru/download/. + + p Для захода в систему зайдите на http://joingotowebinar.com и введите: + + ul + li Webinar ID: #{group.webinarId}. + li Email: #{user.profileName}@javascript.ru (именно это, не ваш реальный email). + + p Для запуска системы отвечайте Yes на вопросы. Если ничего не запускается – возможно, нужно нажать кнопку Launch или Download Software и запустить скачанную программу вручную. + + p Когда программа поставится, при следующем заходе скачивать или запускать ее заново будет не нужно. + + :simpledown + [warn] + До собрания попробуйте войти, как указано выше, чтобы вас "зарегистрировало", но работать видео будет только во время онлайн-встречи. + + При попытке зайти во "внеурочное" время видео не запустится. Это нормально. + [/warn] + + p + | Заранее, для проверки, можно подключиться по адресу https://www3.gotomeeting.com/join/406552062, + | людей там нет, но программа должна поставиться и запуститься. + + p Полная инструкция по тестированию входа находится на http://support.citrixonline.com/en_US/GoToMeeting/help_files/GTM140010?title=Test+Your+GoToMeeting+Connection. + + p Практика показывает, что если у вас стоит Java и работает Skype, то и видео тоже нормально заработает. + + p. + Для того, чтобы задавать вопросы голосом, используется та же система, что и для видео. Понадобится микрофон. + Заметим, что микрофон не обязателен для участия, так как задавать вопросы можно и в чате. + + + h2 Dropbox + + p Для обмена файлами и совместной работы с кодом мы будем использовать Dropbox. + + p. + Это – сервис, который синхронизирует содержимое директории между различными компьютерами. + После его установки и настройки вы сможете видеть мои файлы, а я – ваши. Конечно, в пределах указанного каталога. + + ol + li Зарегистрируйтесь в Dropbox с вашим Email: #{user.email}, поставьте программу и запустите. + li Создайте у себя в Dropbox каталог с названием из вашего имени и фамилии латинницей через точку, например ivan.ivanov. + li Расшарьте этот каталог (иконка с радугой) с аккаунтом #{group.slug}@javascript.ru. + li Теперь я и вы имеем доступ к этому каталогу. В нём можно выкладывать решения домашних задач. + + p Настройте, пожалуйста, Dropbox заранее, мне тоже нужно будет пригласить вас в свой каталог до начала занятий, а вам – принять это приглашение. + + :simpledown + [warn] + Ни в коем случае не ставьте модули для Node.JS в каталог Dropbox. Ставьте их в `node_modules` на уровень выше. + + Иначе они будут забивать канал синхронизации и приводить к ошибкам выполнения, так как модули расшаривать нельзя. + [/warn] + + + + h2 IO.JS, Git, Mongo + + + ul + li. + IO.JS поставьте после просмотра первых выпусков скринкаста, проверьте работу на скрипте с console.log("Hello world"). + li. + Git – если у вас не стоит, то поставьте, причём если у вас Windows и раньше с Git не работали, то во избежание "подводных камней" выбрать при установке опции, как указано на скриншотах: + скриншот 1 и скриншот 2. + li. + MongoDB – работать с базой данных мы начнём не сразу, но достаточно скоро, пусть она стоит у вас заранее. + Под *nix-системы и MacOS используйте систему пакетов, для Windows есть выпуск скринкаста. + Желательно поставить более старую версию 2.x, в ней пока меньше глюков, чем в 3.x. + + include:simpledown ./nodejs.md diff --git a/handlers/courses/templates/groupInfo/nodejs.md b/handlers/courses/templates/groupInfo/nodejs.md new file mode 100644 index 000000000..11e3f9de0 --- /dev/null +++ b/handlers/courses/templates/groupInfo/nodejs.md @@ -0,0 +1,81 @@ + +## Как задавать вопросы? + +Если вдруг вам что-то непонятно в материале или решении задачи -- обязательно говорите об этом, задавайте вопросы. + +Бывает и так, что вроде бы не всё понятно, но что конкретно -- сформулировать сложно. +В этом случае отличным выходом является вопрос "с этого момента поподробнее". Главное - спрашивайте, участвуйте в занятии. + +Ответы на ваши вопросы могут содержать дополнительные интересные сведения, которые помогут не только вам, но и другим участникам. +Поэтому задавайте, все вам скажут только спасибо. + +Задавать вопросы можно двумя способами. + +
    +
  1. Первый -- написать в общем чате. Настройки чата описаны выше.
  2. +
  3. Второй -- спросить голосом. +Для этого нужно нажать на кнопку "ладонь со стрелкой вверх" ("Raise hand"), которая находится на мини-пульте управления системой разделения экрана. +Обычно он справа-сверху. В этом случае, когда ведущий увидит вашу руку -- он передаст вам "микрофон". + +При получении микрофона значок микрофона на мини-пульте изменится и раздастся голосовое оповещение на английском "unmuted", и вас будет слышно.
  4. +
+ +Бывает, что поднятая рука заметна ведущему не сразу, тогда можно написать об этом в чате -- "вопрос голосом". + +## Возможные проблемы и их решения + + +### Если чат не работает + +
    +
  1. Во-первых, проверьте, что вы вводите в качестве логина именно то, что указано выше. Некоторые клиенты требуют указать логин вместе с сервером: `логин@javascript.ru`.
  2. +
  3. Во-вторых, проверьте пароль -- это должен быть пароль для входа на сайт `learn.javascript.ru`.
  4. +
  5. Некоторые провайдеры имеют сложности с правильным разрешением особых ДНС-записей для Jabber. +В результате аккаунт не может подключиться. +Попробуйте поставить DNS-сервер `8.8.8.8` (это открытый сервер от Google), если заработает, значит дело в этом. +Под Windows это делается в настройках сетевого соединения (Свойства соединения, пункт TCP, DNS).
  6. +
  7. Если и это не помогло -- проверьте, открыт ли порт 5222, это можно сделать командой `telnet jabber.javascript.ru 5222` или другой аналогичной. Иногда администраторы в офисах его закрывают.
  8. +
+ +Если всё ещё не работает -- напишите мне на mk@javascript.ru, постараюсь помочь. + +**Чат должен работать в любое время, проверьте его заранее!** + + +### Если не работает видео + +Видео, в отличие от чата, работает только во время занятий. Совершенно нормально, что до занятий вас "не пускает" в вебинар. + +Как правило, оно стартует в течение 1-2 минут после захода ведущего в чат. Ведущий пишет о том, что видео запущено, в чате. + +
    +
  1. Если вход не удаётся -- проверьте, правильные ли данные вы вводите, в точности ли то, что указано выше?
  2. +
  3. Если вебинар пишет "not approved" -- возможно, вы подключились не заранее, а во время занятия, и ведущий не имел возможности подтвердить вашу регистрацию, попросите его об этом в чате.
  4. +
  5. Если всё ещё не работает -- посмотрите системные требования. Операционная система: Windows или MacOS. Нужна Java.
  6. +
  7. Бывает так, что автоматически система не стартует, на этот случай при входе есть предложение скачать (download) программу и запустить её вручную.
  8. +
  9. Если никак не запускается -- убейте процессы с названиями вида `g2*` (то есть, начинающиеся с `g2`) или перезагрузитесь и попробуйте ещё раз. Это сценарий бывает редко.
  10. +
+ +Если всё ещё не работает -- во время онлайн-собрания можно задавать вопросы по Skype, ник: `javascript.ru`. + + +### Форс-мажор: если нет ведущего + +Если вдруг случится что-то непредвиденное (на линии электропередач упало дерево, интернет-провода погрыз ополоумевший барсук, ведущего переехал самосвал) -- занятия всё равно будут, +но, возможно, с опозданием или переносом. + +Подобное бывает очень редко. + +Обычно занятия начинаются по расписанию. Максимально возможное опоздание ведущего -- 15 минут. +Если его нет дольше и нет информации, значит произошло что-то серьезное. + +Можно попытаться узнать, что именно, позвонив по телефону +7(903)541-94-41. Не стесняйтесь -- звоните. +В качестве финального порога отмены занятия устанавливается задержка на 30 минут. + +Разъяснения и соответствующее обновление расписания в этом случае будут в ближайшее возможное время. + +## Поддержка + +Если что-то из этой инструкции непонятно – задавайте вопросы на mk@javascript.ru, я на них отвечу. + +Перед первым занятием проводится собрание, во время него также доступен Skype: `javascript.ru`. diff --git a/handlers/courses/templates/groupMaterials.jade b/handlers/courses/templates/groupMaterials.jade new file mode 100644 index 000000000..33f416d59 --- /dev/null +++ b/handlers/courses/templates/groupMaterials.jade @@ -0,0 +1,46 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var siteToolbarCurrentSection = "courses"; + + +block append head + !=js("coursesMaterials", {defer: true}) + +block content + + form(action="/courses/participants") + label + input(type="hidden" name="id" value=participant._id) + input(type="checkbox" value="1" name="shouldNotifyMaterials" checked=participant.shouldNotifyMaterials data-should-notify-materials) + |  Уведомлять меня по email о появлении материалов. + + + if materials.length + +b.courses-materials + + +e('p').video-key + | Серийный номер для видео: + = ' ' + +e('span')= videoKey + + +e('table').table + +e('tr').line + +e('th').num # + +e('th').name Название + +e('th').size Размер + +e('th').added Добавлено + for material in materials + +e('tr').line + +e('td').num + +e('td').name + +e('a').link(href=material.url)= material.title + +e('td').size= material.size + +e('td').added= moment(material.created).format('DD.MM.YYYY') + else + +b.notification._message._info + +e.content Материалов пока нет, будут доступны позже. diff --git a/handlers/courses/templates/invite/askParticipantDetails.jade b/handlers/courses/templates/invite/askParticipantDetails.jade new file mode 100644 index 000000000..43a17b735 --- /dev/null +++ b/handlers/courses/templates/invite/askParticipantDetails.jade @@ -0,0 +1,83 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("coursesParticipantDetails", {defer: true}) + +block content + + if wasLoggedIn + +b.notification._message._info + +e.content Вы вошли на сайт под пользователем #{user.displayName} + + +b.participant-application + +e('form').form(method="POST" action="/courses/invite") + + input(type="hidden", name="_csrf", value=csrf()) + input(type="hidden" name="inviteToken" value=invite.token) + + +e.line + +e('label').label(for="invite-firstName") Имя * + +b('span')(class=["text-input", "__input", errors.firstName ? '_invalid' : '']) + +e('input').control#invite-firstName(name="firstName" type="text" required value=form.firstName autofocus=!Object.keys(errors).length) + if errors.firstName + +e.err= errors.firstName + + +e.line + +e('label').label(for="invite-surname") Фамилия * + +b('span')(class=["text-input", "__input", errors.surname ? '_invalid' : '' ]) + +e('input').control#invite-surname(type="text", required, name="surname" value=form.surname) + if errors.surname + +e.err= errors.surname + + +e.line + +e('span').label Фото + +b.upload-userpic(data-photo-load) + input(type="hidden" name="photoId") + +e('i').img(style="background-image: url('#{thumb(form.photo || user.getPhotoUrl(), 64, 64)}')") + +e('a').new(href="#") Загрузить новое фото + + +e.line + +e('label').label(for="invite-country") Страна * + +b('select').input-select._small(name="country" id="invite-country") + each country in countries + option(value=country.co selected=(country.co == form.country))= country.na + if errors.country + +e.err= errors.country + + +e.line + +e('label').label(for="invite-city") Город + +b('span')(class=["text-input", "__input", errors.city ? '_invalid' : '']) + +e('input').control#invite-city(type="text", name="city" value=form.city pattern=validate.patterns.doubleword) + if errors.city + +e.err= errors.city + + +e.line + +e('label').label(for="invite-occupation") Область работы + +b('span')(class=["text-input", "__input", errors.occupation ? '_invalid' : '']) + +e('input').control#invite-occupation(type="text", name="occupation" value=form.occupation) + +e('p').note Кем или в какой области работаете (кратко)? + + +e.line + +e('label').label(for="invite-aboutLink") LinkedIn / Github / Moikrug... + +b('span')(class=["text-input", "__input", errors.aboutLink ? '_invalid' : '']) + +e('input').control#invite-aboutLink(type="url", name="aboutLink" pattern=validate.patterns.webpageUrl value=form.aboutLink placeholder="https://linkedin.com/in/vasya") + +e('p').note Профиль в соц. сети или личная страница, где можно узнать о вашей профессиональной деятельности. + + +e.line + +e('label').label(for="invite-purpose") С какой целью записались на курс? + +b('textarea').textarea-input(name="purpose")#invite-purpose + + +e.line + +e('label').label(for="invite-wishes") Ваши пожелания по курсу? + +b('textarea')(name="wishes").textarea-input#invite-wishes + + +e.line.__submit + +b('button').button._action(type="submit") + +e('span').text Отправить diff --git a/handlers/courses/templates/invite/deny.jade b/handlers/courses/templates/invite/deny.jade new file mode 100644 index 000000000..8b5b1bd90 --- /dev/null +++ b/handlers/courses/templates/invite/deny.jade @@ -0,0 +1,16 @@ +extends /layouts/main + +block append variables + + - var sitetoolbar = true + - var title = "Приглашение отменено" + - var layout_main_class = "main_width-limit" + - var siteToolbarCurrentSection = "courses" + +block content + + p Извините, это приглашение было отменено, адрес #{email} был удалён из списка к заказу #{orderNumber}. + + p Контактное лицо по этому заказу: #{contactName}. + + p По техническим вопросам или если ситуация не соответствует действительности, пишите на адрес orders@javascript.ru. diff --git a/handlers/courses/templates/invite/register.jade b/handlers/courses/templates/invite/register.jade new file mode 100644 index 000000000..cd5676e6d --- /dev/null +++ b/handlers/courses/templates/invite/register.jade @@ -0,0 +1,41 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + - var siteToolbarCurrentSection = "courses" + +block content + + p Для продолжения вам необходимо зарегистрироваться. + + +b.login-form.complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(method="POST" action="/courses/invite") + input(type="hidden" name="inviteToken" value=invite.token) + + +e.line + +e('label').label(for="invite-email") Email: + +b('span').text-input.__input + +e('input').control#invite-email(name="email" type="email" value=invite.email disabled) + + +e.line + +e('label').label(for="invite-displayName") Имя пользователя: + +b('span')(class=["text-input", "__input", errors.displayName ? '_invalid' : '' ]) + +e('input').control#invite-displayName(name="displayName" type="text" required value=form.displayName autofocus) + if errors.displayName + +e.err= errors.displayName + + +e.line + +e('label').label(for="invite-password") Пароль + +b('span')(class=["text-input", "__input", errors.password ? '_invalid' : '' ]) + +e('input').control#invite-password(type="password", name="password" required value=form.password minlength="4") + if errors.password + +e.err= errors.password + + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Зарегистрироваться + diff --git a/handlers/courses/templates/order.jade b/handlers/courses/templates/order.jade new file mode 100755 index 000000000..ea08520eb --- /dev/null +++ b/handlers/courses/templates/order.jade @@ -0,0 +1,43 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + - var siteToolbarCurrentSection = "courses"; + +block append head + !=js("courses", {defer: true}) + + +block append ga + script ga('require', 'ec'); + + if orderInfo.status == 'fail' + script window.ga('ec:setAction', 'refund', { id: #{order.number} }); + + script window.ga('send', 'event', 'payment', 'return-#{orderInfo.status}', 'course'); + +block content + - var mailto = "mailto:orders@javascript.ru?subject=" + encodeURIComponent('Заказ ' + order.number); + + script var orderNumber = #{order.number}; + + script window.metrika.reachGoal('ORDER', { product: 'ebook', status: '#{orderInfo.status}', number: '#{orderInfo.number}' }); + + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + +b(data-elem="signup").courses-register._step_1 + + include blocks/receipts + include blocks/payment + include blocks/result diff --git a/handlers/courses/templates/signup.jade b/handlers/courses/templates/signup.jade new file mode 100644 index 000000000..b9d1c8201 --- /dev/null +++ b/handlers/courses/templates/signup.jade @@ -0,0 +1,43 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var siteToolbarCurrentSection = "courses" + +block append head + !=js("coursesSignup", {defer: true}) + +block content + + script var groupInfo = !{escapeJSON(groupInfo)}; + script var rateUsdRub = #{rateUsdRub}; + script var orderNumber = #{order ? order.number : 'null'}; + + - var step = !order ? '1' : (order.status != 'success' && order.status != 'pending' || changePaymentRequested) ? '3' : '4'; + +b(data-elem="signup" class=['courses-register', '_step_' + step]) + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + include blocks/receipts + + if !order + include blocks/participants + include blocks/contacts + + include blocks/payment + + include blocks/result + + include blocks/grayedList + diff --git a/handlers/csrf.js b/handlers/csrf.js new file mode 100755 index 000000000..39a5e71b9 --- /dev/null +++ b/handlers/csrf.js @@ -0,0 +1,83 @@ +const koaCsrf = require('koa-csrf'); +const PathListCheck = require('pathListCheck'); + +function CsrfChecker() { + this.ignore = new PathListCheck(); +} + + +CsrfChecker.prototype.middleware = function() { + var self = this; + + return function*(next) { + // skip these methods + if (this.method === 'GET' || this.method === 'HEAD' || this.method === 'OPTIONS') { + return yield* next; + } + + var checkCsrf = true; + + if (!this.user) { + checkCsrf = false; + } + + if (self.ignore.check(this.path)) { + checkCsrf = false; + } + + // If test check CSRF only when "X-Test-Csrf" header is set + if (process.env.NODE_ENV == 'test') { + if (!this.get('X-Test-Csrf')) { + checkCsrf = false; + } + } + + if (checkCsrf) { + this.assertCSRF(this.request.body); + } else { + this.log.debug("csrf skip"); + } + + yield* next; + }; +}; + + +// every request gets different this._csrf to use in POST +// but ALL tokens are valid +exports.init = function(app) { + koaCsrf(app); + + app.use(function*(next) { + + try { + // first, do the middleware, maybe authorize user in the process + yield* next; + } finally { + // then if we have a user, set XSRF token + if (this.req.user) { + setCsrfCookie.call(this); + } + } + + }); + + app.csrfChecker = new CsrfChecker(); + + app.use(app.csrfChecker.middleware()); +}; + + +// XSRF-TOKEN cookie name is used in angular by default +function setCsrfCookie() { + + try { + // if this doesn't throw, the user has a valid token in cookie already + this.assertCsrf({_csrf: this.cookies.get('XSRF-TOKEN') }); + } catch(e) { + // error occurs if no token or invalid token (old session) + // then we set a new (valid) one + this.cookies.set('XSRF-TOKEN', this.csrf, { httpOnly: false, signed: false }); + } + +} diff --git a/handlers/currencyRate/currencies.json b/handlers/currencyRate/currencies.json new file mode 100755 index 000000000..07a938354 --- /dev/null +++ b/handlers/currencyRate/currencies.json @@ -0,0 +1,170 @@ +{ + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Guilder", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia-Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudan Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTC": "Bitcoin", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswanan Pula", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Chilean Unit of Account (UF)", + "CLP": "Chilean Peso", + "CNY": "Chinese Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUP": "Cuban Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Republic Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EEK": "Estonian Kroon", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Islands Pound", + "GBP": "British Pound Sterling", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanaese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Manx pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "IRR": "Iranian Rial", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgystani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KPW": "North Korean Won", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Laotian Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LTL": "Lithuanian Litas", + "LVL": "Latvian Lats", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanma Kyat", + "MNT": "Mongolian Tugrik", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MTL": "Maltese Lira", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Nuevo Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Zloty", + "PYG": "Paraguayan Guarani", + "QAR": "Qatari Rial", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SDG": "Sudanese Pound", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helena Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SYP": "Syrian Pound", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "United States Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar Fuerte", + "VND": "Vietnamese Dong", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "CFA Franc BEAC", + "XAG": "Silver (troy ounce)", + "XAU": "Gold (troy ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "CFA Franc BCEAO", + "XPF": "CFP Franc", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMK": "Zambian Kwacha (pre-2013)", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar" +} \ No newline at end of file diff --git a/handlers/currencyRate/index.js b/handlers/currencyRate/index.js new file mode 100755 index 000000000..145f31f18 --- /dev/null +++ b/handlers/currencyRate/index.js @@ -0,0 +1,45 @@ + +// - Initialize money module for sync conversion +// - Load rates from DB on boot +// - provide /currency-rate/update url to update money rates + +var config = require('config'); +var CurrencyRate = require('./models/currencyRate'); + +var currencyRate; + +var request = require('koa-request'); +var fetchLatest = require('./lib/fetchLatest'); + +// all supported currencies +// http://openexchangerates.org/api/currencies.json?app_id=APP_ID +var currencies = require('./currencies'); + +var money = require('money'); + + +exports.boot = function*() { + // load from db into memory + currencyRate = yield CurrencyRate.findOne().sort({timestamp: -1}).limit(1).exec(); + + if (!currencyRate) { + currencyRate = yield* fetchLatest(); + } + + if (!currencyRate) { + throw new Error("Unable to get latest currency rate"); + } + + money.rates = currencyRate.rates; + money.base = currencyRate.base; + + // updated asynchronously +}; + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/currency-rate', __dirname)); +}; + diff --git a/handlers/currencyRate/lib/fetchLatest.js b/handlers/currencyRate/lib/fetchLatest.js new file mode 100755 index 000000000..c266b0808 --- /dev/null +++ b/handlers/currencyRate/lib/fetchLatest.js @@ -0,0 +1,43 @@ + +var request = require('koa-request'); +var CurrencyRate = require('../models/currencyRate'); + +var config = require('config'); +var log = require('log')(); + + +module.exports = function*() { + + var result; + + try { + result = yield request({ + url: 'http://openexchangerates.org/api/latest.json?app_id=' + config.openexchangerates.appId, + json: true + }); + } catch(e) { + // failed to request (remote server unavailable?) + log.error(e); + return; + } + + if (!result.body) { + log.error(result); + return; + } + + if (!result.body.rates.RUB) { + // something's wrong + log.error(result); + return; + } + + var currencyRate = yield CurrencyRate.findOneAndUpdate( + { timestamp: result.body.timestamp }, + result.body, + {upsert: true} + ).exec(); + + return currencyRate; + +}; diff --git a/handlers/currencyRate/models/currencyRate.js b/handlers/currencyRate/models/currencyRate.js new file mode 100755 index 000000000..feeb0d4be --- /dev/null +++ b/handlers/currencyRate/models/currencyRate.js @@ -0,0 +1,28 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +// the schema follows http://openexchangerates.org/api/latest.json response +var schema = new Schema({ + timestamp: { + type: Number, + required: true, + unique: true + }, + base: { + type: String, + required: true + }, + + rates: { + type: Schema.Types.Mixed, + required: true + }, + + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('CurrencyRate', schema); + diff --git a/handlers/currencyRate/router.js b/handlers/currencyRate/router.js new file mode 100755 index 000000000..c5c1ebea7 --- /dev/null +++ b/handlers/currencyRate/router.js @@ -0,0 +1,27 @@ +// CRONTAB: run me daily +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var CurrencyRate = require('./models/currencyRate'); +var fetchLatest = require('./lib/fetchLatest'); +var mustBeAdmin = require('auth').mustBeAdmin; + +var router = module.exports = new Router(); + +var money = require('money'); + +router.get('/update', mustBeAdmin, function*() { + var currencyRate = yield* fetchLatest(); + + if (!currencyRate) { + return; + } + + money.rates = currencyRate.rates; + money.base = currencyRate.base; + + this.body = { + status: "ok", + time: new Date() + }; +}); + diff --git a/handlers/dev/index.js b/handlers/dev/index.js new file mode 100755 index 000000000..cec3cee47 --- /dev/null +++ b/handlers/dev/index.js @@ -0,0 +1,6 @@ +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/dev', __dirname) ); +}; + diff --git a/handlers/dev/router.js b/handlers/dev/router.js new file mode 100755 index 000000000..3e19e9392 --- /dev/null +++ b/handlers/dev/router.js @@ -0,0 +1,45 @@ +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var Article = require('tutorial').Article; +var _ = require('lodash'); + +var router = module.exports = new Router(); + +router.get('/user', function*() { + var User = require('users').User; + + var user = new User({ + email: 'mk@abc.ru', + gender: 'male', + displayName: 'Tester2' + }); + + try { + mongoose.Types.ObjectId("51bb793aca2ab77a3200000d"); + + var user = yield User.findById('blabla', function(err, res) { + console.log(arguments); + }); + + } catch (e) { + console.log(e.errors); + } + +}); + +router.get('/die', function*() { + setTimeout(function() { + throw new Error("die"); + }, 10); +}); + +var d = new Date() + ''; + +router.get('/test', function*() { + + this.log.debug("BLABLA"); + + this.body = 'gbkjgjf'; +}); + diff --git a/handlers/download/controllers/download.js b/handlers/download/controllers/download.js new file mode 100755 index 000000000..6a2b21b78 --- /dev/null +++ b/handlers/download/controllers/download.js @@ -0,0 +1,24 @@ +var path = require('path'); + +var ExpiringDownloadLink = require('../models/expiringDownloadLink'); + +exports.get = function*() { + + var linkId = this.params.linkId; + + var downloadLink = yield ExpiringDownloadLink.findOne({ + linkId: linkId + }).exec(); + + if (!downloadLink) { + this.throw(404); + } + + this.set({ + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment; filename=' + path.basename(downloadLink.relativePath), + 'X-Accel-Redirect': '/_download/' + downloadLink.relativePath + }); + + this.body = ''; +}; \ No newline at end of file diff --git a/handlers/download/index.js b/handlers/download/index.js new file mode 100755 index 000000000..d3b102254 --- /dev/null +++ b/handlers/download/index.js @@ -0,0 +1,10 @@ + + +exports.ExpiringDownloadLink = require('./models/expiringDownloadLink'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/download', __dirname)); +}; + diff --git a/handlers/download/models/expiringDownloadLink.js b/handlers/download/models/expiringDownloadLink.js new file mode 100755 index 000000000..ed7aa144f --- /dev/null +++ b/handlers/download/models/expiringDownloadLink.js @@ -0,0 +1,36 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var crypto = require('crypto'); +var config = require('config'); + +// files use /files/ dir +var schema = new Schema({ + + linkId: { + type: String, + default: function() { + // 6-7 random alphanumeric chars + return parseInt(crypto.randomBytes(4).toString('hex'), 16).toString(36); + }, + required: true, + unique: true + }, + + relativePath: { + type: String, + required: true + }, + + created: { + type: Date, + default: Date.now, + expires: '3d' // link must die in 3 days + } +}); + +schema.methods.getUrl = function() { + return config.server.siteHost + '/download/' + this.linkId; +}; + +module.exports = mongoose.model('ExpiringDownloadLink', schema); + diff --git a/handlers/download/router.js b/handlers/download/router.js new file mode 100755 index 000000000..c30020e7e --- /dev/null +++ b/handlers/download/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var download = require('./controllers/download'); + +var router = module.exports = new Router(); + +router.get('/:linkId*', download.get); diff --git a/handlers/ebook/client/index.js b/handlers/ebook/client/index.js new file mode 100755 index 000000000..659206d2e --- /dev/null +++ b/handlers/ebook/client/index.js @@ -0,0 +1,15 @@ +var OrderForm = require('./orderForm'); + +function init() { + + + var orderForm = document.querySelector('[data-order-form]'); + if (orderForm) { + new OrderForm({ + elem: orderForm + }); + } + +} + +init(); diff --git a/handlers/ebook/client/orderForm.js b/handlers/ebook/client/orderForm.js new file mode 100755 index 000000000..237a11ef2 --- /dev/null +++ b/handlers/ebook/client/orderForm.js @@ -0,0 +1,74 @@ +var xhr = require('client/xhr'); +var notification = require('client/notification'); +var delegate = require('client/delegate'); +var FormPayment = require('payments/common/client').FormPayment; +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); + +class OrderForm { + + constructor(options) { + this.elem = options.elem; + + this.product = 'ebook'; + + this.elem.addEventListener('submit', (e) => this.onSubmit(e)); + + this.delegate('[data-order-payment-change]', 'click', function(e) { + e.preventDefault(); + this.elem.querySelector('[data-order-form-step-payment]').style.display = 'block'; + this.elem.querySelector('[data-order-form-step-confirm]').style.display = 'none'; + this.elem.querySelector('[data-order-form-step-receipt]').style.display = 'none'; + }); + + this.delegate('.complex-form__extract .extract__item', 'click', function(e) { + e.delegateTarget.querySelector('[type="radio"]').checked = true; + }); + } + + + onSubmit(event) { + event.preventDefault(); + new FormPayment(this, this.elem).submit(); + } + + + // return orderData or nothing if validation failed + getOrderData() { + var orderData = { }; + + if (window.orderNumber) { + orderData.orderNumber = window.orderNumber; + } else { + var chooser = this.elem.querySelector('input[name="orderTemplate"]:checked'); + orderData.orderTemplate = chooser.value; + orderData.amount = chooser.dataset.amount; // for stats + } + + if (this.elem.elements.email) { + if (!this.elem.elements.email.value) { + window.ga('send', 'event', 'payment', 'checkout-no-email', 'ebook'); + window.metrika.reachGoal('CHECKOUT-NO-EMAIL', {product: 'ebook'}); + new notification.Error("Введите email."); + this.elem.elements.email.scrollIntoView(); + setTimeout(function() { + window.scrollBy(0, -200); + }, 0); + this.elem.elements.email.focus(); + return; + } else { + orderData.email = this.elem.elements.email.value; + } + } + + return orderData; + } + + + +} + + +delegate.delegateMixin(OrderForm.prototype); + +module.exports = OrderForm; diff --git a/handlers/ebook/controller/newOrder.js b/handlers/ebook/controller/newOrder.js new file mode 100755 index 000000000..ec3c2079c --- /dev/null +++ b/handlers/ebook/controller/newOrder.js @@ -0,0 +1,19 @@ +const payments = require('payments'); +var OrderTemplate = payments.OrderTemplate; + +exports.get = function*() { + this.nocache(); + + var orderTemplates = yield OrderTemplate.find({ + module: 'ebook' + }).sort({weight: 1}).exec(); + + this.locals.orderTemplates = orderTemplates; + + this.locals.sitetoolbar = true; + this.locals.title = "Покупка учебника JavaScript"; + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + this.body = this.render('newOrder'); +}; diff --git a/handlers/ebook/controller/orders.js b/handlers/ebook/controller/orders.js new file mode 100755 index 000000000..453983e66 --- /dev/null +++ b/handlers/ebook/controller/orders.js @@ -0,0 +1,30 @@ +const payments = require('payments'); +var Order = payments.Order; +var getOrderInfo = payments.getOrderInfo; +var OrderTemplate = payments.OrderTemplate; +var Transaction = payments.Transaction; +var assert = require('assert'); + +// Existing order page +exports.get = function*() { + + yield* this.loadOrder({ + ensureSuccessTimeout: 10000 + }); + + this.nocache(); + + this.locals.sitetoolbar = true; + this.locals.title = 'Заказ №' + this.order.number; + + this.locals.order = this.order; + + this.locals.user = this.req.user; + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + this.locals.orderInfo = yield* getOrderInfo(this.order); + + this.body = this.render('order'); + +}; diff --git a/handlers/ebook/index.js b/handlers/ebook/index.js new file mode 100755 index 000000000..82e196297 --- /dev/null +++ b/handlers/ebook/index.js @@ -0,0 +1,14 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/ebook', __dirname)); + + // anon can do anything here + app.csrfChecker.ignore.add('/ebook/:any*'); + +}; + +exports.onPaid = require('./lib/onPaid'); +exports.cancelIfPendingTooLong = require('./lib/cancelIfPendingTooLong'); +exports.createOrderFromTemplate = require('./lib/createOrderFromTemplate'); diff --git a/handlers/ebook/lib/cancelIfPendingTooLong.js b/handlers/ebook/lib/cancelIfPendingTooLong.js new file mode 100644 index 000000000..a61899fe4 --- /dev/null +++ b/handlers/ebook/lib/cancelIfPendingTooLong.js @@ -0,0 +1,10 @@ +var Order = require('payments').Order; + +// pending for a week => cancel without a notice +module.exports = function*(order) { + if (order.created < new Date() - 7 * 24 * 86400 * 1e3) { + yield order.persist({ + status: Order.STATUS_CANCEL + }); + } +}; diff --git a/handlers/ebook/lib/createOrderFromTemplate.js b/handlers/ebook/lib/createOrderFromTemplate.js new file mode 100755 index 000000000..0070bff4e --- /dev/null +++ b/handlers/ebook/lib/createOrderFromTemplate.js @@ -0,0 +1,34 @@ +var Order = require('payments').Order; +var Discount = require('payments').Discount; + +// middleware +// create order from template, +// use the incoming data if needed +module.exports = function* (orderTemplate, user, requestBody) { + + var amount = orderTemplate.amount; + if (requestBody.discountCode) { + var discount = yield* Discount.findByCodeAndModule(requestBody.discountCode, 'ebook'); + if (discount) amount = discount.adjustAmount(amount); + } + + var order = new Order({ + title: orderTemplate.title, + description: orderTemplate.description, + amount: amount, + module: orderTemplate.module, + data: orderTemplate.data + }); + + if (user) { + order.user = user._id; + order.email = user.email; + } else { + order.email = requestBody.email; + } + + yield order.persist(); + + return order; + +}; diff --git a/handlers/ebook/lib/onPaid.js b/handlers/ebook/lib/onPaid.js new file mode 100755 index 000000000..9dea7d604 --- /dev/null +++ b/handlers/ebook/lib/onPaid.js @@ -0,0 +1,33 @@ +const Order = require('payments').Order; +const sendMail = require('mailer').send; +const ExpiringDownloadLink = require('download').ExpiringDownloadLink; +const path = require('path'); +const log = require('log')(); + +// not a middleware +// can be called from CRON +module.exports = function* (order) { + + var downloadLink = new ExpiringDownloadLink({ + relativePath: order.data.file + }); + + downloadLink.linkId += "/" + path.basename(order.data.file); + + yield downloadLink.persist(); + + yield sendMail({ + templatePath: path.join(__dirname, '..', 'templates', 'successEmail'), + to: order.email, + subject: "Учебник для чтения оффлайн", + link: downloadLink.getUrl() + }); + + order.data.downloadLink = downloadLink.getUrl(); + order.markModified('data'); + order.status = Order.STATUS_SUCCESS; + + yield order.persist(); + + log.debug("Order success: " + order.number); +}; diff --git a/handlers/ebook/lib/paymentMethods.js b/handlers/ebook/lib/paymentMethods.js new file mode 100755 index 000000000..c16f6b3b3 --- /dev/null +++ b/handlers/ebook/lib/paymentMethods.js @@ -0,0 +1,11 @@ +const payments = require('payments'); + +var paymentMethods = {}; + +var methodsEnabled = ['webmoney', 'yandexmoney', 'paypal', 'payanyway', 'interkassa']; + +methodsEnabled.forEach(function(key) { + paymentMethods[key] = payments.methods[key].info; +}); + +module.exports = paymentMethods; diff --git a/handlers/ebook/router.js b/handlers/ebook/router.js new file mode 100755 index 000000000..169cb105c --- /dev/null +++ b/handlers/ebook/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var newOrder = require('./controller/newOrder'); +var orders = require('./controller/orders'); + +router.get('/', newOrder.get); +router.get('/orders/:orderNumber(\\d+)', orders.get); + diff --git a/handlers/ebook/templates/newOrder.jade b/handlers/ebook/templates/newOrder.jade new file mode 100644 index 000000000..767d6f83b --- /dev/null +++ b/handlers/ebook/templates/newOrder.jade @@ -0,0 +1,58 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + +block append ga + script. + ga('require', 'ec') + ga('set', '&cu', 'RUB'); + + ga('ec:addProduct', { + id: 'ebook' + }); + ga('ec:setAction', 'click'); + +block append head + !=js("ebook", {defer: true}) + +block content + + script var status = "#{status}"; + + +b("form").complex-form(data-order-form) + +e.step._current + +e('h2').alternate-title Выберите курс + +b.extract._small.__extract + each orderTemplate, i in orderTemplates + +e.item + +e('input').input(type="radio" name="orderTemplate" value=orderTemplate.slug data-price=orderTemplate.amount checked=(i==0) id=('book-' + i) ) + +e.wrap + +e.input-wrap + +e.content + +e('h3').title + label(for=('book-' + i))!= orderTemplate.title + +e.info!= orderTemplate.description + +e.aside._price._center + | Стоимость + +b.price.__price + | #{orderTemplate.amount + ' RUB'} + +e('span').secondary (≈ #{currencyConvertRound(orderTemplate.amount, "RUB", "USD")}$) + + if (!user) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="my@email.com", name="email", required, type="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес. + + +e('h2').alternate-title Выберите метод оплаты + +e.body + +b.extract._small.__extract + include ../../payments/common/templates/payment-methods + + +e.submit-line + +b('button')(type="submit").button._action + +e('span').text Продолжить + + +b('ul').grayed-list + +e('li').item Подтверждение diff --git a/handlers/ebook/templates/order.jade b/handlers/ebook/templates/order.jade new file mode 100755 index 000000000..a8d3ed118 --- /dev/null +++ b/handlers/ebook/templates/order.jade @@ -0,0 +1,106 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + +block append ga + script ga('require', 'ec'); + + if orderInfo.status == 'fail' + script window.ga('ec:setAction', 'refund', { id: #{order.number} }); + + script window.ga('send', 'event', 'payment', 'return-#{orderInfo.status}', 'ebook'); + +block append head + !=js("ebook", {defer: true}) + +block content + + script var orderNumber = #{order.number}; + + script window.metrika.reachGoal('ORDER', { product: 'ebook', status: '#{orderInfo.status}', number: '#{orderInfo.number}' }); + + - var mailto = "mailto:orders@javascript.ru?subject=" + encodeURIComponent('Заказ ' + order.number); + + +b('form').complex-form(data-order-form data-order-info-status=orderInfo.status) + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + if !~['fail'].indexOf(orderInfo.status) + +b.receipts.__receipts(data-order-form-step-receipt) + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title!= order.title + +e.note!= order.description + +e.receipt-aside + +e.price #{order.amount + 'р.'} + if ~['paid', 'success', 'pending'].indexOf(orderInfo.status) + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + if (orderInfo.status == 'paid' || orderInfo.status == 'success') + +e.status._ok Осуществлена успешно + else if (orderInfo.status == 'pending') + +e.status._ok Ожидается подтверждение + +e.receipt-aside + +e(class=["pay-method", "_" + paymentMethods[orderInfo.transaction.paymentMethod].name]) + + + if ~['fail', 'pending'].indexOf(orderInfo.status) + +e.step._current(data-order-form-step-payment) + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title!= order.title + +e.info!= order.description + +e.aside._price._center + | Стоимость + +b.price.__price + | #{order.amount + ' RUB'} + +e('span').secondary (≈ #{currencyConvertRound(order.amount, "RUB", "USD")}$) + + if orderInfo.status == 'fail' + +e('h2').alternate-title Выберите метод оплаты + else if orderInfo.status == 'pending' + +e('h2').alternate-title Выберите другой метод оплаты + p Не оплачивайте дважды. Меняйте метод оплаты лишь если уверены, что оплата не произошла. + + include ../../payments/common/templates/payment-methods + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + + if orderInfo.status == 'success' + +e.step._current(data-order-form-step-confirm) + +b.order-confirm + +e('h2').title__step-title Спасибо за покупку! + +e.accent._ok В ближайшее время на электронный адрес #{order.email} придёт подтверждение. + +e.content + +e.text + p Вы можете скачать учебник прямо сейчас, по ссылке #{order.data.downloadLink}. + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + else if ~['error', 'cancel', 'pending', 'paid'].indexOf(orderInfo.status) + +e.step._current(data-order-form-step-confirm) + +b.order-confirm + +e('h2').title__step-title!= orderInfo.title + if orderInfo.accent + +e.accent!= orderInfo.accent + if orderInfo.description + +e.content + +e.text!= orderInfo.description + + if ~['fail'].indexOf(orderInfo.status) + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение diff --git a/handlers/ebook/templates/successEmail.jade b/handlers/ebook/templates/successEmail.jade new file mode 100644 index 000000000..721670662 --- /dev/null +++ b/handlers/ebook/templates/successEmail.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Спасибо за покупку! + p Вы можете скачать учебник по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/errorHandler/index.js b/handlers/errorHandler/index.js new file mode 100755 index 000000000..cdd17b046 --- /dev/null +++ b/handlers/errorHandler/index.js @@ -0,0 +1,142 @@ +const config = require('config'); +const escapeHtml = require('escape-html'); +const _ = require('lodash'); +const path = require('path'); + +var isDevelopment = process.env.NODE_ENV == 'development'; + +// can be called not from this MW, but from anywhere +// this.templateDir can be anything +function renderError(err) { + /*jshint -W040 */ + + // don't pass just err, because for "stack too deep" errors it leads to logging problems + var report = { + message: err.message, + stack: err.stack, + errors: err.errors, // for validation errors + status: err.status, + referer: this.get('referer'), + cookie: this.get('cookie') + }; + if (!err.expose) { // dev error + report.requestVerbose = this.request; + } + + this.log.error(report); + + // may be error if headers are already sent! + this.set('X-Content-Type-Options', 'nosniff'); + + var preferredType = this.accepts('html', 'json'); + + if (err.name == 'ValidationError') { + this.status = 400; + + if (preferredType == 'json') { + var errors = {}; + + for (var field in err.errors) { + errors[field] = err.errors[field].message; + } + + this.body = { + errors: errors + }; + } else { + this.body = this.render(path.join(__dirname, "templates/400"), { + useAbsoluteTemplatePath: true, + error: err + }); + } + + return; + } + + if (isDevelopment) { + this.status = err.status || 500; + + var stack = (err.stack || '') + .split('\n').slice(1) + .map(function(v) { + return '
  • ' + escapeHtml(v).replace(/ /g, '  ') + '
  • '; + }).join(''); + + if (preferredType == 'json') { + this.body = { + message: err.message, + stack: stack + }; + this.body.statusCode = err.statusCode || err.status; + } else { + this.type = 'text/html; charset=utf-8'; + this.body = "

    " + err.message + "

      " + stack + "
    "; + } + + return; + } + + this.status = err.expose ? err.status : 500; + + if (preferredType == 'json') { + this.body = { + message: err.message, + statusCode: err.status || err.statusCode + }; + if (err.description) { + this.body.description = err.description; + } + } else { + var templateName = ~[500, 401, 404, 403].indexOf(this.status) ? this.status : 500; + this.body = this.render(`${__dirname}/templates/${templateName}`, { + useAbsoluteTemplatePath: true, + error: err, + requestId: this.requestId + }); + } + +} + + +exports.init = function(app) { + + app.use(function*(next) { + this.renderError = renderError; + + try { + yield* next; + } catch (err) { + // this middleware is not like others, it is not endpoint + // so wrapHmvcMiddleware is of little use + try { + this.renderError(err); + } catch(renderErr) { + // could not render, maybe template not found or something + this.status = 500; + this.body = "Server render error"; + this.log.error(renderErr); // make it last to ensure that status/body are set + } + } + }); + + // this middleware handles error BEFORE ^^^ + // rewrite mongoose wrong mongoose parameter -> 400 (not 500) + app.use(function* rewriteCastError(next) { + + try { + yield next; + } catch (err) { + + if (err.name == 'CastError') { + // malformed or absent mongoose params + if (process.env.NODE_ENV == 'production') { // do not rewrite in dev/test env + this.throw(400); + } + } + + throw err; + } + + }); + +}; diff --git a/handlers/errorHandler/templates/400.jade b/handlers/errorHandler/templates/400.jade new file mode 100755 index 000000000..7ac017355 --- /dev/null +++ b/handlers/errorHandler/templates/400.jade @@ -0,0 +1,17 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Некорректный запрос'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Некорректный запрос + +e.code 400 + if error.errors + + +e.text + dl + each value, key in error.errors + dt= key + dd= value.message diff --git a/handlers/errorHandler/templates/401.jade b/handlers/errorHandler/templates/401.jade new file mode 100755 index 000000000..a9a194117 --- /dev/null +++ b/handlers/errorHandler/templates/401.jade @@ -0,0 +1,15 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Требуется авторизация'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ к этой странице закрыт + +e.code 401 + +e.text + if error.description + != error.description + else + | Возможно, вам нужно войти в сайт? \ No newline at end of file diff --git a/handlers/errorHandler/templates/403.jade b/handlers/errorHandler/templates/403.jade new file mode 100755 index 000000000..220e558b8 --- /dev/null +++ b/handlers/errorHandler/templates/403.jade @@ -0,0 +1,15 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Доступ запрещён'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ запрещён + +e.code 403 + +e.text + if error.description + != error.description + else + | Возможно, вам нужно войти под нужным пользователем? \ No newline at end of file diff --git a/handlers/errorHandler/templates/404.jade b/handlers/errorHandler/templates/404.jade new file mode 100755 index 000000000..1e8e44e61 --- /dev/null +++ b/handlers/errorHandler/templates/404.jade @@ -0,0 +1,23 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Страница не найдена'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Страница не найдена + +e.code 404 + +e.text + p Возможно, это произошло из-за большого обновления сайта, многие материалы были обновлены и реорганизованы. + p Для того, чтобы найти нужную вам страницу, вы можете воспользоваться поиском: + +e.text + +e('form').search(action="/search") + +e.search-query-wrap + +b('span').text-input.__search-query + +e('input').control(type="text", name="query") + +e.search-submit-wrap + +b('button').button._action.__search-submit + +e('span').text Найти + +e.text + | …или верхней навигацией. diff --git a/handlers/errorHandler/templates/500.jade b/handlers/errorHandler/templates/500.jade new file mode 100755 index 000000000..ded02bac5 --- /dev/null +++ b/handlers/errorHandler/templates/500.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Ошибка на сервере'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Ошибка на сервере + +e.code 500 + - var subject = encodeURIComponent("Ошибка 500 на " + url.href) + +e.text Мы анализируем и исправляем возникающие ошибки, но вы можете ускорить этот процесс, сообщив подробности на mk@javascript.ru. + +e.text + +e('span').request RequestId: #{requestId} diff --git a/handlers/flash/index.js b/handlers/flash/index.js new file mode 100755 index 000000000..1165f3e61 --- /dev/null +++ b/handlers/flash/index.js @@ -0,0 +1,63 @@ + +// FIXME: add flash deletion? +exports.init = function(app) { + // koa-flash is broken + // reading from one object, writing to another object + // occasionally writing to default + app.use(function *flash(next) { + this.flash = this.session.flash || {}; + + this.session.flash = {}; + + Object.defineProperty(this, 'newFlash', { + get: function() { + return this.session.flash; + }, + set: function(val) { + this.session.flash = val; + } + }); + + yield *next; + + // now this.session can be null + // (logout does that) + + if (this.session && Object.keys(this.session.flash).length === 0) { + // don't write empty flash + delete this.session.flash; + } + + if (this.status == 302 && this.session && !this.session.flash) { + // pass on the flash over a redirect + this.session.flash = this.flash; + } + }); + + app.use(function*(next) { + + var notificationTypes = ["error", "warning", "info", "success"]; + + // by default koa-flash uses same defaultValue object for all flashes, + // this.flash.message writes to defaultValue! + + this.addFlashMessage = function(type, html) { + // split this.flash from defaultValue (fix bug in koa-flash!) + if (!this.newFlash.messages) { + this.newFlash.messages = []; + } + + if (!~notificationTypes.indexOf(type)) { + throw new Error("Unknown flash type: " + type); + } + + this.newFlash.messages.push({ + type: type, + html: html + }); + }; + + yield* next; + + }); +}; diff --git a/handlers/imgur/controllers/upload.js b/handlers/imgur/controllers/upload.js new file mode 100644 index 000000000..70b5838f1 --- /dev/null +++ b/handlers/imgur/controllers/upload.js @@ -0,0 +1,63 @@ +var multiparty = require('multiparty'); +var uploadStream = require('../lib/uploadStream'); +var co = require('co'); +var ImgurImage = require('../models/imgurImage'); +var BadImageError = require('../lib/badImageError'); + +exports.post = function*() { + + var self = this; + + if (process.env.IMGUR_DISABLED) { + this.body = { + imgurId: (Math.random() * 1e6 ^ 0).toString(36), + link: '//assets/img/logo.png' + }; + return; + } + + var imgurImage; + try { + imgurImage = yield new Promise(function(resolve, reject) { + + var form = new multiparty.Form(); + + // multipart file must be the last + form.on('part', function(part) { + self.log.debug("Part", part.name, part.filename); + + if (!part.filename) { + reject(new Error("No filename for form part " + part.name)); + return; + } + + co(function*() { + // filename='blob' for FormData(photo, blob) where blob comes from canvas.toBlob + return yield* uploadStream(part.filename, part.byteCount, part); + }).then(function(result) { + resolve(result); + }).catch(reject); + }); + + form.on('error', reject); + + form.parse(self.req); + + }); + } catch (e) { + if (e instanceof BadImageError) { + this.status = 400; + this.body = e.message; + return; + } else { + throw e; + } + } + + + this.body = { + link: imgurImage.link, + imgurId: imgurImage.imgurId + }; + +}; diff --git a/handlers/imgur/fixtures/user.js b/handlers/imgur/fixtures/user.js new file mode 100644 index 000000000..b593b87d8 --- /dev/null +++ b/handlers/imgur/fixtures/user.js @@ -0,0 +1,13 @@ +require('users').User; + + +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "profileName": "iliakan", + "password": "1234", + "verifiedEmail": true + } +]; diff --git a/handlers/imgur/index.js b/handlers/imgur/index.js new file mode 100644 index 000000000..cd81c8fb1 --- /dev/null +++ b/handlers/imgur/index.js @@ -0,0 +1,13 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + + app.multipartParser.ignore.add('/imgur/upload'); + + app.use(mountHandlerMiddleware('/imgur', __dirname)); +}; + +exports.ImgurImage = require('./models/imgurImage'); +exports.transload = require('./lib/transload'); +exports.uploadStream = require('./lib/uploadStream'); diff --git a/handlers/imgur/lib/badImageError.js b/handlers/imgur/lib/badImageError.js new file mode 100644 index 000000000..d0620b807 --- /dev/null +++ b/handlers/imgur/lib/badImageError.js @@ -0,0 +1,11 @@ +var inherits = require('inherits'); + +function BadImageError(msg) { + Error.call(this, msg); + this.message = msg; + this.name = 'BadImageError'; +} +inherits(BadImageError, Error); + + +module.exports = BadImageError; diff --git a/handlers/imgur/lib/imgurRequest.js b/handlers/imgur/lib/imgurRequest.js new file mode 100644 index 000000000..3dcbae38f --- /dev/null +++ b/handlers/imgur/lib/imgurRequest.js @@ -0,0 +1,27 @@ +var config = require('config'); +var _ = require('lodash'); +var request = require('request'); +var log = require('log')(); + +module.exports = function*(serviceName, options) { + options = _.merge({ + method: 'POST', + url: config.imgur.url + serviceName, + headers: {'Authorization': 'Client-ID ' + config.imgur.clientId}, + json: true + }, options); + + var response = yield function(callback) { + request(options, function(error, response) { + callback(error, response); + }); + }; + + if (response.statusCode != 200 && response.statusCode != 400) { + log.error("Imgur error", {res: response}); + throw new Error("Error communicating with imgur service."); + } + + return response.body; + +}; diff --git a/handlers/imgur/lib/transload.js b/handlers/imgur/lib/transload.js new file mode 100644 index 000000000..9867aad24 --- /dev/null +++ b/handlers/imgur/lib/transload.js @@ -0,0 +1,19 @@ + +var log = require('log')(); +var imgurRequest = require('./imgurRequest'); +var BadImageError = require('./badImageError'); +var ImgurImage = require('../models/imgurImage'); + +module.exports = function*(url) { + + log.debug("transload", url); + var response = yield imgurRequest('image', { + formData: { + type: 'url', + image: url + } + }); + + return yield ImgurImage.createFromResponse(response); +}; + diff --git a/handlers/imgur/lib/uploadBuffer.js b/handlers/imgur/lib/uploadBuffer.js new file mode 100644 index 000000000..89c531ba0 --- /dev/null +++ b/handlers/imgur/lib/uploadBuffer.js @@ -0,0 +1,6 @@ +var uploadStream = require('./uploadStream'); + +module.exports = function*(fileName, buffer) { + // the same code actually + return yield* uploadStream(fileName, buffer.length, buffer); +}; diff --git a/handlers/imgur/lib/uploadStream.js b/handlers/imgur/lib/uploadStream.js new file mode 100644 index 000000000..8d44ab807 --- /dev/null +++ b/handlers/imgur/lib/uploadStream.js @@ -0,0 +1,46 @@ + +var capitalizeKeys = require('lib/capitalizeKeys'); +var BadImageError = require('./badImageError'); +var mime = require('mime'); +var imgurRequest = require('./imgurRequest'); +var ImgurImage = require('../models/imgurImage'); + +/* + custom_file: { + value: fs.createReadStream('/dev/urandom'), + options: { + filename: 'topsecret.jpg', + contentType: 'image/jpg' + } + */ + +/** + * Uploads a stream (file or multiparty part or...) + * @param fileName fileName (for mime) + * @param knownLength contentLength (from file stream request could get it, but not from multiparty part) + * @param stream + * @returns {*} + */ +module.exports = function*(fileName, knownLength, stream) { + + if (!knownLength) { + throw new BadImageError("Пустое изображение."); + } + var mimeType = mime.lookup(fileName); + + var response = yield* imgurRequest('image', { + formData: { + type: 'file', + image: { + value: stream, + options: { + filename: fileName, + contentType: mimeType, + knownLength: knownLength + } + } + } + }); + + return yield ImgurImage.createFromResponse(response); +}; diff --git a/handlers/imgur/models/imgurImage.js b/handlers/imgur/models/imgurImage.js new file mode 100644 index 000000000..d002d4059 --- /dev/null +++ b/handlers/imgur/models/imgurImage.js @@ -0,0 +1,110 @@ +const capitalizeKeys = require('lib/capitalizeKeys'); +const BadImageError = require('../lib/badImageError'); +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + imgurId: { + type: String, + required: true, + unique: true + }, + title: { + type: String + }, + description: { + type: String + }, + datetime: { + type: Number + }, + type: { + type: String + }, + animated: { + type: Boolean + }, + width: { + type: Number, + required: true + }, + height: { + type: Number, + required: true + }, + size: { + type: Number, + required: true + }, + views: { + type: Number, + required: true + }, + bandwidth: { + type: Number, + required: true + }, + deletehash: { + type: String, + required: true + }, + name: { + type: String + }, + link: { + type: String + }, + gifv: { + type: String + }, + mp4: { + type: String + }, + webm: { + type: String + }, + section: { + type: String + }, + looping: { + type: Boolean + }, + favorite: { + type: Boolean + }, + nsfw: { + type: Boolean + }, + vote: { + type: String + }, + accountId: { + type: Number + }, + accountUrl: { + type: String + } +}); + +/** + * Create ImgurImage from raw imgur response and save it. + * @param response + * @returns {*} + */ +schema.statics.createFromResponse = function*(response) { + + if (!response.success) { + throw new BadImageError(response.data.error); + } + + var imgurImageData = capitalizeKeys(response.data); + imgurImageData.imgurId = imgurImageData.id; + delete imgurImageData.id; + + var imgurImage = new ImgurImage(imgurImageData); + yield imgurImage.persist(); + return imgurImage; +}; + +/* jshint -W003 */ +var ImgurImage = module.exports = mongoose.model('imgurImage', schema); diff --git a/handlers/imgur/router.js b/handlers/imgur/router.js new file mode 100644 index 000000000..957f07097 --- /dev/null +++ b/handlers/imgur/router.js @@ -0,0 +1,8 @@ +var Router = require('koa-router'); +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var upload = require('./controllers/upload'); + +var router = module.exports = new Router(); + +router.post('/upload', mustBeAuthenticated, upload.post); + diff --git a/handlers/imgur/test/ball.gif b/handlers/imgur/test/ball.gif new file mode 100644 index 000000000..4843c13d5 Binary files /dev/null and b/handlers/imgur/test/ball.gif differ diff --git a/handlers/imgur/test/server/upload.js b/handlers/imgur/test/server/upload.js new file mode 100644 index 000000000..7e41e55e0 --- /dev/null +++ b/handlers/imgur/test/server/upload.js @@ -0,0 +1,51 @@ +const path = require('path'); +const request = require('supertest'); +const app = require('app'); +const ImgurImage = require('../../models/imgurImage'); +const User = require('users').User; +const fixtures = require(path.join(__dirname, '../../fixtures/user')); +const db = require('lib/dataUtil'); + +describe('imgur', function() { + + var server; + var user; + before(function* () { + yield ImgurImage.remove({}); + yield* db.loadModels(fixtures, {reset: true}); + + user = yield User.findOne({}); + + server = app.listen(); + }); + + after(function() { + server.close(); + }); + + describe('POST /imgur/upload', function() { + + it('returns id after uploading a valid image', function(done) { + request(server) + .post('/imgur/upload') + .set('X-Test-User-Id', fixtures.User[0]._id) + .attach('photo', path.join(__dirname, '../test.png'), 'test.png') + .expect(200) + .end(function(err, res) { + if (err) return done(err); + res.body.imgurId.should.exist; + res.body.link.should.exist; + done(); + }); + }); + + it('fails to upload a non-image', function(done) { + request(server) + .post('/imgur/upload') + .set('X-Test-User-Id', fixtures.User[0]._id) + .attach('photo', __filename, 'test.png') + .expect(400, done); + }); + }); + +}); diff --git a/handlers/imgur/test/test.png b/handlers/imgur/test/test.png new file mode 100644 index 000000000..3cba80920 Binary files /dev/null and b/handlers/imgur/test/test.png differ diff --git a/handlers/imgur/test/transload.js b/handlers/imgur/test/transload.js new file mode 100644 index 000000000..c2d65bdff --- /dev/null +++ b/handlers/imgur/test/transload.js @@ -0,0 +1,33 @@ +var fs = require('fs'); +var path = require('path'); + +var transload = require('../lib/transload'); + +describe("imgur", function() { + + describe("transload", function() { + + var urlExample = 'http://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/LARGE_elevation.jpg/800px-LARGE_elevation.jpg'; + var url14MB = 'http://upload.wikimedia.org/wikipedia/commons/3/3d/LARGE_elevation.jpg'; + + it("works for a normal url", function*() { + var response = yield* transload(urlExample); + response.imgurId.should.be.string; + }); + + + it("fails for too big picture", function*() { + var hasError = false; + try { + yield* transload(url14MB); + } catch (e) { + hasError = true; + } + hasError.should.be.true; + }); + + + }); + + +}); diff --git a/handlers/imgur/test/uploadStream.js b/handlers/imgur/test/uploadStream.js new file mode 100644 index 000000000..b58dc5c64 --- /dev/null +++ b/handlers/imgur/test/uploadStream.js @@ -0,0 +1,35 @@ +var fs = require('fs'); +var path = require('path'); + +var uploadStream = require('../lib/uploadStream'); + +describe("imgur", function() { + + describe("uploadStream", function() { + + it("uploads a stream as image (gif)", function*() { + + var filePath = path.join(__dirname, 'ball.gif'); + var stream = fs.createReadStream(filePath); + + var response = yield* uploadStream(filePath, fs.statSync(filePath).size, stream); + + response.size.should.be.eql(fs.statSync(filePath).size); + response.should.be.string; + }); + + it("uploads a stream as image (png)", function*() { + + var filePath = path.join(__dirname, 'test.png'); + var stream = fs.createReadStream(filePath); + + var response = yield* uploadStream(filePath, fs.statSync(filePath).size, stream); + + response.size.should.be.eql(fs.statSync(filePath).size); + response.should.be.string; + }); + + }); + + +}); diff --git a/handlers/jb/controllers/index.js b/handlers/jb/controllers/index.js new file mode 100755 index 000000000..2033961a3 --- /dev/null +++ b/handlers/jb/controllers/index.js @@ -0,0 +1,81 @@ +var JbRequest = require('../models/jbRequest'); +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +var products = { + "WebStorm": "WebStorm (JavaScript/HTML/CSS)", + "PhpStorm": "PhpStorm (PHP)", + "RubyMine": "RubyMine (Ruby)", + "IntelliJ IDEA": "IntelliJ IDEA Ultimate (Java)", + "ReSharper": "ReSharper (C#)", + "PyCharm": "PyCharm (Python)", + "AppCode": "AppCode (OSX, Objective-C, C/C++)", + "CLion": "CLion (C/C++)" +}; + + +exports.get = function*() { + this.locals.products = products; + this.body = this.render('index'); +}; + +exports.post = function*() { + this.locals.products = products; + + var fewDaysAgo = new Date(); + fewDaysAgo.setDate( fewDaysAgo.getDate() - 3 ); + + var form = this.locals.form = { + email: this.request.body.email, + name: this.request.body.name, + product: this.request.body.product + }; + + var existingRequest = yield JbRequest.findOne({ + email: this.request.body.email, + product: this.request.body.product, + name: this.request.body.name, + created: { + $gt: fewDaysAgo + } + }).exec(); + + if (existingRequest) { + this.locals.error = ` +

    Вы уже делали запрос на этот продукт с этими данными менее чем 3 дня назад.

    +

    Если вы не получили ответа, напишите на orders@javascript.ru.

    `; + this.body = this.render('index'); + return; + } + + var jbRequest = new JbRequest({ + email: this.request.body.email, + product: this.request.body.product, + name: this.request.body.name + }); + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'mail'), + to: 'iliakan@gmail.com',//config.jb.email, + subject: "Запрос лицензии", + form: form + }); + + yield jbRequest.persist(); + + this.body = this.render('success'); + return; + } catch(e) { + if (e.name != 'ValidationError') throw e; + var errors = this.locals.errors = {}; + for(var key in e.errors) { + errors[key] = e.errors[key].message; + } + this.body = this.render('index'); + return; + } + +}; diff --git a/handlers/jb/index.js b/handlers/jb/index.js new file mode 100755 index 000000000..53e1523ce --- /dev/null +++ b/handlers/jb/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/jb', __dirname)); +}; + diff --git a/handlers/jb/models/jbGoStat.js b/handlers/jb/models/jbGoStat.js new file mode 100755 index 000000000..23f4daab6 --- /dev/null +++ b/handlers/jb/models/jbGoStat.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + ip: { + type: String + }, + referer: { + type: String + }, + cookie: { + type: String + }, + + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('JbGoStat', schema); diff --git a/handlers/jb/models/jbRequest.js b/handlers/jb/models/jbRequest.js new file mode 100755 index 000000000..81aa7bcca --- /dev/null +++ b/handlers/jb/models/jbRequest.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + product: { + type: String, + enum: { + values: ["WebStorm", "PhpStorm", "RubyMine", "IntelliJ IDEA", "ReSharper", "PyCharm", "AppCode", "CLion"], + message: 'Такой продукт недоступен' + }, + required: 'Укажите продукт' + }, + email: { + type: String, + required: 'Укажите email' + }, + name: { + type: String, + validate: [ + { + validator: function(value) { + return /^\s*[a-z]+\s*[a-z]+\s*$/i.test(value); + }, + msg: 'Имя и фамилия должны быть на английском, например: ILYA KANTOR' + } + ], + required: 'Укажите имя и фамилию' + }, + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('JbRequest', schema); \ No newline at end of file diff --git a/handlers/jb/router.js b/handlers/jb/router.js new file mode 100755 index 000000000..ae6ac727f --- /dev/null +++ b/handlers/jb/router.js @@ -0,0 +1,23 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); +router.post('/', index.post); + +var JbGoStat = require('./models/jbGoStat'); +router.get('/go', function*() { + + yield JbGoStat.create({ + ip: this.request.ip, + referer: this.get('referer'), + cookie: this.get('cookie') + }); + + this.status = 301; + this.redirect('http://www.jetbrains.com/webstorm/?utm_source=javascript.ru&utm_medium=banner&utm_content=webstormge&utm_campaign=webstorm'); + //this.redirect('https://ad.doubleclick.net/ddm/jump/N3643.1915072JAVASCRIPT.RU/B8253346.111523141;sz=200x200;ord=[timestamp]?'); +}); + diff --git a/handlers/jb/templates/index.jade b/handlers/jb/templates/index.jade new file mode 100755 index 000000000..dc07b1d30 --- /dev/null +++ b/handlers/jb/templates/index.jade @@ -0,0 +1,60 @@ + +extends /layouts/main + +block append variables + - var sitetoolbar = true + - var title = "Jetbrains для участников курса" + - var layout_main_class = "main_width-limit" + - var layout_header_class = "main__header_center" + + - var content_class = '_center' + + +block content + + if error + +b.notification._message._error + +e.content!= error + +e('button').close(title="Закрыть") + + + p Участники курсов имеют 30% скидку на персональную лицензию почти для любого редактора от Jetbrains. + + +b("form").jetbrains-form(method="POST" action="/jb" name="jb") + input(type="hidden", name="_csrf", value=csrf()) + + p.note + | Совет: если вам нужен только JavaScript/HTML/CSS – выбирайте WebStorm. + br + | Если ещё какой-то язык – выберите из селекта ниже. + +e.form + +e.line + +e("label").label(for="name") Имя и фамилия (англ.): + +b("span")(class=['text-input', errors && errors.name && '_invalid']) + +e("input").control(type="text", pattern="^\\s*[a-zA-Z ]+\\s+[a-zA-Z ]+\\s*$", required, name="name", id="name", value=form && form.name, placeholder="John Smith" autofocus) + + +e.line + +e("label").label(for="email") Email: + +b("span")(class=['text-input', errors && errors.email && '_invalid']) + +e("input").control(type="email", name="email", id="email", required, value=form && form.email, placeholder="my@mail.com") + + +e.line + +e("label").label(for="product") Редактор: + +b('select').input-select._small(name="product" id="product") + each title, value in products + +e('option')(value=value selected=(form && form.product == value))= title + + +e.line + +b("button").button._action(type="submit") + +e("span").text Отправить + + script. + document.forms.jb.onsubmit = function() { + return confirm('Проверьте запрос на лицензию:' + + '\nРедактор: ' + this.elements.product.value + + '\nEmail: ' + this.elements.email.value + + '\nИмя: ' + this.elements.name.value + + '\nВсё верно, отправлять?'); + }; + + diff --git a/handlers/jb/templates/mail.jade b/handlers/jb/templates/mail.jade new file mode 100755 index 000000000..6f3b63413 --- /dev/null +++ b/handlers/jb/templates/mail.jade @@ -0,0 +1,9 @@ +extends /layouts/email + +block body + h2 Запрос скидки от javascript.ru + + p Продукт: #{form.product} + p Email: #{form.email} + p Имя: #{form.name} + diff --git a/handlers/jb/templates/success.jade b/handlers/jb/templates/success.jade new file mode 100755 index 000000000..0a9a43d1e --- /dev/null +++ b/handlers/jb/templates/success.jade @@ -0,0 +1,15 @@ + +extends /layouts/main + +block append variables + - var sitetoolbar = true + - var title = "Запрос отправлен!" + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + p Ссылка на покупку со скидкой придёт на указанный вами email. + p По вопросам пишите orders@javascript.ru. + diff --git a/handlers/lastActivity.js b/handlers/lastActivity.js new file mode 100755 index 000000000..b19166b53 --- /dev/null +++ b/handlers/lastActivity.js @@ -0,0 +1,17 @@ +exports.init = function(app) { + + app.use(function* saveLastUserActivityOncePerMinute(next) { + + var minuteAgo = new Date(); + minuteAgo.setMinutes(minuteAgo.getMinutes() - 1); + + if (this.user && (!this.user.lastActivity || this.user.lastActivity < minuteAgo)) { + this.user.lastActivity = new Date(); + yield this.user.persist(); + } + + yield* next; + }); + +}; + diff --git a/handlers/mailer/controllers/webhook.js b/handlers/mailer/controllers/webhook.js new file mode 100755 index 000000000..668a238dc --- /dev/null +++ b/handlers/mailer/controllers/webhook.js @@ -0,0 +1,49 @@ +var path = require('path'); +var MandrillEvent = require('../models/mandrillEvent'); +var config = require('config'); +var crypto = require('crypto'); +var capitalizeKeys = require('lib/capitalizeKeys'); + +exports.post = function*() { + + var signature = this.get('X-Mandrill-Signature'); + + if (generateMandrillSignature(this.request.body) != signature) { + this.throw(401, "Wrong signature"); + } + + var mandrillEvents; + try { + mandrillEvents = JSON.parse(this.request.body.mandrill_events); + } catch(e) {} + + if (!Array.isArray(mandrillEvents)) { + this.throw(400); + } + + mandrillEvents = capitalizeKeys(mandrillEvents); + + for (var i = 0; i < mandrillEvents.length; i++) { + var event = mandrillEvents[i]; + yield MandrillEvent.create({payload: event}); + } + + this.body = ''; +}; + + +function generateMandrillSignature(body) { + var signedData = config.mailer.mandrill.webhookUrl; + + var keys = Object.keys(body); + + keys.sort(); + + for (var i = 0; i < keys.length; i++) { + signedData += keys[i] + body[keys[i]]; + } + + return crypto.createHmac('sha1', config.mailer.mandrill.webhookKey).update(signedData, 'utf8', 'binary').digest('base64'); +} + + diff --git a/handlers/mailer/index.js b/handlers/mailer/index.js new file mode 100755 index 000000000..658279357 --- /dev/null +++ b/handlers/mailer/index.js @@ -0,0 +1,129 @@ +var inlineCss = require('./inlineCss'); +var config = require('config'); +var fs = require('fs'); +var path = require('path'); +var _ = require('lodash'); +var jade = require('lib/serverJade'); +var mandrill = require('./mandrill'); +var logoBase64 = fs.readFileSync(path.join(config.projectRoot, 'assets/img/logo.png')).toString('base64'); +var log = require('log')(); +var Letter = require('./models/letter'); +var capitalizeKeys = require('lib/capitalizeKeys'); + +// some clients don't allow svg +// var logoSrc = yield fs.readFile(path.join(config.projectRoot, 'assets/img/logo.svg')); + +// not middleware, cause can be used in CRON-based runs, from onPaid callback +// mail can be sent outside of request context + +/** + * create & save a letter object + * we save it to db to track delivery status + * + * Doesn't send the letter + * Can use to send it letter + * @param options + * @returns {Letter} + */ +function* createLetter(options) { + var message = {}; + + var sender = config.mailer.senders[options.from || 'default']; + if (!sender) { + throw new Error("Unknown sender:" + options.from); + } + + var locals = Object.create(options); + _.assign(locals, config.jade); + + locals.logoBase64 = logoBase64; + locals.signature = sender.signature; + + var templatePath = options.templatePath; + if (!templatePath.endsWith('.jade')) templatePath += '.jade'; + + var letterHtml = jade.renderFile(templatePath, locals); + letterHtml = yield inlineCss(letterHtml); + + message.html = letterHtml; + message.subject = options.subject; + message.from_email = sender.fromEmail; + message.from_name = sender.fromName; + + message.to = (typeof options.to == 'string') ? [{email: options.to}] : options.to; + + for (var i = 0; i < message.to.length; i++) { + var recepient = message.to[i]; + if (!recepient.email) { + throw new Error("No email for recepient:" + recepient + " message options:" + JSON.stringify(options)); + } + } + + message.headers = options.headers; + + // auto generate text by default (spamassassin wants that) + message.auto_text = "auto_text" in options ? options.auto_text : true; + + message.track_opens = options.track_opens; + message.track_clicks = options.track_clicks; + + var letter = yield Letter.create({ + message: message, + labelId: options.labelId, + label: options.label + }); + + return letter; +} + +/** + * A shortcut to send a letter + * E.g send({to: ..., subject: ..., templatePath: ... }) + * @param options + * @returns {*} + */ +function* send(options) { + var letter = yield* createLetter(options); + + return yield* sendLetter(letter); +} + +/** + * Send an existing letter + * @param letter + * @returns {*} + */ +function* sendLetter(letter) { + + if (process.env.NODE_ENV == 'test' || process.env.MAILER_DISABLED) { + letter.transportResponse = []; + } else { + letter.transportResponse = yield mandrill.messages.send({ + message: letter.message + }); + + letter.transportResponse = capitalizeKeys(letter.transportResponse); + } + + letter.sent = true; + + log.debug("sent ", letter.toObject()); + + yield letter.persist(); + + return letter; +} + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.verboseLogger.logPaths.add('/mailer/:any*'); + app.use(mountHandlerMiddleware('/mailer', __dirname)); +}; + + +exports.Letter = require('./models/letter'); +exports.send = send; +exports.createLetter = createLetter; +exports.sendLetter = sendLetter; diff --git a/handlers/mailer/inlineCss.js b/handlers/mailer/inlineCss.js new file mode 100755 index 000000000..56b7b529c --- /dev/null +++ b/handlers/mailer/inlineCss.js @@ -0,0 +1,15 @@ +var fs = require('fs'); + +// juice does not work w/ node 0.11 + +var Styliner = require('styliner'); + +var path = require('path'); + +module.exports = function*(html) { + + var styliner = new Styliner('.', { compact: false }); + var result = yield styliner.processHTML(html, '.'); + + return result; +}; diff --git a/handlers/mailer/mandrill.js b/handlers/mailer/mandrill.js new file mode 100755 index 000000000..518da475b --- /dev/null +++ b/handlers/mailer/mandrill.js @@ -0,0 +1,35 @@ +var config = require('config'); +var mandrill = require('mandrill-api/mandrill'); + +var mandrillClient = new mandrill.Mandrill(config.mailer.mandrill.apiKey); + +//console.log(require('util').inspect(mandrillClient)); + +for(var key in mandrillClient) { + if (mandrillClient[key].master) { + promisifyMandrillApi(mandrillClient[key]); + } +} + +function promisifyMandrillApi(api) { + for(var key in Object.getPrototypeOf(api)) { + var value = api[key]; + + if (typeof value != 'function') { + return; + } + + promisifyMandrillApiMethod(api, key); + } +} + +function promisifyMandrillApiMethod(api, methodName) { + var prev = api[methodName]; + api[methodName] = function(opts) { + return new Promise(function(resolve, reject) { + prev.call(api, opts, resolve, reject); + }); + }; +} + +module.exports = mandrillClient; \ No newline at end of file diff --git a/handlers/mailer/models/letter.js b/handlers/mailer/models/letter.js new file mode 100755 index 000000000..c80e49177 --- /dev/null +++ b/handlers/mailer/models/letter.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + sent: { + type: Boolean, + required: true, + index: true, + default: false + }, + created: { + type: Date, + index: true, + default: Date.now + }, + + // add a label to search through db for sent messages + // e.g can send letters for the same label to those who didn't receive it + label: { + type: String, + index: true + }, + + // or you can label with objectId + // e.g NewsletterRelease + labelId: { + type: Schema.Types.ObjectId, + index: true + }, + + message: {}, + + // Transport responds with that + transportResponse: {} +}); + +schema.index({ 'message.to': 1 }); +var Letter = module.exports = mongoose.model('Letter', schema); diff --git a/handlers/mailer/models/mandrillEvent.js b/handlers/mailer/models/mandrillEvent.js new file mode 100755 index 000000000..8d355cf2f --- /dev/null +++ b/handlers/mailer/models/mandrillEvent.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + created: { + type: Date, + default: Date.now + }, + + // freeform + // so that any changes in the schema will not affect the store + payload: {} +}); + +module.exports = mongoose.model('MandrillEvent', schema); \ No newline at end of file diff --git a/handlers/mailer/router.js b/handlers/mailer/router.js new file mode 100755 index 000000000..f8322e944 --- /dev/null +++ b/handlers/mailer/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var webhook = require('./controllers/webhook'); + +var router = module.exports = new Router(); + +router.post('/webhook', webhook.post); diff --git a/handlers/markup/controller/markup.js b/handlers/markup/controller/markup.js new file mode 100755 index 000000000..82660e933 --- /dev/null +++ b/handlers/markup/controller/markup.js @@ -0,0 +1,16 @@ +var join = require('path').join; +var fs = require('fs'); +var path = require('path'); + +exports.get = function *get(next) { + var templatePath = this.params.path; + + var fullPath = path.join(this.templateDir, templatePath) + '.jade'; + + if (!fs.existsSync(fullPath)) { + this.throw(404); + } + + this.body = this.render(templatePath); +}; + diff --git a/handlers/markup/index.js b/handlers/markup/index.js new file mode 100755 index 000000000..3a5944c8b --- /dev/null +++ b/handlers/markup/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/markup', __dirname) ); +}; + diff --git a/handlers/markup/router.js b/handlers/markup/router.js new file mode 100755 index 000000000..243ae7a74 --- /dev/null +++ b/handlers/markup/router.js @@ -0,0 +1,8 @@ +var Router = require('koa-router'); + +var markup = require('./controller/markup'); + +var router = module.exports = new Router(); + +router.get("/:path*", markup.get); + diff --git a/handlers/markup/templates/blocks/article-foot.jade b/handlers/markup/templates/blocks/article-foot.jade new file mode 100755 index 000000000..7e49b8ee6 --- /dev/null +++ b/handlers/markup/templates/blocks/article-foot.jade @@ -0,0 +1,5 @@ +footer.main__footer + time.main__footer-date(datetime="2010-12-03") 12.03.2010 + span.main__footer-author + a(href="http://ikantor.moikrug.ru") Илья Кантор + span.main__footer-star diff --git a/handlers/markup/templates/blocks/balance-single.jade b/handlers/markup/templates/blocks/balance-single.jade new file mode 100755 index 000000000..ae41b7bbc --- /dev/null +++ b/handlers/markup/templates/blocks/balance-single.jade @@ -0,0 +1,21 @@ ++b.balance._single + +e.pluses + +e.content + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. + ++b.balance._single + +e.minuses + +e.content + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. diff --git a/handlers/markup/templates/blocks/balance.jade b/handlers/markup/templates/blocks/balance.jade new file mode 100755 index 000000000..59ea984aa --- /dev/null +++ b/handlers/markup/templates/blocks/balance.jade @@ -0,0 +1,21 @@ ++b.balance + +e.pluses + +e.content + +e('h3').title Достоинства + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. + +e.minuses + +e.content + +e('h3').title Недостатки + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. \ No newline at end of file diff --git a/handlers/markup/templates/blocks/breadcrumbs.jade b/handlers/markup/templates/blocks/breadcrumbs.jade new file mode 100755 index 000000000..f7f6631cf --- /dev/null +++ b/handlers/markup/templates/blocks/breadcrumbs.jade @@ -0,0 +1,11 @@ ++b('ol').breadcrumbs + +e('li').item._home + +e('a').link(href='/') + //- добавляем элемент hidden-text, чтобы при отключенных стилях ссылка была доступна + +e('span').hidden-text Главная + +e('li').item + +e('a').link(href='/tutorial') Учебник + +e('li').item + +e('a').link(href='/getting-started') Язык JavaScript + +e('li').item + | основы JavaScript \ No newline at end of file diff --git a/handlers/markup/templates/blocks/code-tabs-code.html b/handlers/markup/templates/blocks/code-tabs-code.html new file mode 100755 index 000000000..26d30d193 --- /dev/null +++ b/handlers/markup/templates/blocks/code-tabs-code.html @@ -0,0 +1,49 @@ +
    <!DOCTYPE HTML>
    +<html>
    +  <head>
    +    <meta charset="utf-8">
    +    <link rel="stylesheet" href="style.css">
    +  </head>
    +  <body>
    +  
    +        
    +    <table id="table">
    +      <tr>
    +        <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
    +      </tr>
    +      <tr>
    +        <td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders
    +        </td>
    +        <td class="n"><strong>North</strong><br>Water<br>Blue<br>Change
    +        </td>
    +        <td class="ne"><strong>Northeast</strong><br>Earth<br>Yellow<br>Direction
    +        </td>
    +      </tr>
    +      <tr>  
    +          <td class="w"><strong>West</strong><br>Metal<br>Gold<br>Youth
    +        </td>
    +        <td class="c"><strong>Center</strong><br>All<br>Purple<br>Harmony
    +        </td>
    +        <td class="e"><strong>East</strong><br>Wood<br>Blue<br>Future
    +        </td>
    +      </tr>
    +      <tr>  
    +          <td class="sw"><strong>Southwest</strong><br>Earth<br>Brown<br>Tranquility
    +        </td>
    +        <td class="s"><strong>South</strong><br>Fire<br>Orange<br>Fame
    +        </td>
    +        <td class="se"><strong>Southeast</strong><br>Wood<br>Green<br>Romance
    +        </td>
    +      </tr>
    +      
    +    </table>
    +
    +    <textarea id="text"></textarea>
    +
    +    <input type="button" onclick="text.value=''" value="Очистить">
    +
    +        
    +    <script src="script.js"></script>
    +    
    +  </body>
    +</html>
    \ No newline at end of file diff --git a/handlers/markup/templates/blocks/code-tabs.jade b/handlers/markup/templates/blocks/code-tabs.jade new file mode 100755 index 000000000..ce29fea8f --- /dev/null +++ b/handlers/markup/templates/blocks/code-tabs.jade @@ -0,0 +1,69 @@ ++b.code-tabs._scroll._result_on + +e.tools + +e.scroll-wrap + +e('button').scroll-button._left(disabled="disabled") + +e.switches-wrap + +e.switches + +e.switches-items + +e.switch._current Результат + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.scroll-wrap + +e('button').scroll-button._right + +e.buttons + +e('a').button._download(target="_blank", href="file.zip") + +e('a').button._open(target="_blank", href="/play/file") + +e.content(style="height: 250px;") + +e.section._current + +e('iframe').result(src="http://lipsum.com/") + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + ++b.code-tabs._scroll + +e.tools + +e.scroll-wrap + +e('button').scroll-button._left(disabled="disabled") + +e.switches-wrap + +e.switches + +e.switches-items + +e.switch Результат + +e.switch._current index.html + +e.switch style.css + +e.switch example.js + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.scroll-wrap + +e('button').scroll-button._right + +e.buttons + +e('a').button._download(target="_blank", href="file.zip") + +e('a').button._open(target="_blank", href="/play/file") + +e.content(style="height: 350px;") + +e.section + +e('iframe').result(src="http://lipsum.com/") + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section._current + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html diff --git a/handlers/markup/templates/blocks/comments.jade b/handlers/markup/templates/blocks/comments.jade new file mode 100755 index 000000000..613799d41 --- /dev/null +++ b/handlers/markup/templates/blocks/comments.jade @@ -0,0 +1,30 @@ +.comments#comments + .comments__header + h2.comments__header-title + | Комментарии + span.comments__header-number 5 + a.comments__header-write(href="#write-comment") Написать + ul + li Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них. + li Если ваш комментарий касается задачи — откройте её в отдельном окне и напишите там. + li + | Для кода внутри строки используйте тег + <code> + | , для блока кода — тег + <pre> + | , если больше 10 строк — ссылку на + песочницу + | . + li Если что-то непонятно — пишите, что именно и с какого места. +//-
    +//-
    +//-

    Комментарии 5

    +//- Написать +//-
      +//-
    • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
    • +//-
    • Если ваш комментарий касается задачи — откройте её в отдельном окне и напишите там.
    • +//-
    • Для кода внутри строки используйте тег <code>, для блока кода — тег <pre>, если больше 10 строк — ссылку на песочницу.
    • +//-
    • Если что-то непонятно — пишите, что именно и с какого места.
    • +//-
    +//-
    +//-
    \ No newline at end of file diff --git a/handlers/markup/templates/blocks/corrector.jade b/handlers/markup/templates/blocks/corrector.jade new file mode 100755 index 000000000..2d5143b0f --- /dev/null +++ b/handlers/markup/templates/blocks/corrector.jade @@ -0,0 +1,2 @@ +.corrector + | Нашли опечатку на сайте? Что-то кажется странным? Выделите соответствующий текст и нажмите Ctrl+Enter \ No newline at end of file diff --git a/handlers/markup/templates/blocks/courses-faq.jade b/handlers/markup/templates/blocks/courses-faq.jade new file mode 100644 index 000000000..30e1aa27e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-faq.jade @@ -0,0 +1,18 @@ +- var questions = []; +- questions.push({ title: 'А это все правда? Действительно ли курсы такие хорошие?', answer: ['

    Вам решать.

    Здесь нет курсов по HTML/CSS/PHP/Photoshop и прочему разному.

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

    '] }); +- questions.push({ title: 'Какие есть способы оплаты? Можно ли от организации?', answer: ['

    Вам решать.

    Здесь нет курсов по HTML/CSS/PHP/Photoshop и прочему разному.

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

    '] }); + ++b.courses-faq.courses-mix + +e('h2').title Часто задаваемые вопросы + +e.body + + +e('ul').questions + for question, index in questions + +e('li').question + +e('input').input(type="checkbox" id= 'q' + index) + +e('label').question-title(for= 'q' + index) !{ question.title } + +e.answer !{ question.answer } + + p У вас другой вопрос? Напишите его в комментариях внизу этой страницы. Если он может быть полезен другим участникам — я его оставлю, если нет — отвечу и через месяц после своего ответа удалю. + p Для быстрой связи можно также писать мне на email: mk@javascript.ru (проверяется регулярно), а если совсем срочно — звонить по телефону +7-903-5419441. + diff --git a/handlers/markup/templates/blocks/courses-features.jade b/handlers/markup/templates/blocks/courses-features.jade new file mode 100644 index 000000000..0fc68cc54 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-features.jade @@ -0,0 +1,16 @@ +- var features = []; + +- features.push({ name: 'quality', title: 'Качество', text: 'Это самое главное. Мы изучаем разработку на профессиональном уровне' }); +- features.push({ name: 'online', title: 'Дистанционность', text: 'На практике это оказывается удобнее, чем очные курсы' }); +- features.push({ name: 'support', title: 'Поддержка', text: 'Вы получите советы по развитию именно для вас' }); +- features.push({ name: 'result', title: 'Результат', text: 'Цель курсов - получить конкретные результаты в плане знаний и умений' }); +- features.push({ name: 'guarantees', title: 'Гарантии', text: 'Отзывы выпускников говорят сами за себя' }); + ++b.courses-features + +e('h2').title Особенности курсов + +e('ul').features + for feature in features + +e('li')(class=['feature', '_' + feature.name]) + +e('h3').feature-title !{ feature.title } + +e('p').feature-text !{ feature.text } + diff --git a/handlers/markup/templates/blocks/courses-guarantee.jade b/handlers/markup/templates/blocks/courses-guarantee.jade new file mode 100644 index 000000000..0d0e0f09e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-guarantee.jade @@ -0,0 +1,18 @@ ++b.courses-guarantee.courses-mix + h2 Гарантии + + p Всем участникам курсов, независимо от пола, возраста, ориентации и религиозной принадлежности… + + +e('p').list_wrap Если объяснения будут вам непонятны + + ul + li + strong Если объяснения будут вам непонятны + li + strong Если курсы не дадут вам новых знаний и умений + li + strong Если вы не сможете подключиться к системе онлайн-обучения + + +e('p').list_wrap … то вы сможете получить деньги назад. + + p Для этого достаточно не позже окончания первой недели курса написать мне, указать причину из этого списка и что именно вас не устраивает, удостоверить свою личность, чтобы возврат не потребовал хакер, и тогда ваше участие будет прекращено, а вы получите ваши деньги обратно, удобным для вас способом. diff --git a/handlers/markup/templates/blocks/courses-how.jade b/handlers/markup/templates/blocks/courses-how.jade new file mode 100644 index 000000000..c047f226e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-how.jade @@ -0,0 +1,21 @@ ++b.courses-how.courses-mix + +e('h2').title Как проходит обучение? + + +e.body + p Время обучения: 2 месяца, включая одну неделю каникул с самостоятельно выполняемым заданием, плюс видеокурс за неделю до начала занятий. + + p За это время мы планируем освоить очень многое. + + p Это подразумевает не ленивое ковыряние в носу во время лекции, а довольно-таки активный режим обучения. + + ol + li До начала курса вы получаете вводный видео-курс. + p К основному курсу необходимо с ним ознакомиться. Там раскрыты самые базовые темы, которые можно дать в таком формате. Это введение нужно, чтобы мы на занятиях не разбирали ну уж совсем простые темы (но вы сможете задавать вопросы по ним, если будут, в том числе и до начала курса). + + li Далее, к каждому занятию выдаются материалы для освоения и задачи. Если это текст - читаете, если видео - смотрите в удобное для вас время. Делаете задачи. + li Мы встречаемся два раза в неделю онлайн, я рассказываю важные и тонкие моменты, на которые следует обратить внимание в материале (простые вы изучили по лекциям дома), вы задаете вопросы, показываете решения. Мы смотрим, как можно сделать лучше. Продолжительность 1.5 часа, может быть меньше или больше, в зависимости от темы и количества вопросов. + + p + strong Резюмирую: будьте готовы к тому, что придётся учиться и делать реальные задачи, многие из которых не так уж просты. + + diff --git a/handlers/markup/templates/blocks/courses-master.jade b/handlers/markup/templates/blocks/courses-master.jade new file mode 100644 index 000000000..173198f77 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-master.jade @@ -0,0 +1,8 @@ ++b.courses-master + +e('h2').title Ведущий + +e('p').text Веду курсы я сам, Илья Кантор, создатель этого сайта, frontend-разработчик с большим стажем, вот немного обо мне. + +e('p').text Начиная с 2007 года вёл мастер-классы для опытных разработчиков, в которых участвовали, в том числе, сотрудники ведущих IT-компаний России и Украины. Информацию о них вы можете найти здесь. + +e('p').text С января 2011 года открыты эти курсы. + + + diff --git a/handlers/markup/templates/blocks/courses-materials.jade b/handlers/markup/templates/blocks/courses-materials.jade new file mode 100644 index 000000000..a0ecfe422 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-materials.jade @@ -0,0 +1,21 @@ +- var data = []; +- data.push({ url: '/123', name: 'Вводный курс JS', size: '345 Mb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 08 22 2130', size: '1.3 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 09 22 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 09 26 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + ++b.courses-materials + +e('table').table + +e('tr').line + +e('th').num # + +e('th').name Название + +e('th').size Размер + +e('th').added Добавлено + for material in data + +e('tr').line + +e('td').num + +e('td').name + +e('a').link(href=material.url) !{ material.name } + +e('td').size !{ material.size } + +e('td').added !{ material.date } + diff --git a/handlers/markup/templates/blocks/courses-parts.jade b/handlers/markup/templates/blocks/courses-parts.jade new file mode 100644 index 000000000..5ec363c3e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-parts.jade @@ -0,0 +1,87 @@ ++b.courses-parts.courses-mix + +e('h2').title Основные темы программы + + +b.tabbed-pane._01 + + +e('ul').tabs + +e('li').tab._01 Первая часть курса + +e('li').tab._02 Вторая часть курса + +e('li').tab._03 Третья часть курса + + +e.body._01 + + +e('h2').title.phone-only Первая часть курса + + ol + li + strong Основной JavaScript. + p Здесь мы изучим сам язык, его конструкции и особенности, которые позволяют "разговаривать" на JavaScript коротко, понятно, а главное - без ошибок. + ul + li IDE, настройка, полезные приёмы использования, средства для автопроверки кода. + li Основные структуры данных, работа с числами, строками, датами, массивами, объектами. + li Инструменты разработки, отладка в браузерах. + li Автоматизированное тестирование, инструменты и их применение. + li + strong Более глубокое понимание языка. + p Чтобы писать хороший код, а также грамотно пользоваться современными фреймворками, мы изучим JavaScript лучше, включая тонкости и продвинутое применение языковых конструкций. + ul + li Замыкания и их грамотное применение. + li Внутреннее устройство движка JavaScript. + li Контекст this в деталях. + li Форвардинг, одалживание и делегирование функций. + li Прототипы, классы, прототипное и функциональное ООП, детали использования. + + p По окончанию первой части курса вы свободно пользуетесь языком JavaScript, с учётом его особенностей. Мы улучшим эти навыки в последующих частях курса. + + +e.body._02 + + +e('h2').title.phone-only Вторая часть курса + + ol + li + strong Документ, генерация интерфейса. + p Здесь мы учимся работать с документом, решать всевозможные задачи в браузере. + ul + li Внутреннее устройство браузера, оптимальная организация страницы со скриптами. + li Дерево DOM, особенности разработки в современных браузерах с отмирающей, но иногда нужной поддержкой старых. + li Динамическая генерация интерфейса - методы DOM, их грамотное использование. + li + strong События, взаимодействие с посетителем. + ul + li Основы и тонкости работы с различными событиями для решения основных интерфейсных задач. + li Drag'n'Drop, по окну и внутри элемента + li Паттерн "делегирование", оптимизация производительности и архитектуры, чтобы интерфейсы не тормозили. + li Объектно-ориентированная разработка, компонентная архитектура с использованием ООП, событий и DOM. + + p По окончании второй части вы можете создавать интерфейсные компоненты, но нужно больше практики. + + +e.body._03 + + +e('h2').title.phone-only Третья часть курса + + ol + li Фреймворк jQuery, его важные тонкости и правильное использование. + li Архитектура сложных интерфейсов. + li Node.JS как средство запуска полезных утилит. + li Шаблонизация, организация шаблонов и кода в файлах, автоматизированная сборка проекта. + li Обзор AJAX-технологий и фреймворков (Backbone/Marionette, Angular.JS, React.js), куда двигаться дальше. + li В результате окончания третьей части вы, если конечно делали домашнее задание все это время, можете создать и поддерживать современный JS-проект и понимаете, как развиваться далее. + + p На практике эти части не так чтобы резко отделены друг от друга, переход между ними плавный. Продвинутые темы используют элементы предыдущих. + + script. + var className = 'tabbed-pane', + block = document.querySelector('.' + className); + + block + .querySelector('.' + className + '__tabs') + .addEventListener('click', function(e) { + + block.className = className + ' ' + + className + '_' + + e.target.className.split('_').pop(); + + }); + + + diff --git a/handlers/markup/templates/blocks/courses-professionals.jade b/handlers/markup/templates/blocks/courses-professionals.jade new file mode 100644 index 000000000..dad0764ba --- /dev/null +++ b/handlers/markup/templates/blocks/courses-professionals.jade @@ -0,0 +1,22 @@ ++b.courses-professionals.courses-mix + +e('section').feedbacks + +e('h2').title Мнение профессионалов + + +e('article').feedback + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg" width="86" height="86") + +e('h3').feedback-title Константин Профессионалов + +e('a').homepage(src="/123") LinkedIn + +e('p').about Разработчик с огромным опытом бла бла. Принимал участие в таки проектах, как YouTube, LiveJournal + + p Хороший, детальный курс по Javascript. Далеко-далеко за словесными горами в стране гласных и согласных живут рыбные тексты. Вдали от всех живут они в буквенных домах на берегу Семантика большого языкового океана. + + +e('article').feedback + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg" width="86" height="86") + +e('h3').feedback-title Константин Профессионалов + +e('a').homepage(src="/123") LinkedIn + +e('p').about Разработчик с огромным опытом бла бла. Принимал участие в таки проектах, как YouTube, LiveJournal + + p Хороший, детальный курс по Javascript. Далеко-далеко за словесными горами в стране гласных и согласных живут рыбные тексты. Вдали от всех живут они в буквенных домах на берегу Семантика большого языкового океана. + diff --git a/handlers/markup/templates/blocks/courses-programm-and-register.jade b/handlers/markup/templates/blocks/courses-programm-and-register.jade new file mode 100644 index 000000000..3b035cef9 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-programm-and-register.jade @@ -0,0 +1,95 @@ ++b.course-info._program.courses-mix + +e.body.columns.columns_2 + + +e.col.columns__col + +e.content + +e('h3').title Программа + p Курс состоит из трёх частей: + ol + li + +e.text Первая часть позволяет хорошо разобраться в языке JavaScript, получить знания и навыки написания хорошего JavaScript-кода. + li + +e.text Вторая часть позволяет научиться работать со страницей и посетителем, создавать меню, слайдеры, Drag’n’Drop и прочие интерфейсные компоненты. + li + +e.text Третья часть посвящена более сложным интерфейсам. На ней мы изучаем, как построить архитектуру, взаимодействие между компонентами, как при помощи шаблонов и Require.JS организовать код, грамотно используем jQuery. + + p Большое внимание на этом курсе уделяется стилю кода. Это важно. Хороший стиль кода позволяет писать более быстро, красиво и делать меньше ошибок. А на серьёзных проектах он просто необходим. + + +e.col.columns__col + +e.content + +e('h3').title Набор в группы + + +b.courses-recruitment + +e('a').anchor#signup + +e('ul').list + +e('li').course + +e.info + +e('h4').title 15 Мар 2014 — 15 Май 2014 + +e('p').text + | Занятия каждый Пн/Чт + br + | 19:30 - 21:00 GMT+3 (Мск). + + +e.apply + +b.price + +e('span') 24000 RUB + +e('span').secondary + |  ≈ 450$ + +e.submit + +b('button').button._action + +e('span').text Записаться + + +e('li').course + +e.info + +e('h4').title 15 Мар 2014 — 15 Май 2014 + +e('p').text + | Занятия каждый Пн/Чт + br + | 19:30 - 21:00 GMT+3 (Мск). + + +e.apply + +b.price + +e('span') 24000 RUB + +e('span').secondary + |  ≈ 450$ + +e.submit + +b('button').button._action + +e('span').text Записаться + + p В цену входит 2 месяца обучения, включая одну неделю каникул с самостоятельно выполняемым заданием и организационное собрание. Также участники получают вводный видеокурс за неделю до начала занятий. + p Вы можете подписаться на уведомления по набору новых групп по этой программе: + + +b.text-input-button + +e.input + +b.text-input._invalid + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err тест ошибки + +e.button + +b('button').button._common + +e('span').text Подписаться + + p(style="font-size: 13px; color: #999") На ваш email придёт письмо с информацией о дате, деталях курсов и ссылка на запись. + + + // #589 Courses / Program / No Groups + + +b.courses-recruitment._no-groups + +e('a').anchor#signup + +e('h3').info-title В данный момент информация о наборе в группы отсутствует + p Вы можете подписаться на уведомления по набору новых групп по этой программе: + +b.text-input-button + +e.input + +b.text-input._invalid + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err тест ошибки + +e.button + +b('button').button._action + +e('span').text Подписаться + + p(style="font-size: 13px; color: #999") На ваш email придёт письмо с информацией о дате, деталях курсов и ссылка на запись. + + p В цену входит 2 месяца обучения, включая одну неделю каникул с самостоятельно выполняемым заданием и организационное собрание. Также участники получают вводный видеокурс за неделю до начала занятий. + p Вы можете подписаться на уведомления по набору новых групп по этой программе: + + + diff --git a/handlers/markup/templates/blocks/courses-programm-register.jade b/handlers/markup/templates/blocks/courses-programm-register.jade new file mode 100644 index 000000000..0403737f4 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-programm-register.jade @@ -0,0 +1,15 @@ +- var courses = [] +- courses.push({ isOpen: true, title: 'Javascript, DOM, интерфейсы', link: '/123', text: 'В первую очередь это курс для тех, кто не разрабатывал на JS, либо разрабатывал эпизодически и теперь хочет освоить профессионально' }) +- courses.push({ isOpen: false, title: 'Javascript, DOM, интерфейсы', link: '/123', text: 'В первую очередь это курс для тех, кто не разрабатывал на JS, либо разрабатывал эпизодически и теперь хочет освоить профессионально' }) + ++b.courses-programm-register + +e('a').anchor#courses + +e('h2').title Программа курсов и запись + +e('ul').courses + for course in courses + +e('li').course + +e('h3').course-title + +b('a').link(href=course.link) !{ course.title } + if course.isOpen + +e('span').course-badge Идет набор в группы + +e('p').course-text !{ course.text } diff --git a/handlers/markup/templates/blocks/courses-register.jade b/handlers/markup/templates/blocks/courses-register.jade new file mode 100644 index 000000000..6a326ad04 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-register.jade @@ -0,0 +1,262 @@ +// mods: '_step_1', '_step_2', '_step_3', '_step_4' +// _step_1 Participants +// _step_2 Contacts +// _step_3 Pyament +// _step_3 Result + ++b.courses-register._step_1 + + +b.receipts._register + + +e.receipt._step_1 + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Посещение курсов для 10 человек + +b.course-register-info + +e('p').info._length + +e('time')(datetime="2014-03-15 17:00").time 15 Мар 2014 + | — + +e('time')(datetime="2014-05-15 17:00").time 15 Май 2014 + +e('p').info + | Каждый Пн и Ср в  + +e('time').time 17:00  + | (UTC+4) + + +e.receipt-aside + +e.price + +b('span').price 2400 RUB + +e('a').edit(href="/123") + + +e.receipt._step_2 + +e.receipt-body + +e.receipt-content + +e.type Контактная информация: + +e.title Александр Сергеевич Константинопольский + +e.receipt-aside._center + +e('span').title +7 495 926-22-23 + +e('a').edit(href="/123") + + +e.receipt._step_3 + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e.pay-method_paypal + + + // Participants + + +b('form').complex-form._step_1 + +e.step._current + +b.courses-register-participants.courses-register-common + +e('h2').title.courses-register-common__title Места и участники + + +b.course-register-info + +e('h2').group Группа 10 + +e('p').info._length + +e('time')(datetime="2014-03-15 17:00").time 15 Мар 2014 + | — + +e('time')(datetime="2014-05-15 17:00").time 15 Май 2014 + +e('p').info + | Каждый Пн и Ср в  + +e('time').time 17:00  + | (UTC+4) + + +b.course-register-settings + +e.number.course-register-settings__cell + +e('h3').title Количество мест + +e.body + +b.number-input + +e('button')(disabled).btn._dec − + +b.text-input._small.__text._invalid + +e('input')(type="number" value="1").control.__input + +e('span').err Укажите поменьше людей + +e('button').btn._inc + + + +e.is-participant.course-register-settings__cell + +e('h3').title Я являюсь участником + +e.body + +b.switch-input + +e('input').checkbox#request-participant(type='checkbox') + +e('i').bg + +e('label').label(for="request-participant") + +e('span').off НЕТ + +e('span').on ДА + + +e.price.course-register-settings__cell + +e('h3').title Стоимость + +e.body + +b.price 2400 RUB + +e('span').secondary (≈ 200$) + + +e.add-participants + +b.course-add-participants._visible + +e('input')(type="checkbox" id="add-participants").checkbox + +e('label').add(for="add-participants") Укзать участников + +e('p').note (это можно сделать позже) + +e.dropdown + +e('label')(for="add-participants").dropdown-close.close-button + +e('ul').dropdown-list + - var n = 0 + while n < 6 + +b('li').course-add-participants-item + +e('label').participant + +e('span').participant-n Участник + +b('span').text-input + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err тест ошибки + - n++ + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + + // Contacts + + +b('form').complex-form._step_2 + +e.step._current + +b.course-register-contacts.courses-register-common + +e('h2').title.courses-register-common__title Контактная информация + +e('p').note + | Оставьте ваши контактные данные, чтобы мы могли связаться с вами + | в случае необходимости + + +e.body + +b.contact-form + +e.content + +e.fields + +e.name + label(for="contact-name") Имя и Фамилия: + +b.text-input._small.__name-input + +e('input').control#contact-name + +e.tel + label(for="contact-phone") Телефон: + +b.full-phone.__full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control#contact-phone(placeholder="+X (XXX) XXX-XX-XX") + +e.note + +e('h5').note-title Ваши данные в безопасности + p + | В соответствии с законом о защите личных данных, никакие ваши личные данные + | не будут переданы третьим лицам, кроме как по вашему желанию или для + | целей выполнения заключенного с вами договора. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + + // Pyament + + +b('form').complex-form._step_3 + +e.step._current + +b.course-register-payment.courses-register-common + +e('h2').title.courses-register-common__title Оплата + + +e.body + +b.pay-method + +e('ul').methods + + - var paymentMethods = []; + - paymentMethods.push({ name: 'yandexmoney', image: true}); + - paymentMethods.push({ name: 'webmoney', image: true }); + - paymentMethods.push({ name: 'paypal', image: true, settings: true }); + - paymentMethods.push({ name: 'payanyway', image: false, title: 'Payanyway', subtitle: 'и много других методов', cards: ['visa-mastercard'] }); + - paymentMethods.push({ name: 'banksimple', image: false, title: 'Банковсий перевод', subtitle: 'или другой банк', cards: ['sberbank']}); + - paymentMethods.push({ name: 'interkassa', image: false, title: 'Interkassa', subtitle: ' и другие методы для Украины', cards: ['privatbank'] }); + - paymentMethods.push({ name: 'invoice', image: false, title: 'Счет на компанию', subtitle: 'Для юрлиц из России', settings: true }); + + + each paymentMethod in paymentMethods + - var paymentMethod = paymentMethod + +e('li').method + + +e('input').method-radio(type="radio" name="paymentMethod" value=paymentMethod.name id=paymentMethod.name) + + +e('label')(class=["method-label", "_"+paymentMethod.name] for=paymentMethod.name) + +e('header').header + + if paymentMethod.title + +e('h3').method-title !{ paymentMethod.title } + if paymentMethod.cards + +e('span').cards + each card in paymentMethod.cards + +e('img').card(src='/pay-methods/' + card + '.svg', alt=card) + if paymentMethod.subtitle + +e('h4').method-subtitle !{ paymentMethod.subtitle } + + if paymentMethod.image + +e('img').logo(src="/pay-methods/pay-" + paymentMethod.name + '.svg' alt=paymentMethod.name.charAt(0).toUpperCase() + paymentMethod.name.slice(1)) + + if paymentMethod.settings + include payment-settings + + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Перейти к оплате + + p Если возникли какие-нибудь сложности, вы можеле оплатить заказ позже + + + // Result + + +b('form').complex-form._step_4 + +e.step._current + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title Спасибо за заказ! + +e('h3').title.courses-register-common__title В ближайшее время вам прийдет уведомление на электронный адрес + + +e.body + p + | Вы можете отредактировать детали своего заказа + | в любое время до начала проведения курсов в + | соответствующем разделе вашей учетной записи. + | В случаях каких-либо изменений мы обязательно с вами свяжемся. + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + +b.login-form(data-form="login").complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(action="#") + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" value="bla@bla.com" disabled) + +e.line + +e('label').label(for="auth-password") Пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e('button').aside.__forgot.__button-link(type="button" data-switch="forgot-form") Забыли? + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + + +b.login-form(data-form="login").complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(action="#") + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" value="bla@bla.com" disabled) + +e.line + +e('label').label(for="auth-login") Имя пользвателя: + +b('span').text-input.__input + +e('input').control#auth-login(name="login" type="text") + +e.line + +e('label').label(for="auth-password") Новый пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + + +b('ul').grayed-list + +e('li').item._step_2 Контактная информация + +e('li').item._step_3 Оплата + +e('li').item._step_4 Подтверждение + diff --git a/handlers/markup/templates/blocks/courses-result.jade b/handlers/markup/templates/blocks/courses-result.jade new file mode 100644 index 000000000..1d5470f5c --- /dev/null +++ b/handlers/markup/templates/blocks/courses-result.jade @@ -0,0 +1,11 @@ ++b.courses-result.courses-mix + +e('h2').title Результат обучения + + +e.body + ol + li Вы хорошо знаете JavaScript, свободно разрабатываете и отлаживаете программы на этом языке. + li Вы умеете организовать JavaScript-проект, шаблоны и стили в файлах на диске в удобную структуру, собирать и оптимально подключать их к странице. + li Ваши интерфейсы работают стабильно, без глюков, их можно удобно дорабатывать и развивать. + li Мы идём от основ и до довольно-таки сложных штук. Успешное прохождение обучения гарантировано в том случае, если вы будете регулярно заниматься и делать домашнее задание. + + diff --git a/handlers/markup/templates/blocks/courses-system-req.jade b/handlers/markup/templates/blocks/courses-system-req.jade new file mode 100644 index 000000000..d8fe9527b --- /dev/null +++ b/handlers/markup/templates/blocks/courses-system-req.jade @@ -0,0 +1,7 @@ ++b.courses-system-req.courses-mix + +e('h2').title Системные требования + + +e.body + p Для общения используются видео, аудио и чат. Если у вас есть гарнитура - вы сможете использовать её для вопросов, но это не обязательно. + + p Системные требования для общения онлайн - Windows/MacOS и скорость 256kbit+, для просмотра видео - Windows вне виртуальной машины. diff --git a/handlers/markup/templates/blocks/courses-tabbed-pane.jade b/handlers/markup/templates/blocks/courses-tabbed-pane.jade new file mode 100644 index 000000000..4b40c1493 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-tabbed-pane.jade @@ -0,0 +1,95 @@ ++b.tabbed-pane._01.courses-tabbed-pane + + +e('ul').tabs + +e('li').tab._01 Чем эти курсы отличаются от других? + +e('li').tab._02 Зачем курсы, когда есть книги и статьи на javascript.ru? + +e('li').tab._03 Зачем курсы, если можно научиться на работе? + + +e.body._01 + + +e('h2').title.phone-only Чем эти курсы отличаются от других? + + p В интернет есть много различных курсов, но, к сожалению, большинство из них не выдерживают никакой критики. Скорее всего, вы и сами понимаете это, а если нет – спросите знакомого специалиста, он подтвердит. + p Курсы, которые находятся здесь — эффективны и не похожи ни на один из них. + + ul + li Цель — полноценная профессиональная разработка. Курс идёт с расчетом на современную разработку уровня мировых стандартов. Это немного другой уровень, чем «кнопка на коленке», и другой подход к знаниям. Понятно, что «гуру» шлифуют мастерство годами, но мы можем достаточно сильно продвинуться и научиться грамотной разработке за время курса. Для участников «с нуля» существует вводный видеокурс, который позволяет освоить самые базовые моменты заранее. + li Курс построен на примерах и задачах. Программировать — это как плавать, одной теории маловато, нужна практика, и чем больше — тем лучше. Значит – много примеров и задач. Ведь умение их решать, основанное на понимании и прямых руках — и есть реальная цель. + li Правильное понимание языка. JavaScript — особенный язык. Если взять все часы «среднего» JavaScript-разработчика, потерянные на вопросы на форумах, на отладку кривого кода… То важность этого становится очевидной. + li Актуальность… То, как делаются современные проекты, а не как это было 5 лет назад. + li Качество кода — это важно, т.к. большинство времени тратится не на изначальное написание кода, а на его развитие и поддержку. На курсах ему уделяется особое внимание. + li Непрерывная обратная связь — на любые вопросы вы получаете ответы, на ваши решения — грамотный ответ, можно ли так писать и когда возможны проблемы. + + p Курсы возникли в результате долгого опыта разработки и преподавания, очного, заочного и совмещенного, и сочетают преимущества обоих технологий. + + ul + li У вас на руках будут лекционные материалы для изучения и выполнения заданий. + li Ваши вопросы, результаты выполнения заданий, способы сделать лучше и правильнее мы обсуждаем при видео-общении онлайн. + + + +e.body._02 + + +e('h2').title.phone-only Зачем курсы, когда есть книги и статьи на javascript.ru? + + p Практика показывает, что язык программирования, как и обычные языки, все же лучше изучаются на курсах. + + p JavaScript в этом смысле особенный язык. На нём очень легко начать что-то делать. Но при этом разница между человеком, который нахватался по верхам и профессионалом, постигшим JS-дзен — колоссальна. Один делает три кнопки, другой пишет Gmail и покоряет мир. + + p Цель курсов — упростить и спрямить вторую дорогу, и пройтись по ее началу вместе, чтобы не свернуть ненароком куда не следует. А уж что вы потом захотите делать — новый Gmail или меню на сайте — вам решать. Главное это скорость и качество разработки. + + blockquote Курсы JavaScript — мощный и быстрый способ обучения. При полноценном участии они гарантируют актуальные, глубокие знания. + + p Наша цель — не просто выучить, какие есть функции. Да, методы знать нужно, но главное — уметь «думать на javascript» и разрабатывать понятный, хороший код, без ошибок и с правильной структурой. + + p Возможность участников общаться онлайн друг с другом и с ведущим, выполнение заданий также даёт более глубокое и эффективное усвоение практических навыков. + + p Ниже находится классическая «пирамида обучения». Слева указаны полученные в результате исследований средние проценты усвоения знаний. Четыре верхние ступени относятся к индивидуальному обучению. Три нижние — к групповому и, в частности, курсам. + + +b.image-with-text + + +e.img + img(src="/courses/pyramid.png" alt="пирамида обучения") + + +e.text + p На текущий момент в курсах уже участвовало более 1000 человек. Могло бы быть гораздо больше, но моя цель — не количество, а качество. Группы веду только я один, мест в них не так много. + + p Все участники как и вы, имеют доступ к гугл, книгам и javascript.ru. Но каждый имеет право на лучшее, они выбрали поход на курсы и, похоже, не пожалели. + + p Курсы — это вложение в себя. Это усилия, которые позволят быстро продвинуться. А где вы хотите быть через несколько месяцев/лет? + + p Может быть, имеет смысл level up? + + +e.body._03 + + +e('h2').title.phone-only Зачем курсы, если можно научиться на работе? + + p Забавный совет, который дают многим начинающим, такой: «читай книги, иди работай, пиши скрипты и научишься». Он отчасти правилен — действительно, нужно разрабатывать, получать опыт. + + p Но вот что касается «научиться» — на практике все не так просто. Люди могут работать долго, но качество кода при этом не всегда растёт. + + p Это и видно, мы все знаем, что компаниям нужны результаты. Им нужны хорошие разработчики, очень нужны. В современном интернет всё решают люди. За них постоянно идет борьба. На поиск выделяются ресурсы, деньги... + + p Если бы люди быстро вырастали в процессе работы — не было бы огромных трат ресурсов на поиск разработчиков. + + p Для компании обучать людей самостоятельно — гораздо затратнее, чем брать уже учёных. Поэтому предпочитают заплатить хорошему разработчику побольше, чем самостоятельно «допиливать» среднего. + + p Всё это объективные реалии, которые можно наблюдать в мире. Именно поэтому существуют курсы. Хорошие курсы могут дать очень многое, если, конечно, это — действительно хорошие курсы. + + + +script. + var className = 'tabbed-pane', + block = document.querySelector('.' + className); + + block + .querySelector('.' + className + '__tabs') + .addEventListener('click', function(e) { + + block.className = className + ' ' + + className + '_' + + e.target.className.split('_').pop(); + + }); + + + diff --git a/handlers/markup/templates/blocks/courses-table.jade b/handlers/markup/templates/blocks/courses-table.jade new file mode 100644 index 000000000..2b7c32866 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-table.jade @@ -0,0 +1,47 @@ +- var courses = []; +- var time = '2014-01-01T19:45' +- var date = 'Начало ' + moment(time).locale('ru').format('D MMM YYYY'); + +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }], status: 'need-verify' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'verified' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'started' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'ended' }); + ++b.courses-table + +e('table').table + + for course in courses + +e('tr').line + + +e('th').main + +e('h3').title !{ course.name } + +e('ul').info-links + for link in course.info_links + +e('li').info-links-item + +e('a').info-link(href=link.url) !{ link.name } + + +e('td').info + +e('strong').start !{ course.start } + +e.schedule !{ course.schedule } + + +e('td').verify + if course.status === 'verified' + +e('span').status._verified Участие подтверждено + + else if course.status === 'need-verify' + +b('a').button._action(href="/123") + +e('span').text Подтвердить участие + + else if course.status === 'started' + +e('span').status._started Занятия начались + + else if course.status === 'ended' + +e('span').status._ended Курсы завершены + +e('ul').info-links + +e('li').info-links-item + +e('a').info-link(href="/123") Отсавить отзыв + +e('li').info-links-item + +e('a').info-link(href="/123") Скачать серфтификат + + + diff --git a/handlers/markup/templates/blocks/courses-testimonials.jade b/handlers/markup/templates/blocks/courses-testimonials.jade new file mode 100644 index 000000000..1737684df --- /dev/null +++ b/handlers/markup/templates/blocks/courses-testimonials.jade @@ -0,0 +1,44 @@ +- var testimonials = []; +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '5', location: { country: 'ru', text: 'Россия, Москва' }, name: 'Бендер Константинопольский', text: 'При облучении инфракрасным лазером кондуктометрия захватывает сернистый газ, даже если нанотрубки меняют свою межплоскостную ориентацию. Изомерия, как следует из совокупности экспериментальных наблюдений', text2: 'редко активирует окисленный серный эфир' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '4', location: { country: 'ua', text: 'Украина, Киев' }, name: 'Иван Пупкин', text: 'Чо норм курсы' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '3', location: { country: 'ru', text: 'Россия, Усть-Каменогороск'}, name: 'Пьер Безухов', text: 'Продукт реакции разъедает жидкофазный раствор. Упаривание активирует серный эфир' }) + ++b.courses-testimonials.courses-mix + +e('h2').title Что говорят о курсах люди + + +e.wrapper + +e('i').arr._prev + +e('i').arr._next + +e('a').all(href="/123") Все отзывы + + +e.body + +e('ul').testimonials + for testimonial, index in testimonials + +e('li').testimonial + +e.main + +b.rating._4 + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e('p').testimonial-text !{ testimonial.text } + if testimonial.text2 + =' ' + +e('span').cut … + +e('span').cuted !{ testimonial.text2 } + +e.user + +e.userpic + +e('img').userpic-img(src=testimonial.userpic) + +e.username + +e('a').username-link(href="/123") !{ testimonial.name } + +e.country + +e('img').country-flag(src='/img/flags/' + testimonial.location.country + '.svg' width=16 height=12) + +e('span').country-text !{ testimonial.location.text } + +script. + + var cut = document.querySelector('.courses-testimonials__cut'); + + cut.addEventListener('click', function() { + cut.style.display = 'none'; + cut.nextSibling.style.display = 'inline' + }); diff --git a/handlers/markup/templates/blocks/head.jade b/handlers/markup/templates/blocks/head.jade new file mode 100755 index 000000000..2049a062a --- /dev/null +++ b/handlers/markup/templates/blocks/head.jade @@ -0,0 +1,25 @@ +title= (headTitle || title) + +//- for mobile devices +meta(name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes") +meta(name="apple-mobile-web-app-capable" content="yes") +link(rel="author" href="https://plus.google.com/+IlyaKantor") +link(rel="publisher" href="https://plus.google.com/+IlyaKantor") + +script if (window.devicePixelRatio > 1) document.cookie = 'pixelRatio=' + window.devicePixelRatio + ';path=/;expires=Tue, 19 Jan 2038 03:14:07 GMT'; +link(href='//fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700|Open+Sans+Condensed:700&subset=latin,latin-ext,cyrillic,cyrillic-ext' rel='stylesheet') + + +link(href=pack('styles', 1) rel='stylesheet') + +if prev + link(rel="prev" href=prev.url) + +if next + link(rel="next" href=next.url) + + +script(src=pack("head", "js")) + +//- head из конкретной статьи +!=head diff --git a/handlers/markup/templates/blocks/invoice-table.jade b/handlers/markup/templates/blocks/invoice-table.jade new file mode 100644 index 000000000..997c57333 --- /dev/null +++ b/handlers/markup/templates/blocks/invoice-table.jade @@ -0,0 +1,114 @@ +- var invoces = []; +- var datestr = '2014-01-01T19:45'; +- var date = moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'); +- var participants = [{ email: 'abc@abc.com', approved: true }, { email: 'abc@abc.com' }, {}] + +- invoces.push({number: '3451', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '5 мест', free: '2', busy: '3', confirmed: '2' }, payment: { amount: '2400', currency: 'RUR', status: 'done', type: 'Paypal' }, participants: participants }); +- invoces.push({number: '3453', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '4 места', free: '3', busy: '1', confirmed: '1' }, payment: { amount: '2200', currency: 'USD', status: 'await', type: 'Банковская квитанция' }, participants: participants }); +- invoces.push({number: '3456', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '5 мест', free: '2', busy: '3', confirmed: '2' }, payment: { amount: '2400', currency: 'UAH', status: 'done', type: 'Paypal' }, participants: participants }); +- invoces.push({number: '3458', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '1 местo' }, payment: { amount: '600', currency: 'RUR', status: 'await', type: 'Банковская квитанция' }, participants: [{}] }); + ++b.invoice-table + + +e('table').table + + for invoce, indexInvoice in invoces + +e('tr')(class=['data', indexInvoice == 1 ? '_show_settings' : '']) + +e('th').main + +e('span').number Заказ № !{ invoce.number } + +e('time').time(datetime=datestr)= date + +e('h3').title !{ invoce.name } + + - var slots = invoce.slots; + + if slots && slots.total + - slots.total = slots.total.split(' '); + + +e.slots + +e('strong').slots-total !{ slots.total[0] } +  !{ slots.total[1] } + + if slots.free + +e('strong').slots-free !{ slots.free } +  свободно + + if slots.busy + +e('strong').slots-busy !{ slots.busy } +  занято + + if slots.confirmed + +e('span').slots-confirmed  (!{ slots.confirmed } подтверждено) + else + +e('span').slots-confirmed._note  (подтверждение участников происходит после оплаты) + + +e('td').info + +e('a').info-link(href='/123') Описание курсов + + +e('td').price + +b.price !{ invoce.payment.amount } !{ invoce.payment.currency } + +e(class=['payment-status', '_' + invoce.payment.status]) + if invoce.payment.status === 'done' + Оплачено + if invoce.payment.status === 'await' + Ожидается оплата + +e.payment-type (!{ invoce.payment.type }) + + +e('tr').settings + +e('td').settings-cell(colspan=3) + +e.settings-dropdown + +e('button').settings-dropdown-close.close-button + +e.settings-dropdown-cell._left + form(action="/123") + +e('h4').settings-title Участники + + +e('ul').settings-participants + + for participant, index in participants + - var number = index + 1 + +e('li').settings-participant + +e('label').participant-label(for='participant' + number + '' + indexInvoice) Участник !{number}: + +b('span')(class=['text-input', index == 1 ? '_invalid' : '', '__input', participant.approved ? '_approved_yes' : '_approved_no']) + +e('input').control(placeholder="email", name="email", type="email", value= participant.email ? participant.email : '', id = 'participant' + number + '' + indexInvoice) + +e('span').status + if index == 1 + +e('span').err Не верный email + + +e.settings-line_submit + +b('button').button._common(type="submit") + +e('span').text Сохранить участников + + + +e.settings-dropdown-cell._right + +e('h4').settings-title Контактная информация + +e('form').contact-form(action="/123") + +e.settings-line + +e("label").contact-form-label(for="contact-name" + indexInvoice) Имя и фамилия: + +b("span").text-input + +e("input").control(type="text", required, name="contact-name", id="contact-name" + indexInvoice) + + +e.settings-line + +e('label').contact-form-label(for="contact-phone" + indexInvoice) Телефон: + +b.full-phone + +e.tel-wrap + +b.text-input._invalid._small.__tel + +e('input').control(placeholder="+X XXX XXX-XX-XX", required, id="contact-phone" + indexInvoice) + +e('span').err Не верный телефон + + +e.settings-line._submit + +b('button').button._common(type="submit") + +e('span').text Сохранить контакты + + +e.settings-line._foot + +e('h4').settings-title Оплата + +e('p').note Вы выбрали вариант оплаты «Оплатить позже», подтвердить участие мы сможем только после того, как получим оплату. + +b('button').button._action + +e('span').text Перейти к оплате + + +e.settings-line._foot + +e('h4').settings-title Оплата + +e('p').note Вы выбрали вариант оплаты «Оплатить позже», подтвердить участие мы сможем только после того, как получим оплату. Вы можете повторно скачать квитанцию. + +b('button').button._common + +e('span').text Изменить метод оплаты + + + diff --git a/handlers/markup/templates/blocks/lesson.jade b/handlers/markup/templates/blocks/lesson.jade new file mode 100755 index 000000000..e69de29bb diff --git a/handlers/markup/templates/blocks/lessons-list-inner.jade b/handlers/markup/templates/blocks/lessons-list-inner.jade new file mode 100755 index 000000000..b1c5f1fb2 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons-list-inner.jade @@ -0,0 +1,24 @@ ++b.lessons-list + +e('ol').lessons + //- data-section-number should contain first part of item number, like _2_.1. + //- Is used to generate numbered bullets automatically. + +e('li').lesson(data-section-number="2") + +e('a').link(href="#intro") Введение + +e('li').lesson(data-section-number="2") + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson(data-section-number="2") + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson(data-section-number="2") + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson(data-section-number="2") + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson(data-section-number="2") + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson(data-section-number="2") + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson(data-section-number="2") + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson(data-section-number="2") + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson(data-section-number="2") + +e('a').link(href="#events") Свои события, подписка - уведомление diff --git a/handlers/markup/templates/blocks/lessons-list.jade b/handlers/markup/templates/blocks/lessons-list.jade new file mode 100755 index 000000000..a37eb5013 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons-list.jade @@ -0,0 +1,144 @@ ++b.lessons-list + //- behavior demo + script. + document.addEventListener('DOMContentLoaded', function() { + var togglingLinks = document.querySelectorAll('.lessons-list__lesson_level_1 > .lessons-list__link'); + + Array.prototype.forEach.call(togglingLinks, function(element) { + element.addEventListener('click', function(e) { + this.parentNode.classList.toggle('lessons-list__lesson_open'); + e.preventDefault(); + }); + }); + }); + +e('ol').lessons + +e('li').lesson._level_1 + +e('a').link(href="#intro") Введение + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_1._open + +e('a').link(href="#graphical") Верстка графических компонент + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_1 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_1 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_1 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_1 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_1 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент diff --git a/handlers/markup/templates/blocks/lessons.jade b/handlers/markup/templates/blocks/lessons.jade new file mode 100755 index 000000000..cddee8ad3 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons.jade @@ -0,0 +1,8 @@ +- var lessons = []; +- lessons.push({title: 'Переменные', }) +- lessons.push({title: 'Переменные'}) + +.lessons + for [1..10] + .lessons__lesson-wrap + include lesson diff --git a/handlers/markup/templates/blocks/map.jade b/handlers/markup/templates/blocks/map.jade new file mode 100755 index 000000000..02f90f9d9 --- /dev/null +++ b/handlers/markup/templates/blocks/map.jade @@ -0,0 +1,333 @@ ++b.tutorial-map + +e.filter + +e.input-wrap + //- чтобы сделать видимой кнопку очистки необходимо добавить блоку + //- .text-input модификатор ._clear-button + +b('span').text-input._clear-button.__input + +e('input').control(type="text" placeholder="Фильтр по заголовку" autofocus data-tutorial-map-filter) + +b.close-button.__clear + +e.option + +e('label').option-label(for="show-tasks") + +e('input').option-control#show-tasks(type="checkbox" data-tutorial-map-show-tasks) + | Показать задачи + +e.layout + +b.switch.__layout-switch + +e.option + +e('input').control#multicol(type="radio", name="map-layout", checked="checked") + +e('label').label.__multicol(for="multicol") + +e.option + +e('input').control#singlecol(type="radio", name="map-layout") + +e('label').label.__singlecol(for="singlecol") + + script. + (function() { + document.querySelector('.tutorial-map__layout-switch').addEventListener('click', function() { + if(document.getElementById('singlecol').checked) { + document.querySelector('.tutorial-map').classList.add('tutorial-map_singlecol'); + } else { + document.querySelector('.tutorial-map').classList.remove('tutorial-map_singlecol'); + } + }) + })() + + +e.tutorial-map-map.columns.columns_3 + //- использовать блок columns? + +e.section.columns__col + +e('h2').col-title Базовый JavaScript + +e('ul').items + +e('li').item + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item._collapsed + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 + + +e.section.columns__col + +e('h2').col-title Документ, События, Интерфейсы + +e('ul').items + +e('li').item._collapsed + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 + + +e.section.columns__col + +e('h2').col-title Дополнительные курсы + +e('ul').items + +e('li').item._collapsed + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item._collapsed + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 diff --git a/handlers/markup/templates/blocks/notification-message.jade b/handlers/markup/templates/blocks/notification-message.jade new file mode 100755 index 000000000..e41e8833f --- /dev/null +++ b/handlers/markup/templates/blocks/notification-message.jade @@ -0,0 +1,3 @@ ++b.notification._message._warning + +e.content Обрати внимание: сделано в Германии! + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/notification-popup.jade b/handlers/markup/templates/blocks/notification-popup.jade new file mode 100755 index 000000000..70a504bf5 --- /dev/null +++ b/handlers/markup/templates/blocks/notification-popup.jade @@ -0,0 +1,3 @@ ++b.notification._popup._success + +e.content Действие выполнено успешно + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/notification-stripe.jade b/handlers/markup/templates/blocks/notification-stripe.jade new file mode 100755 index 000000000..de0185262 --- /dev/null +++ b/handlers/markup/templates/blocks/notification-stripe.jade @@ -0,0 +1,4 @@ +//- __notification - потому что одновременно является элементом родителя, .sitetoolbar ++b.notification._top._info.__notification + +e.content Поздравляем, вы — миллионный посетитель! + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/page-footer.jade b/handlers/markup/templates/blocks/page-footer.jade new file mode 100755 index 000000000..a556b6973 --- /dev/null +++ b/handlers/markup/templates/blocks/page-footer.jade @@ -0,0 +1,14 @@ ++b.page-footer + +e.left + +e("ul").list + - var year = new Date().getFullYear() + +e("li").item © 2007—!{year} Илья Кантор + +e("li").item + +e("a")(href="/about#contact-us").link связаться с нами + +e("li").item + +e("a")(href="/about").link о проекте + + +e.right + +e("ul").list + +e("li").item сделано на + +e("a")(href="/aaa").link io.js diff --git a/handlers/markup/templates/blocks/page-nav.jade b/handlers/markup/templates/blocks/page-nav.jade new file mode 100755 index 000000000..630e99f12 --- /dev/null +++ b/handlers/markup/templates/blocks/page-nav.jade @@ -0,0 +1,15 @@ +.page__nav-wrap + a.page__nav.page__nav_prev(href="#prev", title="назад") + span.page__nav-text + span.page__nav-text-shortcut + | Ctrl + + =" " + span.page__nav-text-arr ← + span.page__nav-text-alternate Предыдущий урок + a.page__nav.page__nav_next(href="#next", title="вперед") + span.page__nav-text + span.page__nav-text-shortcut + | Ctrl + + =" " + span.page__nav-text-arr → + span.page__nav-text-alternate Следующий урок diff --git a/handlers/markup/templates/blocks/payment-settings.jade b/handlers/markup/templates/blocks/payment-settings.jade new file mode 100644 index 000000000..50e9772ba --- /dev/null +++ b/handlers/markup/templates/blocks/payment-settings.jade @@ -0,0 +1,39 @@ +block append variables + +- var name = paymentMethod.name + ++b.payment-setting + if name == 'paypal' + +e.item._currency + +e('label').label(for="pay-form-currency") Выберите валюту: + +b('select').input-select._small.__control#pay-form-currency + +e('option').option(value="USD") USD + +e('option').option(value="RUR") RUR + +e('span').small-note + | Если у вас Paypal аккаунт в рублях, вы
    + | сможете оплатить только в RUB. + + if name == 'invoice' + +e.item + +e('label').label(for="pay-form-company") Название компании: + +b('span').text-input._small + +e('input').control#pay-form-company + + +e.item._with_cb + +e('input').cb._invoice-need#pay-form-contract(type="checkbox") + +e('label').cb-label(for="pay-form-contract") + | Нужен договор + +e('span').small-note  (Договор заключается с компанией зарегистрированной в РФ) + + +e.item._hidden + +e('label').label(for="pay-form-contract-head") Шапка (для акта и договора): + +b('textarea').textarea-input.__textarea-head#pay-form-contract-head(cols="30" rows="10") ___, именуемое в дальнейшем Заказчик, в лице ___, действующего на основании ___, с одной стороны + +e.small-note Например: Общество с ограниченной ответственностью «Лютики», именуемое в дальнейшем Заказчик, в лице Иванова Петра Сергеевича, действующего на основании Устава, с одной стороны + + +e.item_hidden + +e('label').label(for="pay-form-company-address") Юридический адрес: + +b('textarea').textarea-input.__textarea-addr#pay-form-company-address(cols="30", rows="5") + + +e.item_hidden + +e('label').label(for="pay-form-bank-details") Банковские реквизиты: + +b('textarea').textarea-input.__textarea-bank#pay-form-bank-details(cols="30", rows="5") diff --git a/handlers/markup/templates/blocks/phone-toggler.jade b/handlers/markup/templates/blocks/phone-toggler.jade new file mode 100644 index 000000000..4a5326c75 --- /dev/null +++ b/handlers/markup/templates/blocks/phone-toggler.jade @@ -0,0 +1,5 @@ +input(type="checkbox" id="phone-toggler").phone-toggler__input.phone-only ++b('label').phone-toggler.phone-only(for="phone-toggler") + | Информация о ведущем и особенностях курсов. + + diff --git a/handlers/markup/templates/blocks/profile-ok-cancel.jade b/handlers/markup/templates/blocks/profile-ok-cancel.jade new file mode 100755 index 000000000..6658eee27 --- /dev/null +++ b/handlers/markup/templates/blocks/profile-ok-cancel.jade @@ -0,0 +1,3 @@ ++e.ok-cancel + +b('button').submit-button._small.__item-save Сохранить + +e('button').item-cancel Отмена \ No newline at end of file diff --git a/handlers/markup/templates/blocks/profile-upic.jade b/handlers/markup/templates/blocks/profile-upic.jade new file mode 100755 index 000000000..85cff0f5b --- /dev/null +++ b/handlers/markup/templates/blocks/profile-upic.jade @@ -0,0 +1,21 @@ ++e.upic(style="background-image: url('/img/userpic.svg')") + +e.upic-edit Загрузить
    фотографию + ++e.upic._loading(style="background-image: url('/img/userpic.svg')") + +e.upic-edit Загрузить
    фотографию + +b('span').spinner._active._medium + +e.dot._1 + +e.dot._2 + +e.dot._3 + ++e.upic(style="background-image: url('/img/userpic-deleted.svg')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/300x100')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/200x600')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/20x60')") + +e.upic-edit Загрузить
    фотографию diff --git a/handlers/markup/templates/blocks/quiz-explanations.jade b/handlers/markup/templates/blocks/quiz-explanations.jade new file mode 100755 index 000000000..30640125d --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-explanations.jade @@ -0,0 +1,4 @@ ++b.quiz-explanations + +e("h4").title !{ quiz.explanations.title } + each item, i in quiz.explanations.list + +e("li").item !{ item } diff --git a/handlers/markup/templates/blocks/quiz-question.jade b/handlers/markup/templates/blocks/quiz-question.jade new file mode 100755 index 000000000..fa1d0a426 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-question.jade @@ -0,0 +1,54 @@ ++b(class="quiz-question "+( quiz.done ? (quiz.correct ? " _correct_true" : " _correct_false") : "")) + +e("form")(action="/asd").body + +e("h1").title !{ quiz.title } + +e("ul").variants + + each variant, index in quiz.variants + + +e("li")(class="variant " + ( quiz.correctNum == (index + 1) ? " _correct" : "")) + +e("label").label + +e("input").input(type=quiz.type name="variant" disabled = quiz.done ? true : false checked = quiz.selected == (index + 1) ? true : false) + +e("span").input-text !{ variant.title } + if variant.description + +e.description !{ variant.description } + + + + if quiz.note + +e("p").note !{ quiz.note } + + if !quiz.done + +e.submit + +b("button").button._action(disabled="disabled") + +e("span").text Продолжить + + + script. + (function() { + var isSpinner, + form = document.querySelector('.quiz-question__body'), + button = document.querySelectorAll('.button_action')[1]; + + + button && button.addEventListener('click', function() { + + this.classList.toggle('button_loading'); + + if (!isSpinner) { + this.insertAdjacentHTML( + 'beforeend', + '' + ); + + isSpinner = true; + } + }); + + + form.addEventListener('change', function() { + + button.hasAttribute('disabled') && + button.removeAttribute('disabled'); + + }); + })() diff --git a/handlers/markup/templates/blocks/quiz-result.jade b/handlers/markup/templates/blocks/quiz-result.jade new file mode 100755 index 000000000..61ce94470 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-result.jade @@ -0,0 +1,57 @@ +block append variables + + -var quizResult = {}; + -quizResult.resultsLink = '/123' + -quizResult.try = '3' + -quizResult.percents = '62' + -quizResult.position = '25' + -quizResult.level = 'medium' // junior || senior + -quizResult.levelText = 'средний' + -quizResult.weakList = ['события', 'кроссбраузерность', 'замыкания'] + + -var rotate = parseInt((quizResult.percents * 1.8), 10) + 'deg' + + ++b.quiz-result + + +e.try + +e("span").try-num Попытка №!{ quizResult.try } + span ( + +e("a").prev-results(href='/2') предыдущие результаты + span ) + + +e.layout + +e.left + +b.quiz-percents + +e("dl").result + +e("dt").text Ваш результат: + +e("dd") + +e("p").percents !{ quizResult.percents }% + +e("dl").position + +e("dt").text Вы прошли текст лушче, чем + +e("dd") + +e("p").percents !{ quizResult.position }% + +e("p").text респондентов + + style. + .quiz-results-indicator__indicator:after + { + -webkit-transform: rotate(!{ rotate }); + -moz-transform: rotate(!{ rotate }); + -ms-transform: rotate(!{ rotate }); + -o-transform: rotate(!{ rotate }); + transform: rotate(!{ rotate }); + } + + +e.center + +b.quiz-results-indicator + +e.indicator + +e.text Ваш предположительный уровень — + span(class='quiz-results-indicator__level quiz-results-indicator__level_' + quizResult.level) !{ quizResult.levelText } + + +e.right + +b.quiz-weak-list + +e("h1").title Ваши слабые места: + +e("ul").list + for item in quizResult.weakList + +e("li").item !{ item } diff --git a/handlers/markup/templates/blocks/quiz-results-table.jade b/handlers/markup/templates/blocks/quiz-results-table.jade new file mode 100755 index 000000000..88ae9e32b --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-results-table.jade @@ -0,0 +1,31 @@ ++b.quiz-results-table + +e("table").results + + for result in profileTests + +e("tr").result + + +e("th").test-info + +e("time").time= result.date + +e("h1").name= result.name + + // Отложено до следующей версии + // +e("p").try Попытка №!{ result.try } + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value= result.result + + +e("td").level + +e("h1").title Уровень: + +e("p").level-info= result.level + + // Отложено до следующей версии + //- +e("td").weak-list + //- +e("h1").title Слабые места: + //- +e("p").weak-list-info !{ result.weakList } + + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info= result.time \ No newline at end of file diff --git a/handlers/markup/templates/blocks/quiz-selector.jade b/handlers/markup/templates/blocks/quiz-selector.jade new file mode 100755 index 000000000..25421c451 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-selector.jade @@ -0,0 +1,14 @@ ++b.quiz-selector + +e("ul").list + each item, i in quiz.list + +e("li").item + +e.text + +e("h3").title !{ item.title } + !{ item.description } + +e.start + +e.start-i + +b("a")(href="/123").button._common + +e("span").text Пройти тестирование + if item.result + +e.result Предыдущий результат: !{ item.result } + diff --git a/handlers/markup/templates/blocks/quiz-start.jade b/handlers/markup/templates/blocks/quiz-start.jade new file mode 100755 index 000000000..4add0b4d3 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-start.jade @@ -0,0 +1,9 @@ ++b.quiz-start + + +b("button").button._action + +e("span").text Начать тестирование + + +e("p").info + | Нажмите на кнопку ниже для того чтобы начать тестирование. + br + |Сразу после этого начнется отчет времени. \ No newline at end of file diff --git a/handlers/markup/templates/blocks/quiz-tablet-timeline.jade b/handlers/markup/templates/blocks/quiz-tablet-timeline.jade new file mode 100644 index 000000000..e24fb6e45 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-tablet-timeline.jade @@ -0,0 +1,12 @@ +- var n = 0 + ++b.quiz-tablet-timeline.tablet-only + +e('h2').title Вопрос + +e('strong').num   + + while n < quiz.total + - n++ + if (n == quiz.current) + | !{quiz.current}  + | из  + +e('strong').total !{quiz.total} diff --git a/handlers/markup/templates/blocks/quiz-timeline.jade b/handlers/markup/templates/blocks/quiz-timeline.jade new file mode 100644 index 000000000..7d9c22ee2 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-timeline.jade @@ -0,0 +1,6 @@ +- var n = 0 + ++b.quiz-timeline + while n < quiz.total + - n++ + +e("span")(class="number" + (n == quiz.current ? '_current' : '')) !{ n } diff --git a/handlers/markup/templates/blocks/quiz.jade b/handlers/markup/templates/blocks/quiz.jade new file mode 100644 index 000000000..77b980f63 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz.jade @@ -0,0 +1,4 @@ ++b.quiz + include ../blocks/quiz-timeline + include ../blocks/quiz-tablet-timeline + include ../blocks/quiz-question diff --git a/handlers/markup/templates/blocks/section-intro.jade b/handlers/markup/templates/blocks/section-intro.jade new file mode 100755 index 000000000..2efdee830 --- /dev/null +++ b/handlers/markup/templates/blocks/section-intro.jade @@ -0,0 +1,9 @@ +p + | Здесь мы рассмотрим создание интерфейсных компонент. Более коротко их называют «виджеты» (widgets). +p + | Предполагается, что к этому разделу вы уже знакомы с основами JavaScript, DOM, CSS. +p + | В этом разделе также понадобится библиотека jQuery. Мы используем ее для оптимизации основных операций с DOM/CSS. + | Если вы освоили учебник до этого момента, то уже знаете, как работать с ними средствами обычного JavaScript. + | Поэтому вас не постигнет участь горе-разработчиков, знающих «только jQuery» и впадающих в ступор при необходимости + | минимального выхода за возможности этой библиотеки. diff --git a/handlers/markup/templates/blocks/sidebar.jade b/handlers/markup/templates/blocks/sidebar.jade new file mode 100755 index 000000000..dd4a913f7 --- /dev/null +++ b/handlers/markup/templates/blocks/sidebar.jade @@ -0,0 +1,39 @@ +//- модификатор _sticky-footer делает последнюю секцию всегда прижатой к низу сайдбара ++b.sidebar._sticky-footer.page__sidebar + //- FIXME: инлайновый скрипт — демо и используется для более удобной отладки + +e('button').toggle(onclick="document.getElementsByClassName('page')[0].classList.toggle('page_sidebar_on')") + +e('a').show-map(href="#", data-action="tutorial-map", onclick="document.querySelector('body').classList.add('tutorial-map_on')") + +e.inner + +e.content + +e.section + +e('h4').section-title Раздел + +e('a').link(href="#") Основы JavaScript + +e.section + +e('h4').section-title Навигация по уроку + +e('nav').navigation + +e('ul').navigation-links + +e('li').navigation-link + +e('a').link(href="#") alert + +e('li').navigation-link + +e('a').link(href="#") prompt + +e('li').navigation-link + +e('a').link(href="#") confirm + +e('li').navigation-link + +e('a').link(href="#") Особенности встроенных функций + +e.section._separator_before + +e('nav').navigation + +e('ul').navigation-links + +e('li').navigation-link + +e('a').link(href="#") Задачи (2) + +e('li').navigation-link + +e('a').link(href="#") Комментарии (12) + +e.section._share + +e.section-title Поделиться + +b('a').share._tw.sidebar__share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sidebar__share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sidebar__share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sidebar__share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + +e.section + button(onclick="this.parentNode.innerHTML=new Array(200).join('* ')") click me to add content + +e.section(hidden) + +e('a').link(href="http://github.com/iliakan/javascript-nodejs") Редактировать на github diff --git a/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade b/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade new file mode 100755 index 000000000..11a5d3fde --- /dev/null +++ b/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade @@ -0,0 +1,65 @@ ++b.sitetoolbar + if layout.notificationStripe + include ../blocks/notification-stripe + +e.content + +e.logo-wrap + +e('a').link._logo(href="/") + +e('embed').logo(src="/i/sitetoolbar__logo.svg") + +e.nav-toggle-wrap + //- FIXME: onclick - демо, убрать + //- при начале скролла тут же исчезает, для этого body += .sitetoolbar_hidden + +e('button').nav-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_menu_open')") + +e('nav').sections + +e('ul').sections-list + +e('li').section._current + //- элемент с модификатором _current может содержать как ссылку, + //- так и простой текст, выглядеть будет одинаково + | Учебник + =' ' + +e('li').section + +e('a').link(href="/courses") Курсы + =' ' + +e('li').section + +e('a').link(href="/spec") Стандарт ES5 + =' ' + +e('li').section + +e('a').link(href="/tests") Тестирование + +e.login-wrap + //- делаем логин кнопкой, так как она только раскрывает форму + //- и никуда не ведет. Если предполагается функциональность ссылки + //- потребуются минимальные доработки + +e('button').login(onclick="document.querySelector('.sitetoolbar__spinner').classList.toggle('spinner_active')") + | Вход / Регистрация + +e.search-wrap + +e('button').search-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_search_open')") + +e.tablet-menu + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Навигация по сайту: + +e.tablet-menu-content + +e('select').tablet-menu-nav.input-select + +e('option') Учебник + +e('option') Курсы + +e('option') Стандарт ES5 + +e('option') Тестирование + +e.tablet-menu-aside + +e('a').secondary-link(href="#full-content") Карта учебника + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Поделиться + +e.tablet-menu-content + +b('a').share._tw.sitetoolbar__tablet-menu-share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sitetoolbar__tablet-menu-share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sitetoolbar__tablet-menu-share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sitetoolbar__tablet-menu-share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + +e.search + +e('form').search-content(action="/search/") + +e.search-query-wrap + +b.text-input.__search-query + +e('input').control(type="text" name="query") + +e.search-submit-wrap + +b('button').submit-button.__search-submit(type="submit") Найти + +b('span').spinner._medium.__spinner + +e('span').dot._1 + +e('span').dot._2 + +e('span').dot._3 diff --git a/handlers/markup/templates/blocks/sitetoolbar.jade b/handlers/markup/templates/blocks/sitetoolbar.jade new file mode 100755 index 000000000..5e4d296df --- /dev/null +++ b/handlers/markup/templates/blocks/sitetoolbar.jade @@ -0,0 +1,76 @@ ++b.sitetoolbar + if layout.notificationStripe + include ../blocks/notification-stripe + +e.content + +e.logo-wrap + +e('a').link._logo(href="/") + +e('embed').logo(src="/i/sitetoolbar__logo.svg") + +e.nav-toggle-wrap + //- FIXME: onclick - демо, убрать + //- при начале скролла тут же исчезает, для этого body += .sitetoolbar_hidden + +e('button').nav-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_menu_open')") + +e('nav').sections + +e('ul').sections-list + +e('li').section._current + //- элемент с модификатором _current может содержать как ссылку, + //- так и простой текст, выглядеть будет одинаково + | Учебник + =' ' + +e('li').section + +e('a').link(href="/courses") Курсы + =' ' + +e('li').section + +e('a').link(href="/spec") Стандарт ES5 + =' ' + +e('li').section + +e('a').link(href="/tests") Тестирование + +e.user-wrap + //- делаем пользователя кнопкой, так как она только раскрывает меню + //- и никуда не ведет. Если предполагается функциональность ссылки + //- потребуются минимальные доработки + +e('button').user(title="Very very long nickname with spaces", onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_user_open')") + +e('img').userpic(src="/img/markup/sitetoolbar-userpic.png", alt="Very very long nickname with spaces", width="36", height="36") + +e('span').user-text + | Very very long nickname with spaces + + +e.search-wrap + +e.search-content + + +e('form').search(method="GET", action="/search") + +e('button').search-toggle(type="button") + +e.search-input + +b.text-input + +e('input').control(name="query" placeholder="Искать на Javascript.ru" ) + +e('button').find(type="submit") Найти + + +e.tablet-menu + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Навигация по сайту: + +e.tablet-menu-content + +e('select').tablet-menu-nav.input-select + +e('option') Учебник + +e('option') Курсы + +e('option') Стандарт ES5 + +e('option') Тестирование + +e.tablet-menu-aside + +e('a').secondary-link(href="#full-content") Полное содержание + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Поделиться + +e.tablet-menu-content + +b('a').share._tw.sitetoolbar__tablet-menu-share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sitetoolbar__tablet-menu-share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sitetoolbar__tablet-menu-share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sitetoolbar__tablet-menu-share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + + +e.usermenu + +e('ul').usermenu-items + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#profile") Публичный профиль + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#settings") Аккаунт + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#orders") Заказы + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#logout") Выйти diff --git a/handlers/markup/templates/blocks/task-single.jade b/handlers/markup/templates/blocks/task-single.jade new file mode 100755 index 000000000..2580f7254 --- /dev/null +++ b/handlers/markup/templates/blocks/task-single.jade @@ -0,0 +1,70 @@ ++b.task-single + +e('a').back(href="#lesson") + span вернуться к уроку + +b.task.__task + +e.header + +e.title-wrap + +e('h2').title DOM Children + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button", onclick="toggleSolution(this)") решение + +e.content + +e.answer + +e.step._open + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 1 + +e.answer-content + +e('h4').step-title Шаг 1 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 2 + +e.answer-content + +e('h4').step-title Шаг 2 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 3 + +e.answer-content + +e('h4').step-title Шаг 3 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть", onclick="toggleSolution(this)") + p + | Для страницы: + pre.line-numbers.language-javascript + | <!DOCTYPE html> + | <html> + | <head> + | <title>Задача</title> + | <meta charset="utf-8"> + | </head> + | <body> + | <div>Пользователи:</div> + | <ul> + | <li>Маша</li> + | <li>Вовочка</li> + | </ul> + | <!-- комментарий --> + | <script> + | // ваш код + | </script> + | </body> + | </html> + ul + li Напишите код, который получит элемент HTML. + li Напишите код, который получит UL. + li + | Напишите код, который получит второй LI. Будет ли ваш код работать в IE8-, если + | комментарий переместить между элементами LI? + p + a(href="#source") Открыть исходный документ diff --git a/handlers/markup/templates/blocks/tasks.jade b/handlers/markup/templates/blocks/tasks.jade new file mode 100755 index 000000000..ce9d6c261 --- /dev/null +++ b/handlers/markup/templates/blocks/tasks.jade @@ -0,0 +1,87 @@ ++b.tasks + +e('h2').title#tasks + +e('a').title-anchor.main__anchor.main__anchor_noicon(href="#tasks") Задачи + +b.task.__task + +e.header + +e.title-wrap + +e('h3').title#task1 + a.main__anchor(href="#task1") Использование prompt и alert + +e('a').open-link(href="/tasks/task1", target="_blank") + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button") решение + +e.content + +e.answer + +e.answer-content + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть") + p + | Создайте страницу, которая спрашивает имя и выводит его.
    + | Работать должно так: + =" " + a(href="/tutorial/intro/basic.html") /tutorial/intro/basic.html + +b.task.__task + +e.header + +e.title-wrap + +e('h3').title#task2 + a.main__anchor(href="#task2") Использование prompt и alert + +e('a').open-link(href="/tasks/task2", target="_blank") + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button") решение + +e.content + +e.answer + +e.step._open + +e('button').step-show(type="button") Шаг 1 + +e.answer-content + +e('h4').step-title Шаг 1 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button") Шаг 2 + +e.answer-content + +e('h4').step-title Шаг 2 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button") Шаг 3 + +e.answer-content + +e('h4').step-title Шаг 3 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть") + p Напишите кроссбраузерную функцию insertBefore(elem, html), которая: + ul + li Вставляет HTML-строку перед элементом elem, используя insertAdjacentHTML, + li Если он не поддерживается (старый Firefox) — то через DocumentFragment. + p + | В обоих случаях должна быть лишь одна операция с DOM документа.
    + | Следующий код должен вставить два пропущенных элемента списка <li>3</li><li>4</li>: + + pre.language-javascript.line-numbers + | <ul> + | <li>1</li> + | <li>2</li> + | <li>5</li> + | </ul> + | <script> + | var ul = document.body.children[0]; + | var li5 = ul.children[2]; + | function insertBefore(elem, html) { + | /* Ваш код */ + | } + | insertBefore(li5, '<li>3</li><li>4</li>'); + | </script> diff --git a/handlers/markup/templates/example/user.jade b/handlers/markup/templates/example/user.jade new file mode 100755 index 000000000..3970dae38 --- /dev/null +++ b/handlers/markup/templates/example/user.jade @@ -0,0 +1,11 @@ +//- Статическое объявление переменных шаблона +//- с описаниями, что есть что и зачем :) +-var name = "Vasya" + +doctype html + +html + head + title my jade template + body + h1 Hello #{name} diff --git a/handlers/markup/templates/layouts/base.jade b/handlers/markup/templates/layouts/base.jade new file mode 100755 index 000000000..d0502856a --- /dev/null +++ b/handlers/markup/templates/layouts/base.jade @@ -0,0 +1,63 @@ +doctype html +include /bem + +html + head + - var self = {} + //- по умолчанию все отключено, в шаблоне в секции переменных + //- мы сразу видим набор реально используемых фрагментов + - var layout = {} + - layout.sitetoolbar = false + - layout.prevNext = false + - layout.sidebar = false + - layout.articleFoot = false + - layout.centeredHeader = false + - layout.tutorialMap = false + - layout.header = false + - layout.breadcrumbs = false + - layout.notificationPopup = false + - layout.notificationStripe = false + - layout.bodyClass = "" + + block variables + include ../blocks/head + body.no-icons(class= layout.bodyClass != "" ? layout.bodyClass : undefined) + .page-wrapper(class=[sidebar && 'page-wrapper_sidebar_on']) + script head.fontTest(); + if layout.notificationPopup + include ../blocks/notification-popup + if layout.sitetoolbar + include ../blocks/sitetoolbar + //- include ../blocks/sitetoolbar-login-loaders + .page(class=[sidebar && 'page_sidebar_on', layout_page_class]) + if layout.prevNext + include ../blocks/page-nav + if layout.sidebar + include ../blocks/sidebar + .page__inner + main(class=(mainclass ? mainclass : 'main') + ' ' + [layout_main_class]) + //- отключается только на странице задачи, по возможности отрефакторить + if layout.header + header.main__header(class= layout.centeredHeader == true ? "main__header_center" : undefined) + if layout.breadcrumbs + include ../blocks/breadcrumbs + h1.main__header-title!= self.title + block content + if layout.articleFoot + include ../blocks/article-foot + include ../blocks/corrector + include ../blocks/comments + include ../blocks/page-footer + if layout.tutorialMap + //- блок map должен подгружаться динамически + //- подключен для демонстрации и отладки + //- сделан в виде страницы а не блока чтобы был доступен + //- по собственному url (/markup/pages/map) + .tutorial-map-overlay + include ../blocks/map + +b('button').close-button.tutorial-map-overlay__close + script(src=pack("footer", "js")) + script footer.init(); + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/markup/templates/layouts/profile.jade b/handlers/markup/templates/layouts/profile.jade new file mode 100755 index 000000000..caf40b7c6 --- /dev/null +++ b/handlers/markup/templates/layouts/profile.jade @@ -0,0 +1,56 @@ +doctype html +include /bem + +html + head + - var self = {} + //- по умолчанию все отключено, в шаблоне в секции переменных + //- мы сразу видим набор реально используемых фрагментов + - var layout = {} + - layout.sitetoolbar = false + - layout.prevNext = false + - layout.sidebar = false + - layout.articleFoot = false + - layout.centeredHeader = false + - layout.tutorialMap = false + - layout.header = false + - layout.notificationPopup = false + - layout.notificationStripe = false + - layout.bodyClass = "" + + block variables + include ../blocks/head + body.no-icons(class= layout.bodyClass != "" ? layout.bodyClass : undefined) + script head.fontTest(); + if layout.notificationPopup + include ../blocks/notification-popup + if layout.sitetoolbar + include ../blocks/sitetoolbar + //- include ../blocks/sitetoolbar-login-loaders + .page + .page__inner + .main + //- отключается только на странице задачи, по возможности отрефакторить + if layout.header + header.main__header(class= layout.centeredHeader == true ? "main__header_center" : undefined) + include ../blocks/breadcrumbs + h1.main__header-title!= self.title + block content + if layout.articleFoot + include ../blocks/article-foot + include ../blocks/corrector + include ../blocks/comments + //include ../blocks/page-footer + if layout.tutorialMap + //- блок map должен подгружаться динамически + //- подключен для демонстрации и отладки + //- сделан в виде страницы а не блока чтобы был доступен + //- по собственному url (/markup/pages/map) + .tutorial-map-overlay + include ../blocks/map + +b('button').close-button.tutorial-map-overlay__close + script(src=pack("footer", "js")) + script footer.init(); + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/markup/templates/pages/403.jade b/handlers/markup/templates/pages/403.jade new file mode 100755 index 000000000..39b724557 --- /dev/null +++ b/handlers/markup/templates/pages/403.jade @@ -0,0 +1,15 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Доступ к этой странице закрыт'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ к этой странице закрыт + +e.code 403 + +e.text + | Возможно, вам нужно + =" " + +e('button').button-link.__login-button залогиниться \ No newline at end of file diff --git a/handlers/markup/templates/pages/404.jade b/handlers/markup/templates/pages/404.jade new file mode 100755 index 000000000..3da493954 --- /dev/null +++ b/handlers/markup/templates/pages/404.jade @@ -0,0 +1,24 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Страница не найдена'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Страница не найдена + +e.code 404 + +e.text + | Для того, чтобы найти нужную вам страницу, вы можете воспользоваться поиском: + +e.text + +e('form').search(action="#") + +e.search-query-wrap + +b.text-input._small.__search-query + +e('input').control(type="text", name="error-search-query") + +e.search-submit-wrap + +b('button').submit-button._small.__search-submit Найти + +e.text + | или + =" " + +e('a').tutorial-map(href="/tutorial/map", data-action="tutorial-map") картой сайта \ No newline at end of file diff --git a/handlers/markup/templates/pages/500.jade b/handlers/markup/templates/pages/500.jade new file mode 100755 index 000000000..ab0253f52 --- /dev/null +++ b/handlers/markup/templates/pages/500.jade @@ -0,0 +1,12 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Ошибка на сервере'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Ошибка на сервере + +e.code 500 + +e.text Мы уже работаем над устранением ошибки diff --git a/handlers/markup/templates/pages/about.jade b/handlers/markup/templates/pages/about.jade new file mode 100755 index 000000000..011439093 --- /dev/null +++ b/handlers/markup/templates/pages/about.jade @@ -0,0 +1,154 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block append variables + - self.headTitle = 'Современный учебник Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = false + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + +block content + +b.about-banner + +e("header").header + +e("h1").title + проект + br + +e("span").name Javascript.ru + + +e("hr").line + + +b.about-list + +e("ul").list + +e("li").item + +e("h2").num 2 + +e("p").description + | конференции + br + | JS. Talks + + +e("li").item + +e("h2").num 4940 + +e("p").description + | участников очных + br + | JS. Talks + + +e("li").item + +e("h2").num 1255 + +e("p").description + | участников + br + | дистанционного обучения + + +e("li").item + +e("h2").num >282000 + +e("p").description + | посетителей в месяц + br + | (на основе последнего года) + + +e("li").item + +e("h2").num >24000 + +e("p").description + | строк на js в + br + | open-source коде сайта + + +e("li").item + +e("h2").num >93000 + +e("p").description + | строк в учебнике + br + | Javascript + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title О проекте + +e.body + p Javascript.ru был запущен в 2007 году и с тех пор стал одним из крупнейших русскоязычных порталов по JavaScript. Сегодня основные цели проекта это: + +e('ol').list + +e('li').item Предоставлять грамотную и актуальную информацию по JavaScript и смежным технологиям. + +e('li').item Популяризировать современные фронтенд-технологии + +e('li').item Проводить онлайн и оффлайн-мероприятия по обучению JavaScript. + +e('li').item Создание сообщества JS-разработчиков и обмен знаниями + p + | Код этого сайта и содержимое учебника по Javascript находится в open-source доступе и его можно посмотреть на  + a(href="/123") github + + + +b.about-text.about-layout__right.columns__col + +e('h1').title Люди + +e.body + +e('ul').humans + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/iliakan.jpg") + +e('h3').human-title Илья Кантор + +e('p').human-role Главный координатор, лектор, JS-разработчик + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/bezart.jpg") + +e('h3').human-title Артем Безценный + +e('p').human-role UX-дизайнер + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/tyv.jpg") + +e('h3').human-title Юрий Ткаченко + +e('p').human-role На дуде игрец + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/amax.jpg") + +e('h3').human-title Алексей Максимов + +e('p').human-role Админ + + + +b.about-map + +e('h1').title География офлайн событий + +e('h1').map-container#map + style. + .leaflet-map-pane { + z-index: 2 !important; + } + + .leaflet-google-layer { + z-index: 1 !important; + } + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + + script(src="https://maps.googleapis.com/maps/api/js?v=3.exp") + + script(src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + + script + include circles2.js + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title Принять участие в проекте + +e.body + p + | Если у вас есть идеи по улучшению работы сайта либо содержимого учебника по Javascript, не стесняйтесь присылать их нам либо заходите на наш  + a(href="/123") github + + + +b.about-text.about-layout__right.columns__col + +e('h1').title Обратная связь + +e.body._center + p + | Илья Кантор + p + a(href="mailto:iliakan@javascript.ru") + iliakan@javascript.ru + br + | +79035419441 + + diff --git a/handlers/markup/templates/pages/article.html b/handlers/markup/templates/pages/article.html new file mode 100755 index 000000000..69614835e --- /dev/null +++ b/handlers/markup/templates/pages/article.html @@ -0,0 +1,529 @@ +

    Давайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме + JavaScript.

    + +

    Что такое JavaScript?

    + +

    JavaScript изначально создавался для того, чтобы сделать web-странички «живыми».
    + Программы на этом языке называются скриптами. Они подключаются напрямую к HTML и, как только загружается + страничка — тут же выполняются.

    +

    ECMAScript + намылить новая вкладка песочница песочница plnkr.co документ архив таблица pdf-документ справка в MDN справка microsoft документация w3c спецификация ECMA + + +

    +

    ECMAScript намылить новая вкладка песочница песочница plnkr.co документ архив таблица pdf-документ справка в MDN справка microsoft документация w3c спецификация ECMA

    +

    ECMAScript + намылить новая вкладка песочница песочница plnkr.co документ + архив таблица pdf-документ + справка в MDN справка microsoft документация + w3c спецификация ECMA

    +

    Программы на JavaScript — обычный текст. Они не требуют какой-то специальной подготовки.

    +

    А здесь мы приведем пример сочетания клавиш Cmd + R + прямо в тексте урока.

    +

    Примеры кода и результата, которые должны создаваться динамически:

    + + +
    <div class="code-example">
    +    <div class="codebox code-example__codebox">
    +        <div class="toolbar codebox__toolbar">
    +            <div class="toolbar__tool">
    +                <a href="/play/abcdef" class="toolbar__button toolbar__button_run" title="выполнить"></a>
    +            </div>
    +            <div class="toolbar__tool">
    +                <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +            </div>
    +        </div>
    +<pre class="language-javascript line-numbers">function sayHi(name) {
    +  var phrase = "Привет, " + name;
    +  alert(phrase);
    +}
    +
    +sayHi('Вася');</pre>
    +    </div>
    +
    +    <div class="code-result code-example__result">
    +        <div class="toolbar code-result__toolbar">
    +            <div class="toolbar__tool">
    +                <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +            </div>
    +        </div>
    +        <iframe class="code-result__iframe" src="http://sass-lang.com/documentation/Sass/Script/Functions"></iframe>
    +    </div>
    +</div>
    + +

    Пример кода

    + +
    function sayHi(name) {
    +  var phrase = "Привет, " + name;
    +  alert(phrase);
    +}
    +
    +function HelloWorld(world) {
    +    alert('Hello, ' + world);
    +}
    +
    +HelloWorld('World');
    +
    +sayHi('Вася');
    + +

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

    +
    <div class="code-result">
    +    <div class="toolbar code-result__toolbar">
    +        <div class="toolbar__tool">
    +            <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +        </div>
    +    </div>
    +    <iframe class="code-result__iframe" src="http://sass-lang.com/documentation/Sass/Script/Functions"></iframe>
    +</div>
    + +

    В этом плане JavaScript сильно отличается от другого языка, который называется Java.

    + +

    Выделенный блок с информацией.

    + +
    +
    + + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    XMLHttpRequestIFRAMESCRIPTEventSourceWebSocket
    Кросс-доменностьда, кроме IE<10x1да, сложности в IE<8i1дадада
    МетодыЛюбыеGET / POSTGETGETСвой протокол
    COMETДлинные опросыx2Непрерывное соединениеДлинные опросыНепрерывное соединениеНепрерывное соединение в обе стороны
    ПоддержкаВсе браузеры, ограничения в IE<10x3Все браузерыВсе браузерыКроме IEIE 10, FF11, Chrome 16, Safari 6, Opera 12.5w1
    + +

    Выделенный блок c предупреждением

    + +
    +
    + Важно: + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + +

    Выделенный блок c вопросом

    + +
    +
    + Вопрос: + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + +

    Выделенный блок c ответом

    + +

    Чтобы читать и выполнять текст на JavaScript, нужна специальная программа — интерпретатор. + Процесс выполнения скрипта называют «интерпретацией».

    + +
    +
    + На заметку: + +

    Компиляция и интерпретация, для программистов

    +
    +
    +

    Строго говоря, для выполнения программ существуют «компиляторы» и «интерпретаторы».

    + +

    Когда-то между ними была большая разница. Компиляторы преобразовывали программу в машинный код, который потом + можно выполнять. А интерпретаторы — просто выполняли.

    + +

    Сейчас разница гораздо меньше. Современные интерпретаторы перед выполнением преобразуют JavaScript в машинный код + (или близко к нему), чтобы выполнялся он быстрее. То есть, по сути, компилируют, а затем запускают.

    +
    + +
    +

    Результат - ошибка. Попробуйте:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    Дело в том, что после var a = 5 нет точки с запятой.

    + +

    JavaScript воспринимает этот код как если бы перевода строки не было:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    То есть, он пытается вызвать функцию 5, что и приводит к ошибке.

    + +

    Если точку с запятой поставить, все будет хорошо:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    Это один из наиболее частых и опасных подводных камней, приводящих к ошибкам тех, кто не ставит + точки с запятой.

    +
    +
    +
    + +
    +

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

    +
    +
    +
    +
    + +

    Во все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на + странице.

    + +

    Но, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать + и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.

    + +

    Что умеет JavaScript?

    +

    Подзаголовок

    +

    Подподзаголовок

    + +

    Современный JavaScript — это «безопасный» язык программирования общего назначения. Он не предоставляет + низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это + не требуется.

    +

    В браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в + какой-то мере, с сервером:

    +
      +
    • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
    • +
    • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п. +
    • +
    • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
    • +
    • Получать и устанавливать cookie, запрашивать данные, выводить сообщения…
    • +
    • …и многое, многое другое!
    • +
    + +

    Что НЕ умеет JavaScript? +

    +

    JavaScript — быстрый и мощный язык, но браузер накладывает на его исполнение некоторые + ограничения.

    +

    Это сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные + или как-то навредить компьютеру пользователя. В браузере Firefox существует способ «подписи» скриптов с целью обхода + части ограничений, но он нестандартный и не кросс-браузерный.

    + +

    Спойлеры вне блока .important

    + +
    + +
    +

    Результат - ошибка. Попробуйте:

    + +
    var a = 5
    +
    +(function() {
    +  alert(a);
    +})()
    + +

    Дело в том, что после var a = 5 нет точки с запятой.

    + +

    JavaScript воспринимает этот код как если бы перевода строки не было:

    + +
    var a = 5(function() {
    +  alert(a)
    +})()
    + +

    То есть, он пытается вызвать функцию 5, что и приводит к ошибке.

    + +

    Если точку с запятой поставить, все будет хорошо:

    + +
    var a = 5;
    +
    +(function() {
    +  alert(a)
    +})()
    + +

    Это один из наиболее частых и опасных подводных камней, приводящих к ошибкам тех, кто не ставит точки с + запятой.

    +
    +
    +
    + +
    +

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

    +
    +
    + +

    Этих ограничений нет там, где JavaScript используется вне браузера, например на сервере.

    +

    Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.

    + +
    +
    + +
    + +
    +
    + +
      +
    • +

      JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. + Он не имеет прямого доступа к операционной системе.

      + +

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

    • +
    • +

      JavaScript, работающий в одной вкладке, почти не может общаться с другими вкладками и окнами. За исключением + случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, + протокол).

      + +

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

      +
    • +
    • +

      Из JavaScript можно легко посылать запросы на сервер, с которого пришла страничка. Запрос на другой домен тоже + возможен, но менее удобен, т.к. и здесь есть ограничения безопасности.

      +
    • +
    + +

    В чем уникальность + JavaScript?

    +

    Есть как минимум три замечательных особенности JavaScript:

    +
    +
    +
    +
      +
    • Полная интеграция с HTML/CSS. function f (x, y, z) { return x + y + z; }

    • +
    • Простые вещи делаются просто.

      +
      function sayHi(name) {
      +  var phrase = "Привет, " + name;
      +  alert(phrase);
      +  }
      +
      +  sayHi('Вася');
    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    + +

    Вариант блока с достоинствами и недостатками

    + +
    +
    +
    +

    Достоинства

    +
      +
    • Полная интеграция с HTML/CSS.

    • +
    • Простые вещи делаются просто.

    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    +
    +

    Недостатки

    +
      +
    • Полная интеграция с HTML/CSS.

    • +
    • Простые вещи делаются просто.

    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    + +

    Этих трех вещей одновременно нет больше ни в одной браузерной технологии. Поэтому JavaScript и + является самым распространенным средством создания браузерных интерфейсов.

    +

    Пример блока "balance" с узким контентом, который может вызвать ошибку

    + +
    +
    +
    +

    Достоинства

    +
      +
    • +

      Простота реализации.

      +
    • +
    +
    +
    +
    +
    +

    Недостатки

    +
      +
    • +

      Задержки между событием и уведомлением.

      +
    • +
    • +

      Лишний трафик и запросы на сервер.

      +
    • +
    +
    +
    +
    + +
    + Показать простой вариант compareNumeric + +
    +

    Функция должна возвращать положительное число, если a > b, отрицательное, если наоборот, и, + например, 0, если числа равны.

    + +

    Всем этим требованиям удовлетворяет функция.

    + +

    А примера кода не будет.

    +
    +
    + +

    Тенденции развития.

    +

    Перед тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в + JavaScript все более чем хорошо.

    + +

    HTML 5

    +

    HTML 5 — эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей + браузерам.

    +
    + + +
    +

    Здесь может быть что угодно

    +
    +
    +

    Вот несколько примеров:

    +
      +
    • Чтение/запись файлов на диск (в специальной «песочнице», то есть не любые).
    • +
    • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
    • +
    • Многозадачность с одновременным использованием нескольких ядер процессора.
    • +
    • Проигрывание видео/аудио, без Flash.
    • +
    • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
    • +
    +

    Многие возможности HTML5 все еще в разработке, но браузеры постепенно начинают их поддерживать.

    +
    +

    Тенденция: JavaScript становится все более и более мощным и возможности браузера + растут в сторону десктопных приложений.

    +
    + +

    EcmaScript

    +

    Сам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для + разработки.

    +

    Современные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и + стараются следовать стандартам.

    +
    +
    +

    Тенденция: JavaScript становится все быстрее и стабильнее.

    +
    +
    +

    Очень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. + Это позволяет избежать неприятностей с уже существующими приложениями.

    +

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

    +

    …Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может + привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на + практике такие «супер-новые» решения.

    +

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

    +
    +
    +

    Тенденция: все идет к полной совместимости со стандартом.

    +
    +
    + +

    Недостатки JavaScript

    +

    Зачастую, недостатки подходов и технологий — это обратная сторона их полезности. Стоит ли упрекать молоток в + том, что он — тяжелый? Да, неудобно, зато гвозди забиваются лучше.

    + +

    Заголовок третьего уровня

    +

    В JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора + (Brendan Eich) делался «за 10 бессонных дней и ночей». Поэтому некоторые моменты продуманы плохо, есть и откровенные + ошибки (которые признает тот же Brendan).

    +

    Конкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.

    + +

    Заголовок четвертого уровня

    +

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

    +

    В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают. Процесс внедрения + небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении + несравнимо лучше.

    diff --git a/handlers/markup/templates/pages/article.jade b/handlers/markup/templates/pages/article.jade new file mode 100755 index 000000000..9cd782d45 --- /dev/null +++ b/handlers/markup/templates/pages/article.jade @@ -0,0 +1,19 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.bodyId = 'page'; + - self.title = 'Учебник — Javascript.ru'; + - self.comments = {} // хм? + - self.comments.lenght = 5; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ./article.html diff --git a/handlers/markup/templates/pages/balance.jade b/handlers/markup/templates/pages/balance.jade new file mode 100755 index 000000000..7f83086a4 --- /dev/null +++ b/handlers/markup/templates/pages/balance.jade @@ -0,0 +1,14 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "Пример блоков balance" + +block content + include ../blocks/balance-single + include ../blocks/balance diff --git a/handlers/markup/templates/pages/book-purchase-1.jade b/handlers/markup/templates/pages/book-purchase-1.jade new file mode 100755 index 000000000..048422327 --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase-1.jade @@ -0,0 +1,62 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +e.step._current + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title Основы JavaScript + +e.info 125 стр., pdf (10 Mb) + +e.aside._price._center + | Стоимость + +b.price.__price + | 2400 RUR + +e('span').secondary (≈ 69$) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес + +e('h2').alternate-title Выберите метод оплаты + +b.pay-method.__pay-method + +e.methods + +e.method + +e('button').send(name="[payment-type]", value="webmoney") + +e('img').img(src="/img/pay-method__webmoney.png", width="184", height="98", alt="WebMoney") + +e.method + +e('button').send(name="[payment-type]", value="yamoney") + +e('img').img(src="/img/pay-method__yamoney.png", width="184", height="98", alt="Яндекс.Деньги") + +e.method + +e('button').send(name="[payment-type]", value="paypal") + +e('img').img(src="/img/pay-method__paypal.png", width="184", height="98", alt="Paypal") + +e.method + +e('button').send(name="[payment-type]", value="payanyway") + +e('img').img(src="/img/pay-method__payanyway.png", width="184", height="98", alt="Карта") + +e.method + +e('button').send(name="[payment-type]", value="interkassa") + +e('img').img(src="/img/pay-method__interkassa.png", width="184", height="98", alt="Терминалы и банки") + +b.pay-hint.__pay-hint + +e('a').hint(href="hint") + +b('img').flag.__flag(src="/img/flag/flag_ua.png") + | Рекомендации по оплате не из России + +e.step + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + diff --git a/handlers/markup/templates/pages/book-purchase-success.jade b/handlers/markup/templates/pages/book-purchase-success.jade new file mode 100755 index 000000000..023018d62 --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase-success.jade @@ -0,0 +1,38 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +b.receipts.__receipts + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Учебник «Основы Javascript» + +e.note 125стр., pdf (10Мб) + +e.receipt-aside + +e.price 24000 RUR + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e('img').pay-method(src="/img/paypal.png", alt="PayPal", title="PayPal", width="121", height="31") + +e.step._current + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + diff --git a/handlers/markup/templates/pages/book-purchase.jade b/handlers/markup/templates/pages/book-purchase.jade new file mode 100755 index 000000000..7d67bf0ac --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase.jade @@ -0,0 +1,80 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +b.notification._error._message.__error + +e.content Оплата не прошла, попробуйте еще раз + +b.receipts.__receipts + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Учебник «Основы Javascript» + +e.note 125стр., pdf (10Мб) + +e.receipt-aside + +e.price 24000 RUR + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e('img').pay-method(src="/img/paypal.png", alt="PayPal", title="PayPal", width="121", height="31") + +e.step._current + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title Основы JavaScript + +e.info 125 стр., pdf (10 Mb) + +e.aside._price._center + | Стоимость + +b.price.__price + | 2400 RUR + +e('span').secondary (≈ 69$) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес + +e('h2').alternate-title Выберите метод оплаты + +b.pay-method.__pay-method + +e.methods + +e.method + +e('button').send(name="[payment-type]", value="webmoney") + +e('img').img(src="/img/pay-method__webmoney.png", width="184", height="98", alt="WebMoney") + +e.method + +e('button').send(name="[payment-type]", value="yamoney") + +e('img').img(src="/img/pay-method__yamoney.png", width="184", height="98", alt="Яндекс.Деньги") + +e.method + +e('button').send(name="[payment-type]", value="paypal") + +e('img').img(src="/img/pay-method__paypal.png", width="184", height="98", alt="Paypal") + +e.method + +e('button').send(name="[payment-type]", value="payanyway") + +e('img').img(src="/img/pay-method__payanyway.png", width="184", height="98", alt="Карта") + +e.method + +e('button').send(name="[payment-type]", value="interkassa") + +e('img').img(src="/img/pay-method__interkassa.png", width="184", height="98", alt="Терминалы и банки") + +b.pay-hint.__pay-hint + +e('a').hint(href="hint") + +b('img').flag.__flag(src="/img/flag/flag_ua.png") + | Рекомендации по оплате не из России + +e.step + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + diff --git a/handlers/markup/templates/pages/circles.html b/handlers/markup/templates/pages/circles.html new file mode 100755 index 000000000..38786acae --- /dev/null +++ b/handlers/markup/templates/pages/circles.html @@ -0,0 +1,20 @@ + + + + + + Circles + + + + + +
    + + \ No newline at end of file diff --git a/handlers/markup/templates/pages/circles.jade b/handlers/markup/templates/pages/circles.jade new file mode 100755 index 000000000..06f8aac2d --- /dev/null +++ b/handlers/markup/templates/pages/circles.jade @@ -0,0 +1,32 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.headTitle = 'Современный учебник Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.custom = true + +block content + div#map(style="height:400px") + + style. + .leaflet-map-pane { + z-index: 2 !important; + } + + .leaflet-google-layer { + z-index: 1 !important; + } + + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + + script(src="https://maps.googleapis.com/maps/api/js?v=3.exp") + + script(src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + + + script + include l.js diff --git a/handlers/markup/templates/pages/circles.js b/handlers/markup/templates/pages/circles.js new file mode 100755 index 000000000..2cfc1b494 --- /dev/null +++ b/handlers/markup/templates/pages/circles.js @@ -0,0 +1,223 @@ +// First, create an object containing LatLng and population for each city. +var citymap = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 20000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 10000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 10000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 10000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 10000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 10000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 10000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 10000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 10000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 15000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 10000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + + radius: 20000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + + radius: 18000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 10000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 10000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 10000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 10000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 10000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 10000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 10000 + } + + +}; + +var cityCircle; + +function initialize() { + // Create the map. + var mapOptions = { + zoom: 5, + center: new google.maps.LatLng(54.231473, 37.734144), + mapTypeId: google.maps.MapTypeId.TERRAIN, + scrollwheel: false, // Disable Mouse Scroll zooming (Essential for responsive sites!) + panControl: false, // Set to false to disable + mapTypeControl: false, // Disable Map/Satellite switch + scaleControl: true, // Set to false to hide scale + streetViewControl: false, // Set to disable to hide street view + overviewMapControl: false, // Set to false to remove overview control + rotateControl: false, // Set to false to disable rotate control + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + + }; + + var map = new google.maps.Map(document.getElementById('map-canvas'), + mapOptions); + + // Construct the circle for each value in citymap. + // Note: We scale the area of the circle based on the population. + for (var city in citymap) { + var circleOptions = { + strokeColor: '#C13335', + fillColor: '#C13335', + strokeOpacity: 1, + fillOpacity: 1, + map: map, + center: new google.maps.LatLng(citymap[city].location.lat, citymap[city].location.lng), + radius: citymap[city].radius + }; + // Add the circle for this city to the map. + cityCircle = new google.maps.Circle(circleOptions); + } +} + +initialize(); diff --git a/handlers/markup/templates/pages/circles2.js b/handlers/markup/templates/pages/circles2.js new file mode 100755 index 000000000..9e762ce99 --- /dev/null +++ b/handlers/markup/templates/pages/circles2.js @@ -0,0 +1,360 @@ +// First, create an object containing LatLng and population for each city. +var citymap = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 30000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 20000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 18000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 18000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 18000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 18000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 20000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 18000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 18000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 20000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 18000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + radius: 30000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + radius: 30000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 25000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 22000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 18000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 18000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 18000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 22000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 20000 + } + + +}; + + + +/* + * L.TileLayer is used for standard xyz-numbered tile layers. + * @see https://gist.github.com/crofty/2197042 + */ +L.Google = L.Class.extend({ + includes: L.Mixin.Events, + + options: { + minZoom: 0, + maxZoom: 18, + tileSize: 256, + subdomains: 'abc', + errorTileUrl: '', + attribution: '', + opacity: 1, + continuousWorld: false, + noWrap: false, + }, + + // Possible types: SATELLITE, ROADMAP, HYBRID + initialize: function(type, options) { + L.Util.setOptions(this, options); + + this._type = google.maps.MapTypeId[type || 'SATELLITE']; + }, + + onAdd: function(map, insertAtTheBottom) { + this._map = map; + this._insertAtTheBottom = insertAtTheBottom; + + // create a container div for tiles + this._initContainer(); + this._initMapObject(); + + // set up events + map.on('viewreset', this._resetCallback, this); + + this._limitedUpdate = L.Util.limitExecByInterval(this._update, 150, this); + map.on('move', this._update, this); + //map.on('moveend', this._update, this); + + this._reset(); + this._update(); + }, + + onRemove: function(map) { + this._map._container.removeChild(this._container); + //this._container = null; + + this._map.off('viewreset', this._resetCallback, this); + + this._map.off('move', this._update, this); + //this._map.off('moveend', this._update, this); + }, + + getAttribution: function() { + return this.options.attribution; + }, + + setOpacity: function(opacity) { + this.options.opacity = opacity; + if (opacity < 1) { + L.DomUtil.setOpacity(this._container, opacity); + } + }, + + _initContainer: function() { + var tilePane = this._map._container + first = tilePane.firstChild; + + if (!this._container) { + this._container = L.DomUtil.create('div', 'leaflet-google-layer leaflet-top leaflet-left'); + this._container.id = "_GMapContainer"; + } + + if (true) { + tilePane.insertBefore(this._container, first); + + this.setOpacity(this.options.opacity); + var size = this._map.getSize(); + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + } + }, + + _initMapObject: function() { + this._google_center = new google.maps.LatLng(0, 0); + var map = new google.maps.Map(this._container, { + center: this._google_center, + zoom: 0, + mapTypeId: this._type, + disableDefaultUI: true, + keyboardShortcuts: false, + draggable: false, + disableDoubleClickZoom: true, + scrollwheel: false, + streetViewControl: false, + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + }); + + var _this = this; + this._reposition = google.maps.event.addListenerOnce(map, "center_changed", + function() { _this.onReposition(); }); + + map.backgroundColor = '#ff0000'; + this._google = map; + }, + + _resetCallback: function(e) { + this._reset(e.hard); + }, + + _reset: function(clearOldContainer) { + this._initContainer(); + }, + + _update: function() { + this._resize(); + + var bounds = this._map.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + var google_bounds = new google.maps.LatLngBounds( + new google.maps.LatLng(sw.lat, sw.lng), + new google.maps.LatLng(ne.lat, ne.lng) + ); + var center = this._map.getCenter(); + var _center = new google.maps.LatLng(center.lat, center.lng); + + this._google.setCenter(_center); + this._google.setZoom(this._map.getZoom()); + //this._google.fitBounds(google_bounds); + }, + + _resize: function() { + var size = this._map.getSize(); + if (this._container.style.width == size.x && + this._container.style.height == size.y) + return; + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + google.maps.event.trigger(this._google, "resize"); + }, + + onReposition: function() { + //google.maps.event.trigger(this._google, "resize"); + } +}); + + + +// ==================================================== + + +var map = new L.Map('map', { + center: new L.LatLng(54.231473, 37.734144), + zoom: 5, + attributionControl: false, + markerZoomAnimation: false +}); +var googleLayer = new L.Google('TERRAIN'); +map.addLayer(googleLayer); + +// Construct the circle for each value in citymap. +// Note: We scale the area of the circle based on the population. +for (var city in citymap) (function(city) { + var marker = L.circleMarker([citymap[city].location.lat-0.01, citymap[city].location.lng], { + radius: citymap[city].radius / 3000, + stroke: false, + opacity: 1, + fill: true, + fillColor: '#C13335', + fillOpacity: 1 + }); + map.addLayer(marker); + +}(city)); \ No newline at end of file diff --git a/handlers/markup/templates/pages/code-tabs.jade b/handlers/markup/templates/pages/code-tabs.jade new file mode 100755 index 000000000..dd48d393a --- /dev/null +++ b/handlers/markup/templates/pages/code-tabs.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.bodyId = 'page'; + - self.title = 'Учебник — Javascript.ru'; + - self.comments = {} // хм? + - self.comments.lenght = 5; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + +block content + include ../blocks/code-tabs diff --git a/handlers/markup/templates/pages/courses-common.jade b/handlers/markup/templates/pages/courses-common.jade new file mode 100644 index 000000000..74dc3e367 --- /dev/null +++ b/handlers/markup/templates/pages/courses-common.jade @@ -0,0 +1,13 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Регистрация на курсы JavaScript, DOM, интерфейсы' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + include ../blocks/courses-register diff --git a/handlers/markup/templates/pages/courses-course.jade b/handlers/markup/templates/pages/courses-course.jade new file mode 100644 index 000000000..912b55171 --- /dev/null +++ b/handlers/markup/templates/pages/courses-course.jade @@ -0,0 +1,23 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Название курса' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + - var sitetoolbar = true; + + - var content_class = '_center' + + +block content + p В первую очередь этот курс для тех, кто либо не разрабатывал на JS, либо разрабатывал на нём эпизодически и теперь хочет освоить профессионально. + + include ../blocks/courses-programm-and-register + include ../blocks/phone-toggler + include ../blocks/courses-parts + include ../blocks/courses-how + include ../blocks/courses-result + include ../blocks/courses-system-req + +b.fixed-tab.phone-only.courses-tab + a.courses-tab__link(href="#signup") Записаться на курсы diff --git a/handlers/markup/templates/pages/courses-feedback-many.jade b/handlers/markup/templates/pages/courses-feedback-many.jade new file mode 100644 index 000000000..6cf435d94 --- /dev/null +++ b/handlers/markup/templates/pages/courses-feedback-many.jade @@ -0,0 +1,94 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Отзыв о курсе Javascript, DOM, интерфейсы' + - var layout_main_class = "main_width-limit" + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + + +b.feedback-stat + +e('ul').list + +e('li').item._active Отлично + +e('span').status + +e('span').status-i(style="width: 60px") + +e('li').item Хорошо + +e('span').status + +e('span').status-i(style="width: 40px") + +e('li').item Нормально + +e('span').status + +e('span').status-i(style="width: 20px") + +e('li').item Так себе + +e('span').status + +e('span').status-i(style="width: 7px") + +e('li').item Плохо + +e('span').status + +e('span').status-i(style="width: 1px") + + +b.pie + // +e('img').image(src="") + +e.text + +e('strong').percents 78% + +e('span').advice Пользователей рекомендуют эти курсы + + + +b('section').course-feedbacks + +e('h2').title + +e('span').title-n 286 + |  отзывов + | с оценкой + +e('span').title-mark !{' '} «Отлично» + +e('a').show-all показать все + + +e('article').feedback + +b.course-feedback._result._external + +e.user + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg") + + +e('span').username + +e('a').username-link(href="/123") Александр Луговой + +e('span').country + +e('img').country-flag(src='/img/flags/ru.svg' width=16 height=12) + +e('span').country-text Россия, Москва + +e('span').date 12 Мар 2015 + + +b.rating._4 + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e.name Рекомендует курс “Javascript, DOM, интерфейсы” + + +e.body + p Очень доволен данным курсом.Я сам из Республики Беларусь.Практический все, с кем я общался по js, хоть раз да заходили на ваш сайт. Если кто-то решится из РБ пройти этот курс, рекомендую. Понравилось: подача материала, расставленные приоритеты в изучении, только актуальные данные , а не устаревшая информация. Мне не хватило пару занятий по организации проекта на js и про шаблоны. Хотел пройти курс у человека, который ПОЛНОСТЬЮ ПРАКТИК. Очень радостно, что вы поделились своим опытом!!! Спасибо. + + +e('article').feedback + +b.course-feedback._result._external + +e.user + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg") + + +e('span').username + +e('a').username-link(href="/123") Александр Луговой + +e('span').country + +e('img').country-flag(src='/img/flags/ru.svg' width=16 height=12) + +e('span').country-text Россия, Москва + +e('span').date 12 Мар 2015 + + +b.rating._4 + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e.name Рекомендует курс “Javascript, DOM, интерфейсы” + + +e.body + p Очень доволен данным курсом.Я сам из Республики Беларусь.Практический все, с кем я общался по js, хоть раз да заходили на ваш сайт. Если кто-то решится из РБ пройти этот курс, рекомендую. Понравилось: подача материала, расставленные приоритеты в изучении, только актуальные данные , а не устаревшая информация. Мне не хватило пару занятий по организации проекта на js и про шаблоны. Хотел пройти курс у человека, который ПОЛНОСТЬЮ ПРАКТИК. Очень радостно, что вы поделились своим опытом!!! Спасибо. + + + + diff --git a/handlers/markup/templates/pages/courses-feedback-one.jade b/handlers/markup/templates/pages/courses-feedback-one.jade new file mode 100644 index 000000000..1803ffefa --- /dev/null +++ b/handlers/markup/templates/pages/courses-feedback-one.jade @@ -0,0 +1,49 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Отзыв о курсе Javascript, DOM, интерфейсы' + - var layout_main_class = "main_width-limit" + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + + +b(class="notification notification_message notification_success") + +e.content Ваш отзыв успешно сохранен. При желании, вы можете поделиться им в соц сетях + + +b.course-feedback._result + +e.user + +e.userpic + +e('img').userpic-img(src=courseFeedback.photo || "/img/userpic/userpic.svg") + + +e('span').username + +e('a').username-link(href="/123") Александр Луговой + +e('span').country + +e('img').country-flag(src='/img/flags/ru.svg' width=16 height=12) + +e('span').country-text Россия, Москва + +e('span').date 12 Мар 2015 + + +e('span').homepage + +e('a').homepage-link(href="/123") facebook.com/vasya-poupkin + + +b.rating._4 + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e.name Рекомендует курс “Javascript, DOM, интерфейсы” + + +e.body + p Очень доволен данным курсом.Я сам из Республики Беларусь.Практический все, с кем я общался по js, хоть раз да заходили на ваш сайт. Если кто-то решится из РБ пройти этот курс, рекомендую. Понравилось: подача материала, расставленные приоритеты в изучении, только актуальные данные , а не устаревшая информация. Мне не хватило пару занятий по организации проекта на js и про шаблоны. Хотел пройти курс у человека, который ПОЛНОСТЬЮ ПРАКТИК. Очень радостно, что вы поделились своим опытом!!! Спасибо. + + +e('a').edit(href="/123") редактировать + +e.share + +b.share-icons + +e('span').title Поделиться + include /blocks/social-icons + + + diff --git a/handlers/markup/templates/pages/courses-feedback.jade b/handlers/markup/templates/pages/courses-feedback.jade new file mode 100644 index 000000000..0909a0a60 --- /dev/null +++ b/handlers/markup/templates/pages/courses-feedback.jade @@ -0,0 +1,116 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Отзыв о курсе Javascript, DOM, интерфейсы' + - var layout_main_class = "main_width-limit" + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + +b.course-feedback._form + +e('form').form + +e.line + +e('h2').title Как вы в целом оцениваете курс + +b.rating-chooser._invalid.clearfix + +e('fieldset').fieldset + +e('input').input(type="radio" id="star5" name="rating" value="5" hidden) + +e('label').label(for="star5" title="Отлично") + +e('span').label-text Отлично + +e('input').input(type="radio" id="star4" name="rating" value="4" hidden) + +e('label').label(for="star4" title="Хорошо") + +e('span').label-text Хорошо + +e('input').input(type="radio" id="star3" name="rating" value="3" hidden) + +e('label').label(for="star3" title="Нормально") + +e('span').label-text Нормально + +e('input').input(type="radio" id="star2" name="rating" value="2" hidden) + +e('label').label(for="star2" title="Так себе") + +e('span').label-text Так себе + +e('input').input(type="radio" id="star1" name="rating" value="1" hidden) + +e('label').label(for="star1" title="Плохо") + +e('span').label-text Плохо + + +e('span').err Нужно выбрать оценку + + +e.line + +e('h2').title Порекомендовали бы вы этот курс другим? + +e('label').label + +e('input').input(type="radio" name="recomend" value="yes" checked) + |  Да + br + +e('label').label + +e('input').input(type="radio" name="recomend" value="no") + |  Нет + + +e.line + +e('h2').title Отзыв + +b('textarea').textarea-input._invalid(placeholder="Несколько слов о том, насколько полезным курс оказался для вас, как доступно излагается материал и т.д.") + span.textarea-input__err Напишите отзыв + + + +e.line + +e('h2').title + +e('input').checkbox(type="checkbox" checked) + | Публичный отзыв  + +e('span').title-note (будет опубликован на javascript.ru) + + +e('input').edit-input(type="checkbox" id="edit-input" hidden) + + +e.line._defined + +e.user + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg") + + +e('span').username + +e('a').username-link(href="/123") Александр Луговой + + +e('label').edit(for="edit-input") Редактировать + + +e('span').homepage + +e('a').homepage-link(href="/123") facebook.com/vasya-poupkin + + +e('span').country + +e('img').country-flag(src='/img/flags/ru.svg' width=16 height=12) + + +e('span').country-text Россия, Москва + +e('span').occupation Фрилансер + + + +e.line._editable + +e('h2').title Имя + +b.text-input + +e('input').control(value="Пупкин Вася") + + +e.line._editable + +e('h2').title Фото + +b.upload-userpic(data-photo-load) + input(type="hidden" name="photoId") + +e('i').img(style="background-image: url('/img/userpic/userpic.svg')") + +e('a').new(href="#") Загрузить новое фото + + + +e.line._editable + +e('h2').title Страна + +b('select').input-select._small(name="country") + +e('option')(value="ru") Россия + +e('option')(value="ua") Украина + + +e.line._editable + +e('h2').title Город  + +e('span').title-note (не обязательно) + +b.text-input + +e('input').control + + +e.line._editable + +e('h2').title Ссылка на профиль  + +e('span').title-note (не обязательно) + +b.text-input + +e('input').control(placeholder="Адресс вашего сайта, Вконтакте, Facebook, GitHub и т.д.") + + +e.note Было бы здорово если бы мы могли сослаться на вас в соц. сетях. Эта ссылка будет использоваться только внутри вашего отзыва у нас на сайте + + +e.line + +b('button').button._action(type="submit") Отправить diff --git a/handlers/markup/templates/pages/courses-home.jade b/handlers/markup/templates/pages/courses-home.jade new file mode 100644 index 000000000..c8ef0b77e --- /dev/null +++ b/handlers/markup/templates/pages/courses-home.jade @@ -0,0 +1,25 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Online курсы Javascript' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + - var sitetoolbar = true + - var content_class = '_center' + + +block content + p Здесь находятся «правильные» курсы по профессиональному Javascript, цель которых — научить думать на Javascript, писать просто, быстро и красиво. + +b.flex-column + include ../blocks/phone-toggler + include ../blocks/courses-features + include ../blocks/courses-programm-register + include ../blocks/courses-master + include ../blocks/courses-testimonials + include ../blocks/courses-tabbed-pane + include ../blocks/courses-guarantee + include ../blocks/courses-professionals + include ../blocks/courses-faq + +b.fixed-tab.phone-only.courses-tab + a.courses-tab__link(href="#courses") Перейти к списку открытых курсов diff --git a/handlers/markup/templates/pages/courses-materials.jade b/handlers/markup/templates/pages/courses-materials.jade new file mode 100644 index 000000000..ea662089a --- /dev/null +++ b/handlers/markup/templates/pages/courses-materials.jade @@ -0,0 +1,16 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var title = 'Материалы для группы Курс JavaScript/DOM/Интерфейсы /04.05/' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + p Серийный номер для видео: Q123-N456-Y678 + + include ../blocks/courses-materials diff --git a/handlers/markup/templates/pages/form.jade b/handlers/markup/templates/pages/form.jade new file mode 100755 index 000000000..588d99efd --- /dev/null +++ b/handlers/markup/templates/pages/form.jade @@ -0,0 +1,4 @@ +form(method="POST",action="/test") + input(type="hidden",name="name",value="value") + input(type="submit",value="submit") + diff --git a/handlers/markup/templates/pages/lesson-tasks.jade b/handlers/markup/templates/pages/lesson-tasks.jade new file mode 100755 index 000000000..1eb8c394f --- /dev/null +++ b/handlers/markup/templates/pages/lesson-tasks.jade @@ -0,0 +1,36 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Пример задач в уроке — Javascript.ru'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + +block content + h2 + a.main__anchor#positivism(href="#positivism") + | Онтологический позитивизм в XXI веке + p + | Ассоциация, по определению, индуктивно оспособляет сложный закон внешнего мира. + | Освобождение категорически раскладывает на элементы из ряда вон выходящий конфликт. + | Моцзы, Сюнъцзы и другие считали, что дедуктивный метод может быть получен из опыта. + | Интересно отметить, что дуализм естественно создает мир. Созерцание, как принято + | считать, методологически выводит сенсибельный закон внешнего мира, однако Зигварт + | считал критерием истинности необходимость и общезначимость, для которых нет никакой + | опоры в объективном мире. Даосизм, как принято считать, естественно рассматривается + | позитивизм. + p + | Ощущение мира, по определению, творит сложный даосизм. Мир осмысляет дедуктивный метод, + | ломая рамки привычных представлений. Конфликт может быть получен из опыта. + p + | Герменевтика подрывает конфликт. Освобождение, как следует из вышесказанного, выводит + | напряженный интеллект, ломая рамки привычных представлений. Вероятностная логика творит + | неоднозначный мир. Платоновская академия индуцирует сенсибельный язык образов. + + include ../blocks/tasks \ No newline at end of file diff --git a/handlers/markup/templates/pages/map.jade b/handlers/markup/templates/pages/map.jade new file mode 100755 index 000000000..e0b52bd67 --- /dev/null +++ b/handlers/markup/templates/pages/map.jade @@ -0,0 +1,12 @@ +doctype html +include /bem + +meta(charset='UTF-8') +title Карта сайта — Javascript.ru +//- тут стоило бы использовать блок head, но он также подключает скрипт head.js, +//- а на отдельной странице карты сайта скрипты не нужны +link(href='http://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700|Open+Sans+Condensed:700&subset=latin,latin-ext,cyrillic,cyrillic-ext' rel='style' type='text/css') +link(href='/styles/base.css' rel='stylesheet') + +body + include ../blocks/map diff --git a/handlers/markup/templates/pages/my.html b/handlers/markup/templates/pages/my.html new file mode 100755 index 000000000..9db1c64de --- /dev/null +++ b/handlers/markup/templates/pages/my.html @@ -0,0 +1 @@ +Hello diff --git a/handlers/markup/templates/pages/notifications.jade b/handlers/markup/templates/pages/notifications.jade new file mode 100755 index 000000000..f8ff16c2b --- /dev/null +++ b/handlers/markup/templates/pages/notifications.jade @@ -0,0 +1,40 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Виды уведомлений'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + - layout.notificationPopup = true + - layout.notificationStripe = true + - layout.bodyClass = "notification_on" + +block content + include ../blocks/notification-message + h2 + a.main__anchor#positivism(href="#positivism") + | Онтологический позитивизм в XXI веке + p + | Ассоциация, по определению, индуктивно оспособляет сложный закон внешнего мира. + | Освобождение категорически раскладывает на элементы из ряда вон выходящий конфликт. + | Моцзы, Сюнъцзы и другие считали, что дедуктивный метод может быть получен из опыта. + | Интересно отметить, что дуализм естественно создает мир. Созерцание, как принято + | считать, методологически выводит сенсибельный закон внешнего мира, однако Зигварт + | считал критерием истинности необходимость и общезначимость, для которых нет никакой + | опоры в объективном мире. Даосизм, как принято считать, естественно рассматривается + | позитивизм. + p + | Ощущение мира, по определению, творит сложный даосизм. Мир осмысляет дедуктивный метод, + | ломая рамки привычных представлений. Конфликт может быть получен из опыта. + p + | Герменевтика подрывает конфликт. Освобождение, как следует из вышесказанного, выводит + | напряженный интеллект, ломая рамки привычных представлений. Вероятностная логика творит + | неоднозначный мир. Платоновская академия индуцирует сенсибельный язык образов. + + include ../blocks/tasks \ No newline at end of file diff --git a/handlers/markup/templates/pages/profile-accounts.jade b/handlers/markup/templates/pages/profile-accounts.jade new file mode 100755 index 000000000..8c7f08996 --- /dev/null +++ b/handlers/markup/templates/pages/profile-accounts.jade @@ -0,0 +1,101 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var sitetoolbar = true + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab._current + +e.tab-content + | Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Тесты + +e.item + +e.item-content + +e('h2').inline-title Управление аккаунтом + +e.item._editable + +e.item-content + +e.item-name Юзернейм: + +e.item-value Nakazator + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-username") Юзернейм: + +b.text-input._small.__control + +e('input').control(type="text", id="profile-username", value="Nakazator") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Email: + +e.item-value nakazator@mail.com + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-email") Email: + +b.text-input._small.__control + +e('input').control(type="email", id="profile-email", value="nakazator@mail.com") + include ../blocks/profile-ok-cancel + +e.item + +e.item-content + +e('button').action._change-password(onclick="this.parentNode.parentNode.classList.add('profile__item_editing')") Изменить пароль + +e.item-change + +e.change-content + //- смена пароля: используем поля text а не password, символы не скрываются, + //- потому что в дизайне предусмотрено только одно поле для ввода нового пароля, + //- а значит проверить его повторным вводом невозможно, пользователь должен видеть + //- свой новый пароль. Скрывать старый не имеет смысла, поскольку он станет + //- недействительным после отправки формы + +e.labeled.__pass-change + +e.labeled-label(for="profile-pass-old") Старый пароль: + +b.text-input._small.__labeled-text.__pass + +e('input').control(type="text", id="profile-pass-old") + +e.labeled.__pass-change + +e.labeled-label(for="profile-pass-new") Новый пароль: + +b.text-input._small.__labeled-text.__pass + +e('input').control(type="text", id="profile-pass-new") + include ../blocks/profile-ok-cancel + +e.title + +e.title-content + +e('h2').inline-title Привязанные внешние аккаунты + +e('p').note При привязке аккаунта можно будет заходить на сайт одним нажатием кнопки. + +e.linked-account + +e.account-content + +e.linked-name(href='http://vk.com/alexbor') + +e('img').linked-upic(src="/img/linked-upic.png") + | Алексей Борматенко + +e.linked-provider Vkontakte + +e('button').account-remove(title="Удалить аккаунт") + +e.linked-account + +e.account-content + +e.linked-name(href='http://vk.com/alexbor') + +e('img').linked-upic(src="/img/linked-upic.png") + | Alexey Bormatenko + +e.linked-provider Facebook + +e('button').account-remove(title="Удалить аккаунт") + +e.providers + +e.providers-content + +e.providers-title Привязать: + +e.socials + include ../../../auth/templates/providers + +e.action-item + +e.action-content + +e('button').action._remove-account Удалить аккаунт diff --git a/handlers/markup/templates/pages/profile-courses.jade b/handlers/markup/templates/pages/profile-courses.jade new file mode 100644 index 000000000..857adb607 --- /dev/null +++ b/handlers/markup/templates/pages/profile-courses.jade @@ -0,0 +1,39 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab._current + +e.tab-content Курсы + +e('span').notification 1 + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="profile-quiz") Тесты + + include ../blocks/courses-table diff --git a/handlers/markup/templates/pages/profile-invoice.jade b/handlers/markup/templates/pages/profile-invoice.jade new file mode 100644 index 000000000..fb536d92c --- /dev/null +++ b/handlers/markup/templates/pages/profile-invoice.jade @@ -0,0 +1,39 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab._current + +e.tab-content + | Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="profile-quiz") Тесты + + include ../blocks/invoice-table diff --git a/handlers/markup/templates/pages/profile-quiz.jade b/handlers/markup/templates/pages/profile-quiz.jade new file mode 100644 index 000000000..f354deb06 --- /dev/null +++ b/handlers/markup/templates/pages/profile-quiz.jade @@ -0,0 +1,42 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab._current + +e.tab-content + | Тесты + + include ../blocks/quiz-results-table \ No newline at end of file diff --git a/handlers/markup/templates/pages/profile-recover.jade b/handlers/markup/templates/pages/profile-recover.jade new file mode 100755 index 000000000..47f80fabf --- /dev/null +++ b/handlers/markup/templates/pages/profile-recover.jade @@ -0,0 +1,21 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Восстановление пароля'; + +block content + +b.recover + +e('h1').title Восстановление пароля + +b.notification._message._error.__message + +e.content Пароль не может быт пустым + +e('button').close + +e.content + +e.controls + +e.label-wrap + +e('label').label(for="newpass") Новый пароль + +e.input-wrap + +b.text-input._small.__input + +e('input').control#newpass(type="password", autofocus) + +e.save-wrap + +b('button').submit-button._small.__save Сохранить пароль diff --git a/handlers/markup/templates/pages/profile-summary.jade b/handlers/markup/templates/pages/profile-summary.jade new file mode 100755 index 000000000..0f852e8fe --- /dev/null +++ b/handlers/markup/templates/pages/profile-summary.jade @@ -0,0 +1,39 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._summary + include ../blocks/profile-upic + +e.content + +e.item + +e.item-content + +e.item-name Имя: + +e.item-value Валентиновский Валентин Валентинович + +e.item + +e.item-content + +e.item-name Website: + +e.item-value www.valentin.com + +e.item + +e.item-content + +e.item-name Страна: + +e.item-value Украина + +e.item + +e.item-content + +e.item-name Часовой пояс: + +e.item-value Европа/Москва: GMT+4 + +e.item + +e.item-content + +e.item-name Интересы: + +e.item-value музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css + +e.item + +e.item-content + +e.item-name О себе: + +e.item-value Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. diff --git a/handlers/markup/templates/pages/profile-tests.jade b/handlers/markup/templates/pages/profile-tests.jade new file mode 100755 index 000000000..13697195c --- /dev/null +++ b/handlers/markup/templates/pages/profile-tests.jade @@ -0,0 +1,80 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._tests + include ../blocks/profile-upic + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab._current + +e.tab-content + | Тесты + //- воспроизводим структуру таблицы, чтобы обемпечить выравнивание + //- между разными строками, однако не используем теги таблицы, + //- чтобы оставить простую возможность менять лейаут с помощью css + +e.tests + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #1 + +e.test-score + +e.test-legend Результат + +e.test-percent 36% + +e.test-level + +e.test-legend Уровень + | Новичок + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность, события, CSS + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #2 + +e.test-score + +e.test-legend Результат + +e.test-percent 58% + +e.test-level + +e.test-legend Уровень + | Ученик + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #3 + +e.test-score + +e.test-legend Результат + +e.test-percent 96% + +e.test-level + +e.test-legend Уровень + | Мастер + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность diff --git a/handlers/markup/templates/pages/profile.jade b/handlers/markup/templates/pages/profile.jade new file mode 100755 index 000000000..e64f7f793 --- /dev/null +++ b/handlers/markup/templates/pages/profile.jade @@ -0,0 +1,222 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._settings + script. + document.addEventListener("DOMContentLoaded", function(e) { + var items = document.querySelectorAll('.profile__item_editable'); + for (var i = 0; i < items.length; i++) { + items[i].addEventListener('click', function(e) { + this.classList.add('profile__item_editing'); + }); + } + document.querySelector('.profile').addEventListener('click', function(e) { + var elem; + if (e.target.classList.contains('profile__item-cancel')) { + elem = e.target; + while (elem && !elem.classList.contains('profile__item_editable')) { + elem = elem.parentNode; + } + elem.classList.remove('profile__item_editing'); + } + }) + }); + include ../blocks/profile-upic + +e.content + +e.tabs + +e.tab._current + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Тесты + +e.item + +e.item-content + +e('h2').inline-title Управление аккаунтом + +e.item._editable(data-inline-edit) + +e.item-content + +e.item-name Имя: + +e.item-value Валентиновский Валентин Валентинович + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-name") Имя: + +b.text-input._small._invalid.__control + +e('input').control#profile-name(type="text", value="Валентиновский Валентин Валентинович") + +e.err Таких имен не бывает + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Email: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-email") Email: + +b.text-input._small.__control + +e('input').control#profile-email(type="email") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Website: + +e.item-value www.valentin.com + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-website") Website: + +b.text-input._small.__control + +e('input').control#profile-website(type="url", value="www.valentin.com") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Страна: + +e.item-value Россия + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-country") Страна: + +b.text-input._small.__control + +e('input').control#profile-country(type="text", value="Россия") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Город: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-city") Город: + +b.text-input._small.__control + +e('input').control#profile-city(type="text") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Дата рождения: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-birth-day") Дата рождения: + +b('select').input-select._small.__control#profile-birth-day + +e('option').option(value="1") 1 + +e('option').option(value="2") 2 + +e('option').option(value="3") 3 + +e('option').option(value="4") 4 + +e('option').option(value="5") 5 + +e('option').option(value="6") 6 + +e('option').option(value="7") 7 + +e('option').option(value="8") 8 + +e('option').option(value="9") 9 + +e('option').option(value="10") 10 + +e('option').option(value="11") 11 + +e('option').option(value="12") 12 + +e('option').option(value="13") 13 + +e('option').option(value="14") 14 + +e('option').option(value="15") 15 + +e('option').option(value="16") 16 + +e('option').option(value="17") 17 + +e('option').option(value="18") 18 + +e('option').option(value="19") 19 + +e('option').option(value="20") 20 + +e('option').option(value="21") 21 + +e('option').option(value="22") 22 + +e('option').option(value="23") 23 + +e('option').option(value="24") 24 + +e('option').option(value="25") 25 + +e('option').option(value="26") 26 + +e('option').option(value="27") 27 + +e('option').option(value="28") 28 + +e('option').option(value="29") 29 + +e('option').option(value="30") 30 + +e('option').option(value="31") 31 + +b('select').input-select._small.__control#profile-birth-month + +e('option').select(value="1") январь + +e('option').select(value="2") февраль + +e('option').select(value="3") март + +e('option').select(value="4") апрель + +e('option').select(value="5") май + +e('option').select(value="6") июнь + +e('option').select(value="7") июль + +e('option').select(value="8") август + +e('option').select(value="9") сентябрь + +e('option').select(value="10") октябрь + +e('option').select(value="11") ноябрь + +e('option').select(value="12") декабрь + +b('select').input-select._small.__control#profile-birth-year + +e('option').select(value="1985") 1985 + +e('option').select(value="1986") 1986 + +e('option').select(value="1987") 1987 + +e('option').select(value="1988") 1988 + +e('option').select(value="1989") 1989 + +e('option').select(value="1990") 1990 + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Часовой пояс: + +e.item-value Европа/Москва: GMT+4 + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-tz") Часовой пояс: + +b('select').input-select._small.__control#profile-tz + +e('option').select(value="+1") Европа/Москва: GMT+1 + +e('option').select(value="+2") Европа/Москва: GMT+2 + +e('option').select(value="+3") Европа/Москва: GMT+3 + +e('option').select(value="+4") Европа/Москва: GMT+4 + +e('option').select(value="+5") Европа/Москва: GMT+5 + +e('option').select(value="+6") Европа/Москва: GMT+6 + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Телефон: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-tel") Телефон: + +b.text-input._small.__control + +e('input').control#profile-tel(type="text") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Интересы: + +e.item-value музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-hobbies") Интересы: + +b.text-input._small.__control + +e('input').control#profile-hobbies(type="text", value="музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name О себе: + +e.item-value Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-about") О себе: + +b('textarea').textarea-input.__control#profile-about(cols="80", rows="5") + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. + include ../blocks/profile-ok-cancel diff --git a/handlers/markup/templates/pages/quiz-index.jade b/handlers/markup/templates/pages/quiz-index.jade new file mode 100644 index 000000000..34aa76ef0 --- /dev/null +++ b/handlers/markup/templates/pages/quiz-index.jade @@ -0,0 +1,33 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + - var title = 'Тестирование знания Javascript' + - var sitetoolbar = true + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + - var layout_header_class = "main__header_center" + + - var quiz = {} + - quiz.intro = 'На этой странице Вы можете протестировать свои знания Javascript, выбрав один из тестов' + + - quiz.list = [] + - quiz.list.push({ title: 'Основной Javascript', description: 'В тест включены вопросы по взаимодействию Javascript, DOM HTML, по синтаксису языка', url: '/123' }) + - quiz.list.push({ title: 'Особенности и фишки Javascript', description: 'Особенности Javascript по сравнению с другими языками. Трюки и фишки DOM, браузеров', url: '/123', result: '42%' }) + - quiz.list.push({ title: 'Коммуникация с сервером, AJAX, XMLHttpRequest', description: 'Различные аспекты работы с сервером из Javascript, транспорты и технологии', url: '/123' }) + + + - quiz.explanations = { title: 'Некоторые пояснения', list: []} + - quiz.explanations.list.push('Полный список браузеров, на который рассчитаны тесты: Firefox, Opera, Safari, Konqueror не более чем годовой давности и Internet Explorer 6.0+.') + - quiz.explanations.list.push('Если в вопросе ничего не сказано, то все настройки браузера - по умолчанию.') + - quiz.explanations.list.push('Версия Javascript - самая распространенная на текущий день, т.е 1.5.') + - quiz.explanations.list.push('Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи!') + + + +block content + +b.intro !{ quiz.intro } + include ../blocks/quiz-selector + include ../blocks/quiz-explanations diff --git a/handlers/markup/templates/pages/quiz-results.jade b/handlers/markup/templates/pages/quiz-results.jade new file mode 100644 index 000000000..206effabe --- /dev/null +++ b/handlers/markup/templates/pages/quiz-results.jade @@ -0,0 +1,40 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Результаты' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var contentWithMods = 'content_center' + + - var quiz = {} + + - quiz.title = 'Что из этого не событие мыши' + - quiz.type = 'checkbox' // radio + - quiz.variants = [{ title: 'onmousescroll' }, { title: 'onclick' }, { title: 'onmousover' }, { title: 'onmousemove' }, { title: 'onmousewheel' }, { title: 'some title', description: '
    some BLOCK content
    ' }] + + - var quizes = [JSON.parse(JSON.stringify(quiz)), JSON.parse(JSON.stringify(quiz))] + + - quizes[0].done = quizes[1].done = true + + - quizes[0].correct = true + - quizes[1].correct = false + + - quizes[0].selected = 1 + - quizes[0].correctNum = 1 + + - quizes[1].selected = 3 + - quizes[1].correctNum = 1 + + - quizes[1].note = 'Пояснение результата' + +block content + include ../blocks/quiz-result + + each quiz in quizes + include ../blocks/quiz-question diff --git a/handlers/markup/templates/pages/quiz.jade b/handlers/markup/templates/pages/quiz.jade new file mode 100644 index 000000000..10e4c1a47 --- /dev/null +++ b/handlers/markup/templates/pages/quiz.jade @@ -0,0 +1,32 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + + - var layout_header_class = "main__header_center" + - var headTitle = 'Название теста' + - var title = false + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + - var quiz = {} + - quiz.title = 'Что из этого не событие мыши' + - quiz.type = 'checkbox' // radio + - quiz.variants = [{ title: 'onmousescroll' }, { title: 'onclick' }, { title: 'onmousover' }, { title: 'onmousemove' }, { title: 'onmousewheel' }, { title: 'some title', description: '
    some BLOCK content
    ' }] + - quiz.current = 6 + - quiz.total = 18 + + - quiz.explanations = { title: 'Некоторые пояснения', list: []} + - quiz.explanations.list.push('Полный список браузеров, на который рассчитаны тесты: Firefox, Opera, Safari, Konqueror не более чем годовой давности и Internet Explorer 6.0+.') + - quiz.explanations.list.push('Если в вопросе ничего не сказано, то все настройки браузера - по умолчанию.') + - quiz.explanations.list.push('Версия Javascript - самая распространенная на текущий день, т.е 1.5.') + - quiz.explanations.list.push('Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи!') + +block content + include ../blocks/quiz-start + include ../blocks/quiz-explanations + include ../blocks/quiz diff --git a/handlers/markup/templates/pages/search-notfound.jade b/handlers/markup/templates/pages/search-notfound.jade new file mode 100755 index 000000000..54b6a8c0d --- /dev/null +++ b/handlers/markup/templates/pages/search-notfound.jade @@ -0,0 +1,25 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Результаты поиска'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = false + +block content + +b.search-form + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти + +e.footer + +e.status._notfound + | Мы всё перерыли, но « + +e('mark').marked клад + | » так и не нашли :-( diff --git a/handlers/markup/templates/pages/search.jade b/handlers/markup/templates/pages/search.jade new file mode 100755 index 000000000..9f7d0052e --- /dev/null +++ b/handlers/markup/templates/pages/search.jade @@ -0,0 +1,156 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Результаты поиска'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = false + +block content + script. + document.addEventListener("DOMContentLoaded", function() { + var fixedForm = document.querySelector(".search-form_fixed"); + var fixedFormInput = fixedForm.querySelector(".search-form__query .text-input__control"); + var staticFormInput = document.querySelector(".search-form:not(.search-form_fixed) .search-form__query .text-input__control"); + var fixedInputOffset = parseInt(getComputedStyle(fixedForm, "").paddingTop); + + function updateFixedForm() { + if (staticFormInput.getBoundingClientRect().top <= fixedInputOffset) { + if (fixedForm.classList.contains("search-form_hidden")) { + fixedFormInput.value = staticFormInput.value; + } + fixedForm.classList.remove("search-form_hidden"); + } else { + if (!fixedForm.classList.contains("search-form_hidden")) { + staticFormInput.value = fixedFormInput.value; + } + fixedForm.classList.add("search-form_hidden"); + } + } + + window.addEventListener("scroll", updateFixedForm); + updateFixedForm(); // set initial state + }); + + +b.search-form + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти + +e.footer + +e.types + +e('button').type(type="submit", disabled="disabled") Учебник + +e('button').type(type="submit") Задачи + + + +b.search-results + +e.result + +e.title + +e('a').title-link(href="#") + +e('mark').marked Аргументы функций + +e.extract + | …альтернативная техника работы с + = " " + +e('mark').marked аргументами + | , которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + + +b.search-form._fixed._hidden + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти diff --git a/handlers/markup/templates/pages/section-inner.jade b/handlers/markup/templates/pages/section-inner.jade new file mode 100755 index 000000000..3c7139e19 --- /dev/null +++ b/handlers/markup/templates/pages/section-inner.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Основы Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ../blocks/section-intro + include ../blocks/lessons-list-inner diff --git a/handlers/markup/templates/pages/section.jade b/handlers/markup/templates/pages/section.jade new file mode 100755 index 000000000..49bacd9d2 --- /dev/null +++ b/handlers/markup/templates/pages/section.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Основы Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ../blocks/section-intro + include ../blocks/lessons-list diff --git a/handlers/markup/templates/pages/sitemap-open.jade b/handlers/markup/templates/pages/sitemap-open.jade new file mode 100755 index 000000000..b9c9dd921 --- /dev/null +++ b/handlers/markup/templates/pages/sitemap-open.jade @@ -0,0 +1,18 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Учебник — Javascript.ru'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + - layout.articleFoot = true + - layout.tutorialMap = true + - layout.bodyClass = "tutorial-map_on" + +block content + include ./article.html diff --git a/handlers/markup/templates/pages/task.jade b/handlers/markup/templates/pages/task.jade new file mode 100755 index 000000000..37b7baade --- /dev/null +++ b/handlers/markup/templates/pages/task.jade @@ -0,0 +1,13 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "DOM Children" + +block content + include ../blocks/task-single diff --git a/handlers/markup/templates/pages/tasks.jade b/handlers/markup/templates/pages/tasks.jade new file mode 100755 index 000000000..ee828d233 --- /dev/null +++ b/handlers/markup/templates/pages/tasks.jade @@ -0,0 +1,13 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "Отдельная страница задач" + +block content + include ../blocks/tasks diff --git a/handlers/markup/templates/pages/tutorial.jade b/handlers/markup/templates/pages/tutorial.jade new file mode 100755 index 000000000..31e2c6d03 --- /dev/null +++ b/handlers/markup/templates/pages/tutorial.jade @@ -0,0 +1,113 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Современный учебник JavaScript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.intro + | Перед вами учебник по JavaScript, начиная с основ, включающий в себя много + | тонкостей и фишек JavaScript/DOM. + +b.course-search + +e('form').form(action="#") + +e.input-wrap._text + +b.text-input.__query + +e('input').control(type="text", placeholder="поиск по учебнику") + +e.input-wrap._submit + +b('button').submit-button.__submit Найти + +b.course-info + +e('h2').header Основной курс + +e.body.columns.columns_2 + +e.col.columns__col + +e.content + +e.title-note Часть первая + +e('h3').title Язык JavaScript + p Эта часть позволит вам изучить JavaScript с нуля или упорядочить и дополнить существующие знания. Мы будем использовать браузер в качестве окружения, но основное внимание будет уделяться именно самому языку JavaScript. + +b('ul').special-links-list + +e('li').item + +e('a').link(href="#") Введение + +e('li').item + +e('a').link(href="#") Основы JavaScript + +e('li').item + +e('a').link(href="#") Качество кода + +e('li').item + +e('a').link(href="#") Структуры данных + +e('li').item + +e('a').link(href="#") Замыкания, область видимости + +e('li').item + +e('a').link(href="#") Методы объектов и контекст вызова + +e('li').item + +e('a').link(href="#") Некоторые другие возможности + +e('li').item + +e('a').link(href="#") ООП в функциональном стиле + +e('li').item + +e('a').link(href="#") ООП в прототипном стиле + +e.col.columns__col + +e.content + +e.title-note Часть вторая + +e('h3').title Документ, события, интерфейсы + p Изучаем работу со страницей -- как получать элементы, манипулировать их размерами, динамически создавать интерфейсы и взаимодействовать с посетителем. + +b('ul').special-links-list + +e('li').item + +e('a').link(href="#") Документ и объекты страницы + +e('li').item + +e('a').link(href="#") Основы работы с событиями + +e('li').item + +e('a').link(href="#") События в деталях + +e('li').item + +e('a').link(href="#") Формы, элементы управления + +e('li').item + +e('a').link(href="#") Создание графических компонентов + +b.bricks + +e('h2').title Дополнительные курсы + +e.container + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") AJAX + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex? + p Сonsectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex? + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Lorem ipsum + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Dolor sit amet + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat reprehenderit enim est soluta hic. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Consectetur + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat reprehenderit enim est soluta hic, praesentium magnam laborum. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Adipisicing elit + +e.brick-content + p Lorem ipsum dolor sit amet. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Suscipit + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Voluptatibus + +e.brick-content + p Lorem ipsum. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Quisquam + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Ex quaerat + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat. diff --git a/handlers/mongooseHandler.js b/handlers/mongooseHandler.js new file mode 100755 index 000000000..6b6d54288 --- /dev/null +++ b/handlers/mongooseHandler.js @@ -0,0 +1,28 @@ +const mongoose = require('lib/mongoose'); + +const clsNamespace = require('continuation-local-storage').getNamespace('app'); + +require('cls-mongoose')(clsNamespace); + +exports.boot = function*() { + + if (process.env.NODE_ENV == 'production') { + yield function(callback) { + mongoose.waitConnect(callback); + }; + } + + /* in ebook no elasticsearch, so I don't boot it here + var elastic = elasticClient(); + yield elastic.ping({ + requestTimeout: 1000 + }); + */ +}; + + +exports.close = function*() { + yield function(callback) { + mongoose.disconnect(callback); + }; +}; diff --git a/handlers/multipartParser.js b/handlers/multipartParser.js new file mode 100755 index 000000000..7a7dfbeab --- /dev/null +++ b/handlers/multipartParser.js @@ -0,0 +1,94 @@ +const PathListCheck = require('pathListCheck'); +const multiparty = require('multiparty'); +const thunkify = require('thunkify'); + +var log = require('log')(); + +function MultipartParser() { + this.ignore = new PathListCheck(); +} + + +MultipartParser.prototype.parse = thunkify(function(req, callback) { + + var form = new multiparty.Form(); + + var hadError = false; + var fields = {}; + + form.on('field', function(name, value) { + fields[name] = value; + }); + + // multipart file must be the last + form.on('part', function(part) { + if (part.filename !== null) { + // error is made the same way as multiparty uses + callback(createError(400, 'Files are not allowed here')); + } else { + throw new Error("Must never reach this line (field event parses all fields)"); + } + part.on('error', onError); + }); + + form.on('error', onError); + + form.on('close', onDone); + + form.parse(req); + + function onDone() { + log.debug("multipart parse done", fields); + if (hadError) return; + callback(null, fields); + } + + function onError(err) { + log.debug("multipart error", err); + if (hadError) return; + hadError = true; + callback(err); + } + +}); + + +MultipartParser.prototype.middleware = function() { + var self = this; + + return function*(next) { + // skip these methods + var contentType = this.get('content-type') || ''; + if (!~['DELETE', 'POST', 'PUT', 'PATCH'].indexOf(this.method) || !contentType.startsWith('multipart/form-data')) { + return yield* next; + } + + if (!self.ignore.check(this.path)) { + this.log.debug("multipart will parse"); + + // this may throw an error w/ status 400 or 415 or... + this.request.body = yield self.parse(this.req); + + this.log.debug("multipart done parse"); + } else { + this.log.debug("multipart skip"); + } + + yield* next; + }; +}; + + +exports.init = function(app) { + app.multipartParser = new MultipartParser(); + app.use(app.multipartParser.middleware()); +}; + + +function createError(status, message) { + var error = new Error(message); + Error.captureStackTrace(error, createError); + error.status = status; + error.statusCode = status; + return error; +} diff --git a/handlers/newsletter/client/index.js b/handlers/newsletter/client/index.js new file mode 100755 index 000000000..cc3be6341 --- /dev/null +++ b/handlers/newsletter/client/index.js @@ -0,0 +1,60 @@ +var Spinner = require('client/spinner'); +var xhr = require('client/xhr'); +var notification = require('client/notification'); + +function submitSubscribeForm(form, onSuccess) { + + if (!form.elements.email.value) { + return; + } + + const request = xhr({ + method: 'POST', + url: form.action, + body: { + email: form.elements.email.value, + slug: form.elements.slug.value + } + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + request.addEventListener('loadend', ()=> { + spinner.stop(); + submitButton.disabled = false; + }); + + var formLabel = form.getAttribute('data-newsletter-subscribe-form'); + + request.addEventListener('success', function(event) { + if (this.status == 200) { + + window.metrika.reachGoal('NEWSLETTER-SUBSCRIBE', { + form: formLabel + }); + window.ga('send', 'event', 'newsletter', 'subscribe', formLabel); + + new notification.Success(event.result.message, 'slow'); + onSuccess && onSuccess(); + } else { + + window.metrika.reachGoal('NEWSLETTER-SUBSCRIBE-FAIL', { + form: formLabel + }); + window.ga('send', 'event', 'newsletter', 'subscribe-fail', formLabel); + + new notification.Error(event.result.message); + } + }); + +} + +exports.submitSubscribeForm = submitSubscribeForm; diff --git a/handlers/newsletter/controllers/action.js b/handlers/newsletter/controllers/action.js new file mode 100644 index 000000000..9c6a43485 --- /dev/null +++ b/handlers/newsletter/controllers/action.js @@ -0,0 +1,33 @@ +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const SubscriptionAction = require('../models/subscriptionAction'); +const config = require('config'); + +exports.get = function*() { + this.nocache(); + + const subscriptionAction = yield SubscriptionAction.findOne({ + accessKey: this.params.accessKey + }).exec(); + + if (!subscriptionAction) { + this.throw(404); + } + + if (subscriptionAction.applied) { + this.throw(403, "Подтверждение уже было обработано ранее."); + } + + var subscription = yield subscriptionAction.apply(); + + yield subscriptionAction.persist(); + + if (subscriptionAction.action == 'remove') { + this.body = this.render('removed'); + return; + } + + this.addFlashMessage('success', 'Подписка подтверждена.'); + this.redirect('/newsletter/subscriptions/' + subscription.accessKey); + +}; diff --git a/handlers/newsletter/controllers/frontpage.js b/handlers/newsletter/controllers/frontpage.js new file mode 100644 index 000000000..003ad65ae --- /dev/null +++ b/handlers/newsletter/controllers/frontpage.js @@ -0,0 +1,50 @@ +"use strict"; + +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const sendMail = require('mailer').send; +const config = require('config'); +const _ = require('lodash'); + +exports.get = function*() { + this.nocache(); + + var subscription; + + if (this.params.accessKey) { + subscription = yield Subscription.findOne({ + accessKey: this.params.accessKey + }).exec(); + + if (!subscription) { + this.throw(404); + } + } else if (this.user) { + subscription = yield Subscription.findOne({ + email: this.user.email + }).exec(); + } + + this.locals.email = subscription ? subscription.email : + this.user ? this.user.email : null; + + this.locals.accessKey = this.params.accessKey; + this.locals.subscription = subscription; + + var newsletters = yield Newsletter.find({}).sort({weight: 1}).exec(); + + this.locals.newsletters = newsletters.map(function(newsletter) { + return { + slug: newsletter.slug, + title: newsletter.title, + period: newsletter.period, + // mongoose array can #indexOf ObjectIds + subscribed: subscription && ~subscription.newsletters.indexOf(newsletter._id) + }; + }); + + this.body = this.render('frontpage'); + +}; + diff --git a/handlers/newsletter/controllers/subscribe.js b/handlers/newsletter/controllers/subscribe.js new file mode 100644 index 000000000..963f2dace --- /dev/null +++ b/handlers/newsletter/controllers/subscribe.js @@ -0,0 +1,173 @@ +"use strict"; + +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const SubscriptionAction = require('../models/subscriptionAction'); +const sendMail = require('mailer').send; +const config = require('config'); +const _ = require('lodash'); +const notify = require('../lib/notify'); + +const ACTION_ADD = 'add'; +const ACTION_REPLACE = 'replace'; +const ACTION_REMOVE = 'remove'; + +/** + * Subscribe to newsletters + * fields: + * slug - one or many slugs of newsletters: slug=js&slug=nodejs + * replace - boolean, whether to add or replace newsletters + * remove - boolean, if true, destroy + * accessKey - load given subscription and give full rights over it + * @returns {*} + */ +exports.post = function*() { + + var self = this; + + var subscription; + + // read subscription first + // if no subscription, error must come first before any other errors + if (this.request.body.accessKey) { + subscription = yield Subscription.findOne({ + accessKey: this.request.body.accessKey + }).exec(); + if (!subscription) { + this.throw(404, "Нет такой подписки."); + } + } else { + if (!this.request.body.email) { + this.throw(404, "Email не указан."); + } + subscription = yield Subscription.findOne({ + email: this.request.body.email + }).exec(); + } + + var email = subscription ? subscription.email : this.request.body.email; + + + // may be empty (e.g. for remove request) + var newsletterIds = yield readNewsletterIds.call(this); + + // important: + // remove has priority, because may come with (default) replace + var action = this.request.body.remove ? ACTION_REMOVE : + this.request.body.replace ? ACTION_REPLACE : ACTION_ADD; + + + // full access if user for himself OR accessKey is given + var isFullAccess = this.user && this.user.email == this.request.body.email || + subscription && subscription.accessKey == this.request.body.accessKey; + + function respond(message) { + var accepts = self.accepts('json', 'html'); + + if (accepts == 'json') { + // allow XHR from javascript.ru + if (self.get('Origin') == 'http://javascript.ru') { + self.set('Access-Control-Allow-Origin', 'http://javascript.ru'); + } + self.body = { + message: message + }; + } + + if (accepts == 'html') { + if (isFullAccess) { + if (action == ACTION_REMOVE) { + self.body = self.render("removed"); + } else { + self.addFlashMessage('success', message); + self.redirect('/newsletter/subscriptions/' + subscription.accessKey); + } + } else { + self.body = self.render("pending", {message: message}); + } + } + } + + + if (isFullAccess) { + + let subscriptionAction = new SubscriptionAction({ + action: action, + email: email, + newsletters: newsletterIds + }); + + subscription = yield* subscriptionAction.apply(); + + if (action == ACTION_REMOVE) { + respond(`Адрес ${email} удалён из базы подписок.`); + return; + } + + if (subscription) { + if (action == ACTION_ADD) { + respond(`Вы будете получать уведомления на эту тему на адрес ${email}.`); + } + + if (action == ACTION_REPLACE) { + respond(`Настройки подписок обновлены.`); + } + + } else { + respond(`Вы успешно подписаны, ждите писем на адрес ${email}.`); + } + return; + + } else { + + if (action == ACTION_REMOVE) { + // even if no subscription, we say "ok sending a letter" + // so that an anon user will not learn if the email is subscribed or not. + if (subscription) { + let subscriptionAction = yield SubscriptionAction.create({ + action: 'remove', + email: email + }); + yield notify(subscriptionAction); + } + respond(`На адрес ${email}, если он был подписан, направлен запрос подтверждения.`); + return; + } + + var subscriptionAction = yield SubscriptionAction.create({ + action: action, + newsletters: newsletterIds, + email: email + }); + + yield notify(subscriptionAction); + + respond(`На адрес ${email} направлен запрос подтверждения.`); + + } + +}; + + +function* readNewsletterIds() { + var slugs = (function readSlugs(request) { + var slugs = request.body.slug || []; + + if (!Array.isArray(slugs)) { + slugs = [slugs]; + } + slugs = slugs.map(String); + return slugs; + })(this.request); + + const newsletters = yield Newsletter.find({ + slug: { + $in: slugs + } + }).exec(); + + const newsletterIds = _.pluck(newsletters, '_id'); + + return newsletterIds; +} diff --git a/handlers/newsletter/index.js b/handlers/newsletter/index.js new file mode 100755 index 000000000..d59a483e2 --- /dev/null +++ b/handlers/newsletter/index.js @@ -0,0 +1,13 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/newsletter', __dirname) ); + + // allow to post from javascript.ru + // subscriptions require confirmation anyway, so disabling CSRF is safe + app.csrfChecker.ignore.add('/newsletter/subscribe'); +}; + +exports.Newsletter = require('./models/newsletter'); +exports.Subscription = require('./models/subscription'); diff --git a/handlers/newsletter/lib/notify.js b/handlers/newsletter/lib/notify.js new file mode 100644 index 000000000..0227ac47e --- /dev/null +++ b/handlers/newsletter/lib/notify.js @@ -0,0 +1,24 @@ +const path = require('path'); +const sendMail = require('mailer').send; +const config = require('config'); + +module.exports = function*(subscriptionAction) { + + if (subscriptionAction.action == 'remove') { + yield sendMail({ + templatePath: path.join(__dirname, '../templates/emailRemove'), + subject: "Удаление адреса из базы подписок", + to: subscriptionAction.email, + link: (config.server.siteHost || 'http://javascript.in') + '/newsletter/action/' + subscriptionAction.accessKey + }); + } else { + + yield sendMail({ + templatePath: path.join(__dirname, '../templates/emailConfirm'), + subject: "Подтвердите подписку", + to: subscriptionAction.email, + link: (config.server.siteHost || 'http://javascript.in') + '/newsletter/action/' + subscriptionAction.accessKey + }); + } + +}; diff --git a/handlers/newsletter/models/newsletter.js b/handlers/newsletter/models/newsletter.js new file mode 100755 index 000000000..32d8576eb --- /dev/null +++ b/handlers/newsletter/models/newsletter.js @@ -0,0 +1,30 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + title: { + type: String, + required: true + }, + slug: { + type: String, + required: true, + unique: true + }, + // weight for non-internal subscriptions sorting + weight: { + type: Number, + default: 0, + required: true + }, + // how often? string description + period: { + type: String + }, + created: { + type: Date, + default: Date.now + } +}); + +var Newsletter = module.exports = mongoose.model('Newsletter', schema); diff --git a/handlers/newsletter/models/newsletterRelease.js b/handlers/newsletter/models/newsletterRelease.js new file mode 100755 index 000000000..ce77d6d39 --- /dev/null +++ b/handlers/newsletter/models/newsletterRelease.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const Letter = require('mailer').Letter; + +// keeps all important features for a letter release +// can use it to send more to the same newsleters subscribers (when they appear) +const schema = new Schema({ + newsletters: { + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }], + required: true + }, + newslettersExcept: { + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }] + }, + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('NewsletterRelease', schema); diff --git a/handlers/newsletter/models/subscription.js b/handlers/newsletter/models/subscription.js new file mode 100755 index 000000000..755fc1196 --- /dev/null +++ b/handlers/newsletter/models/subscription.js @@ -0,0 +1,49 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const crypto = require('crypto'); +const _ = require('lodash'); + +const schema = new Schema({ + newsletters: { + // can be empty + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }], + default: [], + validate: [ + { + validator: function mustBeUnique(value) { + return _.uniq(value).length == value.length; + }, + msg: 'Список подписок содержит дубликаты.' + } + ] + }, + email: { + type: String, + required: true, + unique: true, + validate: [ + { + validator: function checkEmail(value) { + return /^[-.\w+]+@([\w-]+\.)+[\w-]{2,12}$/.test(value); + }, + msg: 'Укажите, пожалуйста, корректный email.' + } + ] + }, + accessKey: { + type: String, + unique: true, + default: function() { + return parseInt(crypto.randomBytes(6).toString('hex'), 16).toString(36); + } + }, + created: { + type: Date, + default: Date.now + } +}); + +var Subscription = module.exports = mongoose.model('Subscription', schema); diff --git a/handlers/newsletter/models/subscriptionAction.js b/handlers/newsletter/models/subscriptionAction.js new file mode 100644 index 000000000..07e979789 --- /dev/null +++ b/handlers/newsletter/models/subscriptionAction.js @@ -0,0 +1,103 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const crypto = require('crypto'); +const _ = require('lodash'); +const Subscription = require('./subscription'); + +const schema = new Schema({ + action: { + type: String, + enum: ['add', 'remove', 'replace'], + required: true + }, + + applied: { + type: Boolean + }, + + newsletters: { + // can be empty + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }], + default: [], + validate: [ + { + validator: function mustBeUnique(value) { + return _.uniq(value).length == value.length; + }, + msg: 'Список подписок содержит дубликаты.' + } + ] + }, + email: { + type: String, + required: true, + validate: [ + { + validator: function checkEmail(value) { + return /^[-.\w+]+@([\w-]+\.)+[\w-]{2,12}$/.test(value); + }, + msg: 'Укажите, пожалуйста, корректный email.' + } + ] + }, + accessKey: { + type: String, + unique: true, + required: true, + default: function() { + return parseInt(crypto.randomBytes(6).toString('hex'), 16).toString(36); + } + }, + created: { + type: Date, + default: Date.now + } +}); + +schema.methods.apply = function*() { + + var subscription = yield Subscription.findOne({ + email: this.email + }); + + if (this.newsletters.length && this.newsletters[0]._id) { + throw new Error("Newsletters must not be populated"); + } + + if (this.action == 'remove') { + if (subscription) { + yield subscription.remove(); + } + this.applied = true; + return; + } + + if (!subscription) { + subscription = new Subscription({ + email: this.email + }); + } + + if (this.action == 'add') { + + this.newsletters.forEach(function(id) { + subscription.newsletters.addToSet(id); + }, this); + + yield subscription.persist(); + + } + + if (this.action == 'replace') { + subscription.newsletters = this.newsletters; + yield subscription.persist(); + } + + this.applied = true; + return subscription; +}; + +var SubscriptionAction = module.exports = mongoose.model('SubscriptionAction', schema); diff --git a/handlers/newsletter/router.js b/handlers/newsletter/router.js new file mode 100755 index 000000000..fc735eff1 --- /dev/null +++ b/handlers/newsletter/router.js @@ -0,0 +1,16 @@ +var Router = require('koa-router'); + +var action = require('./controllers/action'); +var frontpage = require('./controllers/frontpage'); +//var subscriptions = require('./controllers/subscriptions'); +var subscribe = require('./controllers/subscribe'); + +var router = module.exports = new Router(); + +router.post("/subscribe", subscribe.post); +router.get("/", frontpage.get); +//router.post("/", frontpage.post); +router.get("/action/:accessKey", action.get); + +router.get("/subscriptions/:accessKey", frontpage.get); + diff --git a/handlers/newsletter/tasks/createLetters.js b/handlers/newsletter/tasks/createLetters.js new file mode 100644 index 000000000..af5e0057b --- /dev/null +++ b/handlers/newsletter/tasks/createLetters.js @@ -0,0 +1,137 @@ +var co = require('co'); +var fs = require('fs'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const NewsletterRelease = require('../models/newsletterRelease'); +const Subscription = require('../models/subscription'); +const mailer = require('mailer'); +const config = require('config'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Slug is required.") + // gulp newsletter:createLetters --slug js --templatePath ./extra/newsletters/js-1405.jade --subject 'Курс JavaScript: напоминание о собрании' --test iliakan@gmail.com --nounsubscribe + .example("gulp newsletter:createLetters --slug nodejs --templatePath ./mail.jade --subject 'Тема письма'") + .describe('slug', 'Названия рассылок NewsLetter через запятую') + .describe('slugExcept', 'Названия рассылок NewsLetter через запятую, подписчиком которых не слать') + .describe('templatePath', 'Шаблон для рассылки') + .describe('subject', 'Тема письма') + .describe('test', 'Email, на который выслать тестовое письмо.') + .describe('nounsubscribe', 'Без ссылки на отписку.') + .describe('track', 'Отслеживать переходы.') + .demand(['slug', 'templatePath', 'subject']) + .argv; + + return co(function* () { + + var slugs = args.slug.split(',').filter(String); + var slugExcepts = args.slugExcept ? args.slugExcept.split(',').filter(String) : []; + + var newsletters = yield Newsletter.find({ + slug: { + $in: slugs + } + }).exec(); + + if (newsletters.length != slugs.length) { + throw new Error("Can't find one or more newsletters with slugs: " + args.slug); + } + + var newslettersExcept = yield Newsletter.find({ + slug: { + $in: slugExcepts + } + }).exec(); + + if (newslettersExcept.length != slugExcepts.length) { + throw new Error("Can't find one or more newsletters with slugExcepts: " + args.except); + } + + var newsletterIdToSlug = {}; + newsletters.forEach(function(n) { + newsletterIdToSlug[n._id.toString()] = n.slug; + }); + + var newsletterIds = newsletters.map(function(n) { + return n._id; + }); + var newsletterExceptIds = newslettersExcept.map(function(n) { + return n._id; + }); + + var release = yield NewsletterRelease.create({ + newsletters: newsletterIds, + newslettersExcept: newsletterExceptIds + }); + + + // subscriptions which match any of newsletter slugs + var subscriptions = yield Subscription.find({ + newsletters: { + $in: release.newsletters, + $nin: release.newslettersExcept + } + }, {email: true, newsletters: true, accessKey: true, _id: false}).exec(); + + + if (args.test) { + subscriptions = [ + new Subscription({ + email: args.test, + accessKey: 'test', + newsletters: newsletters.map(function(n) { + return n._id; + }) + }) + ]; + } + + for (var i = 0; i < subscriptions.length; i++) { + var subscription = subscriptions[i]; + var unsubscribeUrl = args.nounsubscribe ? + null : + config.server.siteHost + '/newsletter/subscriptions/' + subscription.accessKey; + + + // listSlug is generated from a first newsletter, which causes the user to receive the message + var listSlug = ''; + for (var j = 0; j < subscription.newsletters.length; j++) { + var subscriptionNewsletterId = String(subscription.newsletters[j]); + listSlug = newsletterIdToSlug[subscriptionNewsletterId]; + if (listSlug) break; + } + + if (!listSlug) { + throw new Error("No list slug for subscription (why receiving?) " + subscription._id); + } + + yield* mailer.createLetter({ + from: 'informer', + templatePath: args.templatePath, + track_clicks: args.track ? true : false, + to: subscription.email, + subject: args.subject, + unsubscribeUrl: unsubscribeUrl, + labelId: release._id, + headers: { + Precedence: 'bulk', + 'List-ID': '<' + listSlug + '.list-id.javascript.ru>', + 'List-Unsubscribe': '<' + unsubscribeUrl + '>' + } + }); + + gutil.log("Created letter to " + subscription.email); + + } + + }); + + + }; +}; diff --git a/handlers/newsletter/tasks/send.js b/handlers/newsletter/tasks/send.js new file mode 100755 index 000000000..77e3b7fcd --- /dev/null +++ b/handlers/newsletter/tasks/send.js @@ -0,0 +1,40 @@ +var co = require('co'); +var fs = require('fs'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const mailer = require('mailer'); +const Letter = require('mailer').Letter; +const config = require('config'); + +// Sends all newsletter letters +module.exports = function(options) { + + return function() { + + return co(function* () { + + var letters = yield Letter.find({ + sent: false, + // only labelled emails (groups, newsletters), not transient ones + labelId: { + $exists: true + } + }).exec(); + + for (var i = 0; i < letters.length; i++) { + var letter = letters[i]; + + yield mailer.sendLetter(letter); + gutil.log("Sent to " + (letter.message.to.length < 50 ? JSON.stringify(letter.message.to) : letter.message.to.length)); + } + + }); + + }; +}; + + diff --git a/handlers/newsletter/templates/emailConfirm.jade b/handlers/newsletter/templates/emailConfirm.jade new file mode 100644 index 000000000..2aa7d60d8 --- /dev/null +++ b/handlers/newsletter/templates/emailConfirm.jade @@ -0,0 +1,12 @@ +extends /layouts/email + +block body + h1= subject + p Вы запрашивали подписку на уведомления или изменяли настройки существующих подписок на сайте JavaScript.ru? + + p Если да — подтвердите это, перейдя по ссылке + = ' ' + a(href=link)= link + | . + + diff --git a/handlers/newsletter/templates/emailRemove.jade b/handlers/newsletter/templates/emailRemove.jade new file mode 100644 index 000000000..ecfce99db --- /dev/null +++ b/handlers/newsletter/templates/emailRemove.jade @@ -0,0 +1,12 @@ +extends /layouts/email + +block body + h1= subject + p Вы запрашивали удаление своего адреса из базы подписок на сайте JavaScript.ru? + + p Если да — подтвердите это, перейдя по ссылке + = ' ' + a(href=link)= link + | . Если нет – игнорируйте это письмо. + + diff --git a/handlers/newsletter/templates/frontpage.jade b/handlers/newsletter/templates/frontpage.jade new file mode 100644 index 000000000..d30b01393 --- /dev/null +++ b/handlers/newsletter/templates/frontpage.jade @@ -0,0 +1,59 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Управление подписками" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + style. + .main ul > li:before { + content: ''; + } + .main ul { + padding: 0; + } + + form(method="POST", action="/newsletter/subscribe") + + //- subscription can come from accessKey or current user or may be no subscription + if email + p Управление подписками для #{email}. + else + p Ваш email: + + +b('span').text-input + +e('input').control#forgot-email(name="email" type="email" placeholder="my@mail.com", required, pattern=validate.patterns.email) + + input(type="hidden", name="_csrf", value=csrf()) + if accessKey + input(type="hidden", name="accessKey", value=accessKey) + else if email + input(type="hidden", name="email", value=email) + + input(type="hidden", name="replace", value=1) + + p Темы: + + ul + each newsletter in newsletters + li + label + input(type="checkbox", name="slug", value=newsletter.slug, checked=newsletter.subscribed || undefined) + = ' ' + = newsletter.title + = ' (' + = newsletter.period + | ) + + li + label + input(type="checkbox", name="remove" value="1") + = ' ' + | Удалить адрес из подписок + + + +b('button').button._action.__save(type="submit") + +e('span').text Сохранить diff --git a/handlers/newsletter/templates/pending.jade b/handlers/newsletter/templates/pending.jade new file mode 100644 index 000000000..4ce431cbb --- /dev/null +++ b/handlers/newsletter/templates/pending.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Ожидается подтверждение" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + + p= message diff --git a/handlers/newsletter/templates/removed.jade b/handlers/newsletter/templates/removed.jade new file mode 100755 index 000000000..36475737b --- /dev/null +++ b/handlers/newsletter/templates/removed.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Адрес удалён" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + + p Ваш адрес удалён из базы подписок. diff --git a/handlers/newsletter/test/fixtures/db.js b/handlers/newsletter/test/fixtures/db.js new file mode 100644 index 000000000..1d47b4f97 --- /dev/null +++ b/handlers/newsletter/test/fixtures/db.js @@ -0,0 +1,57 @@ +require('users').User; +require('newsletter').Newsletter; +require('newsletter').Subscription; +const ObjectId = require('mongoose').Types.ObjectId; + +exports.User = [ + { + "_id": "000000000000000000000001", + "created": new Date(2014, 0, 1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "profileName": "iliakan", + "password": "1234", + "verifiedEmail": true + }, + { + "_id": "000000000000000000000002", + "created": new Date(2015, 0, 1), + "displayName": "tester", + "email": "mk@javascript.ru", + "profileName": "mk", + "password": "1234", + "verifiedEmail": true + } +]; + +exports.Subscription = [ + { + email: 'mk@javascript.ru', + newsletters: [new ObjectId("100000000000000000000001")] + } +]; + +exports.Newsletter = [ + { + "_id": "100000000000000000000001", + title: "Курс и скринкасты по Node.JS / IO.JS", + slug: "nodejs", + period: "несколько раз в год", + weight: 1 + }, + { + "_id": "100000000000000000000002", + title: "Курс JavaScript/DOM/интерфейсы", + period: "раз в 1.5-2 месяца", + weight: 0, + slug: "js" + }, + { + "_id": "100000000000000000000003", + title: "Продвинутые курсы, мастер-классы и конференции по JavaScript", + period: "редко", + weight: 2, + slug: "advanced" + } +]; + diff --git a/handlers/newsletter/test/server/subscribe.js b/handlers/newsletter/test/server/subscribe.js new file mode 100644 index 000000000..1a2451375 --- /dev/null +++ b/handlers/newsletter/test/server/subscribe.js @@ -0,0 +1,140 @@ +/* globals describe, it, before */ + +const _ = require('lodash'); +const db = require('lib/dataUtil'); +const mongoose = require('mongoose'); +const path = require('path'); +const request = require('supertest'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); +const app = require('app'); +const assert = require('better-assert'); +const Subscription = require('../../models/subscription'); +const should = require('should'); + +describe('Subscribe', function() { + + var server; + before(function*() { + yield* db.loadModels(fixtures, {reset: true}); + server = app.listen(); + }); + + after(function() { + server.close(); + }); + + describe('registered user', function() { + + describe("when email matches user's email", function() { + + it("when newsletter exists and no subscription exists => creates a new subscription", function*() { + + var user = fixtures.User[0]; + yield function(done) { + request(server) + .post('/newsletter/subscribe') + .set('X-Test-User-Id', user._id) + .send({ + email: user.email, + slug: 'js' + }) + .expect(200, done); + }; + + var subscription = yield Subscription.findOne({ + email: user.email + }).exec(); + + subscription.newsletters.map(String).should.be.eql( + _(fixtures.Newsletter).where({slug: 'js'}).pluck('_id').value() + ); + + }); + + it("when newsletter exists and subscription exists and action add => adds to the list", function*() { + var user = fixtures.User[1]; + + yield function(done) { + request(server) + .post('/newsletter/subscribe') + .set('X-Test-User-Id', user._id) + .send({ + email: user.email, + slug: 'js' + }) + .expect(200, done); + }; + + + var subscription = yield Subscription.findOne({ + email: user.email + }).exec(); + + subscription.newsletters.map(String).should.be.eql( + _(fixtures.Newsletter) + .filter(function(n) { return n.slug == 'js' || n.slug == 'nodejs'; } ) + .pluck('_id') + .value() + ); + }); + + it("when newsletter exists and subscription exists and multiple slugs given & replace action => replaces subscription newsletters", function*() { + var user = fixtures.User[1]; + + yield function(done) { + request(server) + .post('/newsletter/subscribe') + .set('X-Test-User-Id', user._id) + .send({ + email: user.email, + replace: true, + slug: ['nodejs', 'advanced'] + }) + .expect(200, done); + }; + + + var subscription = yield Subscription.findOne({ + email: user.email + }).exec(); + + subscription.newsletters.map(String).sort().should.be.eql( + _(fixtures.Newsletter) + .filter(function(n) { return n.slug == 'nodejs' || n.slug == 'advanced'; } ) + .pluck('_id') + .value() + .sort() + ); + }); + + it("when newsletter exists and subscription exists and remove => deletes subscription", function*() { + var user = fixtures.User[1]; + + yield function(done) { + request(server) + .post('/newsletter/subscribe') + .set('X-Test-User-Id', user._id) + .send({ + email: user.email, + slug: ['nodejs', 'advanced'], + remove: true + }) + .expect(200, done); + }; + + + var subscription = yield Subscription.findOne({ + email: user.email + }).exec(); + + should.not.exist(subscription); + + }); + + + }); + + + }); + +}); diff --git a/handlers/nocache.js b/handlers/nocache.js new file mode 100755 index 000000000..27d562b01 --- /dev/null +++ b/handlers/nocache.js @@ -0,0 +1,12 @@ + +exports.init = function(app) { + app.use(function*(next) { + + this.nocache = function() { + this.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + }; + + yield* next; + }); + +}; diff --git a/handlers/nodejsScreencast/client/index.js b/handlers/nodejsScreencast/client/index.js new file mode 100755 index 000000000..c63697d24 --- /dev/null +++ b/handlers/nodejsScreencast/client/index.js @@ -0,0 +1,115 @@ +var Modal = require('client/head/modal'); +var courseForm = require('../templates/course-form.jade'); +var clientRender = require('client/clientRender'); +var newsletter = require('newsletter/client'); +var gaHitCallback = require('gaHitCallback'); + +function init() { + initList(); + + var form = document.querySelector('[data-newsletter-subscribe-form]'); + + form.onsubmit = function(event) { + event.preventDefault(); + newsletter.submitSubscribeForm(form); + }; + + var link = document.querySelector('[data-nodejs-screencast-top-subscribe]'); + + if (link) { + link.onclick = function(event) { + var modal = new Modal(); + modal.setContent(clientRender(courseForm)); + + var form = modal.elem.querySelector('form'); + form.setAttribute('data-newsletter-subscribe-form', 'nodejs-top'); + form.onsubmit = function(event) { + event.preventDefault(); + newsletter.submitSubscribeForm(form, function() { + modal.remove(); + }); + }; + + event.preventDefault(); + }; + } +} + +function initList() { + var lis = document.querySelectorAll('li[data-mnemo]'); + + for (var i = 0; i < lis.length; i++) { + var li = lis[i]; + var mnemo = li.getAttribute('data-mnemo'); + + li.insertAdjacentHTML( + 'beforeEnd', + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + ); + } + + var links = document.querySelectorAll('a[data-video-id]'); + for (var i = 0; i < links.length; i++) { + var link = links[i]; + link.onclick = function(e) { + e.preventDefault(); + var videoId = this.getAttribute('data-video-id'); + window.ga('send', 'event', 'nodejs-screencast', 'open', videoId, { + hitCallback: gaHitCallback(function() { + openVideo(videoId); + }) + }); + }; + } +} + +function openVideo(videoId) { + // sizes from https://developers.google.com/youtube/iframe_api_reference + var sizeList = [ + {width: 0, height: 0}, // mobile screens lower than any player => new window + {width: 640, height: 360 + 30}, + {width: 853, height: 480 + 30}, + {width: 1280, height: 720 + 30} + ]; + + for(var i=0; i` + ); + } + }) + }); + + +} + +init(); diff --git a/handlers/nodejsScreencast/controllers/index.js b/handlers/nodejsScreencast/controllers/index.js new file mode 100755 index 000000000..fb95461bb --- /dev/null +++ b/handlers/nodejsScreencast/controllers/index.js @@ -0,0 +1,31 @@ +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +var CourseGroup = require('courses').CourseGroup; +var Course = require('courses').Course; +var money = require('money'); +var moment = require('momentWithLocale'); + +exports.get = function*() { + this.locals.siteToolbarCurrentSection = "nodejs-screencast"; + + this.locals.rateUsdRub = money.convert(1, {from: 'USD', to: 'RUB'}); + + var course = yield Course.findOne({slug: 'nodejs'}).exec(); + this.locals.groups = []; + if (course) { + this.locals.groups = yield CourseGroup.find({ + course: course._id, + isOpenForSignup: true + }).sort({dateStart: 1}).exec(); + } + + this.locals.formatGroupDate = function(date) { + return moment(date).format('D MMM YYYY').replace(/[а-я]/, function(letter) { + return letter.toUpperCase(); + }); + }; + + this.body = this.render('index'); +}; diff --git a/handlers/nodejsScreencast/index.js b/handlers/nodejsScreencast/index.js new file mode 100755 index 000000000..bd792567c --- /dev/null +++ b/handlers/nodejsScreencast/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/nodejs-screencast', __dirname)); +}; + diff --git a/handlers/nodejsScreencast/router.js b/handlers/nodejsScreencast/router.js new file mode 100755 index 000000000..e0509cf7f --- /dev/null +++ b/handlers/nodejsScreencast/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); diff --git a/handlers/nodejsScreencast/templates/_course-form.jade b/handlers/nodejsScreencast/templates/_course-form.jade new file mode 100755 index 000000000..e975d57dc --- /dev/null +++ b/handlers/nodejsScreencast/templates/_course-form.jade @@ -0,0 +1,40 @@ + ++b.course-form + +e("h3").title Курс и новые выпуски скринкаста по Node.JS + +e("p").text Время от времени я провожу онлайн-курс по Node.JS / IO.JS. + +e("p") Курс — это практика, решение задач на Node.JS, изучение современной разработки на нём. + + if groups.length + +e('h4').title._recruitment Сейчас проходит набор в группы + +b.courses-recruitment + +e('ul').list + + each group in groups + +e('li').course + +e.info + +e('h4').title !{ formatGroupDate(group.dateStart) } — !{ formatGroupDate(group.dateEnd) } + +e('p').text!= group.timeDesc + + +e.apply + +b.price + +e('span') #{group.price} RUB + +e('span').secondary  ≈ #{Math.round(group.price / rateUsdRub)}$ + +e.submit + +b('a')(data-group-signup-link href='/courses/nodejs' type="button").button._action + +e('span').text Узнать программу + p Также вы можете запросить уведомления о наборе новых групп по этой программе. + + else + +e("p") Пришлю уведомление с деталями программы, когда будет открыта запись, и вы сможете решить, интересно ли это вам. Также уведомление будет при новых выпусках скринкаста. + + +e("form").form(data-newsletter-subscribe-form="nodejs-bottom" onsubmit="return false" action="/newsletter/subscribe" method="POST") + input(type="hidden" value="nodejs" name="slug") + + + +b.text-input-button + +e.input + +b.text-input + +e('input').control(type="email" placeholder="me@mail.com" name="email" value=(user && user.email) data-modal-autofocus required) + +e.button + +b('button')(class=["button", groups.length ? "_common" : "_action"] type="submit") + +e('span').text Уведомите меня diff --git a/handlers/nodejsScreencast/templates/course-form.jade b/handlers/nodejsScreencast/templates/course-form.jade new file mode 100755 index 000000000..a8dbc3aa4 --- /dev/null +++ b/handlers/nodejsScreencast/templates/course-form.jade @@ -0,0 +1,3 @@ +include /bem + +include _course-form diff --git a/handlers/nodejsScreencast/templates/index.jade b/handlers/nodejsScreencast/templates/index.jade new file mode 100755 index 000000000..1d1dccefc --- /dev/null +++ b/handlers/nodejsScreencast/templates/index.jade @@ -0,0 +1,77 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Скринкаст по Node.JS'; + - var sitetoolbar = true + - var header = true + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + +block append head + !=js("nodejsScreencast", {defer: true}) + +block content + +b.nodejs-screencast-header + + +e("h1").title Скринкаст + //- two spaces! one is consumed, one remains + +e("strong").title-accent NODE.JS + + +e("p").description + | Основные возможности и средства создания веб-сервисов, включая внутренние особенности самого сервера Node.JS + + +e("ul").buttons + + +e("li").button + +b("a")(href="https://www.youtube.com/playlist?list=PLDyvV36pndZFWfEQpNixIHVvp191Hb3Gg").simple-button + +e.text + | Смотреть на + +e("strong").accent Youtube + +e.description Плейлист 43 записи + + +e("li").button + +b("a")(href="/nodejs-screencast/nodejs-mp4-low.zip").simple-button + +e.text + | Скачать скринкаст + +e.description Компактный размер + +e("strong").description-accent (228Mb) + + +e("li").button + +b("a")(href="/nodejs-screencast/nodejs-mp4.zip").simple-button + +e.text + | Скачать скринкаст + +e.description Хорошее качество + +e("strong").description-accent (4Gb) + + +e("p").info Если вы где-то выкладываете этот скринкаст (торрент и т.п.), то обязательно давайте ссылку на эту страницу, так как все обновления и важные изменения я публикую здесь. + + +b.share-icons + +e('span').title Поделиться + include /blocks/social-icons + + + +e("p").foot Ниже вы можете ознакомиться более детально с содержанием скринкаста + + if groups + +e("a")(href="/courses/nodejs").tag Не прозевайте
    курсы по Node.js! + else + +e("a")(href="/courses/nodejs" data-nodejs-screencast-top-subscribe).tag Не прозевайте
    курсы по Node.js! + + + .main(class='main_width-limit-wide').nodejs-screencast-content + + include:simpledown index.md + + +b.faq-cite + +e("h2").title Ответы на частые вопросы: + +e.list + +e('dl') + dt У меня Windows, пытаюсь запустить скрипт в cmd, набираю "node server.js" — выдаёт ошибку, что делать? + dd Перейдите в нужную директорию командой "CD <директория, в которой у вас находится server.js>". Например: "CD C:\node". Оттуда и запускайте. + dt Пробую запускать в FAR, но не вижу вывода скрипта. + dd Нажмите Ctrl + O, это отключит панели FAR и вы сможете всё видеть. Нажмите ещё раз — и панели снова появятся. + + include _course-form + + include /blocks/comments + diff --git a/handlers/nodejsScreencast/templates/index.md b/handlers/nodejsScreencast/templates/index.md new file mode 100755 index 000000000..cf6c2b725 --- /dev/null +++ b/handlers/nodejsScreencast/templates/index.md @@ -0,0 +1,111 @@ + +Вашему вниманию предлагается скринкаст по Node.JS на русском языке. + +Его целью не является разбор всех-всех возможностей и модулей Node.JS, ведь многие из них используются очень редко. + +С другой стороны, мы очень подробно разберём основные возможности и средства создания веб-сервисов, +включая внутренние особенности самого сервера Node.JS, важные для его работы. + +Если вы -- разработчик, то вам наверняка известно: большинство полезной документации и скринкастов делается на английском. + +Конечно, даже на английском много всего устаревшего, приходится порыться, но на русском -- всё гораздо хуже. +Многого просто нет. Хотелось бы поменять эту ситуацию, хотя бы в плане Node.JS. + +## Часть 1: Изучаем Node.JS + +Выпуски были записаны для Node 0.10. + +Каждую запись можно просмотреть или скачать в низком и хорошем качестве. + + + +## Часть 2: Создаём приложение + +В этой части разные технологии и внешние модули, используемые при NodeJS-разработке будут описаны в контексте создания веб-приложения. + +Веб-приложение -- сайт с чатом, посетителями, базой данных и авторизацией. + +[smart header="Express 3 -> Express 4"] +Вторая часть записана с версией фреймворка express 3, сейчас уже express 4. +Устаревшие фичи express3 в скринкасте не используются, так что это единственное существенное отличие -- в express 4 многие библиотеки вынесены отдельно из фреймворка, см. [Migrating from 3.x to 4.x](https://github.com/visionmedia/express/wiki/Migrating-from-3.x-to-4.x). +Если вы хотите следовать скринкасту, то рекомендуется `npm i express@3`, переход на 4 будет для вас очевиден. + +Вторую часть можно использовать и в качестве основы для перехода к более современным фреймворкам, таким как [KoaJS](http://koajs.com). +[/smart] + + + + + +Дополнительно: + + + + + +## Код + +Код к большинству выпусков находится в здесь: [](https://github.com/iliakan/nodejs-screencast), его также можно скачать и в виде [zip-файла](https://github.com/iliakan/nodejs-screencast/archive/master.zip). diff --git a/handlers/passportRememberMe/index.js b/handlers/passportRememberMe/index.js new file mode 100755 index 000000000..cd1df0ac1 --- /dev/null +++ b/handlers/passportRememberMe/index.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); +const passport = require('koa-passport'); +const config = require('config'); +const User = require('users').User; +const RememberMeStrategy = require('./rememberMeStrategy'); +const RememberMeToken = require('./rememberMeToken'); + +// auto logs in X-Test-User-Id when testing +exports.init = function(app) { + + // this strategy stands aside from others, because it has no route + // works automatically, just as sessions (and essentialy is a session add-on) + + var options = config.auth.rememberMe; + passport.use(new RememberMeStrategy(options, RememberMeToken.consume, RememberMeToken.issue)); + + app.use(passport.authenticate('remember-me')); + + app.use(function*(next) { + + this.rememberMe = function*() { + var token = new RememberMeToken({ + user: this.user + }); + yield token.persist(); + this.cookies.set(options.key, token.value, options.cookie); + }; + + yield* next; + + }); +}; + diff --git a/handlers/passportRememberMe/rememberMeStrategy.js b/handlers/passportRememberMe/rememberMeStrategy.js new file mode 100755 index 000000000..ff481f090 --- /dev/null +++ b/handlers/passportRememberMe/rememberMeStrategy.js @@ -0,0 +1,84 @@ +var passport = require('koa-passport'); +var util = require('util'); + +function Strategy(options, verify, issue) { + this._opts = options; + + if (!verify) throw new Error('remember me cookie authentication strategy requires a verify function'); + if (!issue) throw new Error('remember me cookie authentication strategy requires an issue function'); + + this._key = options.key; + + passport.Strategy.call(this); + this.name = 'remember-me'; + this._verify = verify; + this._issue = issue; +} + +util.inherits(Strategy, passport.Strategy); + +/** + * Authenticate request based on remember me cookie. + * + * @param {Object} req + * @api protected + */ +Strategy.prototype.authenticate = function(req, options) { + // The rememeber me cookie is only consumed if the request is not + // authenticated. This is in preference to the session, which is typically + // established at the same time the remember me cookie is issued. + if (req.isAuthenticated()) { return this.pass(); } + + var token = req.cookies.get(this._key); + + // Since the remember me cookie is primarily a convenience, the lack of one is + // not a failure. In this case, a response should be rendered indicating a + // logged out state, rather than denying the request. + if (!token) { return this.pass(); } + + var self = this; + + function verified(err, user, info) { + if (err) { return self.error(err); } + + // Express exposes the response to the request. We need the response to set + // a cookie, so we'll grab it this way. This breaks the encapsulation of + // Passport's Strategy API, but is acceptable for this strategy. + //var res = req.res; + + if (!user) { + // The remember me cookie was not valid. However, because this + // authentication method is primarily a convenience, we don't want to + // deny the request. Instead we'll clear the invalid cookie and proceed + // to respond in a manner which indicates a logged out state. + // + // Note that a failure at this point may indicate a possible theft of the + // cookie. If handling this situation is a requirement, it is up to the + // application to encode the value in such a way that this can be detected. + // For a discussion on such matters, refer to: + // http://fishbowl.pastiche.org/2004/01/19/persistent_login_cookie_best_practice/ + // http://jaspan.com/improved_persistent_login_cookie_best_practice + // http://web.archive.org/web/20130214051957/http://jaspan.com/improved_persistent_login_cookie_best_practice + // http://stackoverflow.com/questions/549/the-definitive-guide-to-forms-based-website-authentication + + req.cookies.set(self._key); // delete cookie + return self.pass(); + } + + // The remember me cookie was valid and consumed. For security reasons, + // the just-used token should have been invalidated by the application. + // A new token will be issued and set as the value of the remember me + // cookie. + function issued(err, val) { + if (err) { return self.error(err); } + req.cookies.set(self._key, val, self._opts.cookie); + return self.success(user, info); + } + + self._issue(user, issued); + } + + self._verify(token, verified); +}; + +module.exports = Strategy; diff --git a/handlers/passportRememberMe/rememberMeToken.js b/handlers/passportRememberMe/rememberMeToken.js new file mode 100755 index 000000000..2badd91be --- /dev/null +++ b/handlers/passportRememberMe/rememberMeToken.js @@ -0,0 +1,71 @@ + +var mongoose = require('mongoose'); +var crypto = require('crypto'); + +var RememberMeTokenSchema = new mongoose.Schema({ + user: { + required: true, + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + + value: { + type: String, + unique: true, + default: function() { + // 8-9 random alphanumeric chars + return parseInt(crypto.randomBytes(5).toString('hex'), 16).toString(36); + } + }, + + createdAt: { + type: Date, + default: new Date(), + expires: 7 * 24 * 3600 // token lives for 7 days + } +}); + +// find user by tokenValue and kill the token after success +RememberMeTokenSchema.statics.consume = function(tokenValue, done) { + + RememberMeToken.findOne({ + value: tokenValue + }).populate('user') + .exec(function(err, token) { + if (err) return done(err); + if (!token) return done(null, false); + + var user = token.user; + + token.remove(function(err) { + if (err) return done(err); + + if (!user || user.deleted) { + done(null, false); + } else { + done(null, user); + } + }); + + }); + +}; + +// create a new token for user and return it's value +RememberMeTokenSchema.statics.issue = function(user, done) { + + var token = new RememberMeToken({ + user: user + }); + + token.save(function(err) { + if (err) return done(err); + return done(null, token.value); + }); + +}; + + +var RememberMeToken = mongoose.model('RememberMeToken', RememberMeTokenSchema); + +module.exports = RememberMeToken; diff --git a/handlers/passportSession/index.js b/handlers/passportSession/index.js new file mode 100755 index 000000000..ea5f6e909 --- /dev/null +++ b/handlers/passportSession/index.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); +const passport = require('koa-passport'); +const config = require('config'); +const User = require('users').User; + +// @see auth for strategies + +passport.serializeUser(function(user, done) { + done(null, user.id); // uses _id as idFieldd +}); + +passport.deserializeUser(function(id, done) { + User.findById(id, done); // callback version checks id validity automatically +}); + +// auto logs in X-Test-User-Id when testing +exports.init = function(app) { + + app.use(function* cleanEmptySessionPassport(next) { + yield* next; + if (this.session && this.session.passport && Object.keys(this.session.passport).length === 0) { + delete this.session.passport; + } + }); + + app.use(function* defineUserGetter(next) { + Object.defineProperty(this, 'user', { + get: function() { + return this.req.user; + } + }); + yield* next; + }); + + + + app.use(passport.initialize()); + app.use(passport.session()); + + if (process.env.NODE_ENV == 'test') { + app.use(testAutoLogin); + } + +}; + +function* testAutoLogin(next) { + var userId = this.get('X-Test-User-Id'); + if (!userId) { + yield* next; + return; + } + + var user = yield User.findById(userId).exec(); + + if (!user) { + this.throw(500, "No test user " + userId); + } + + yield this.login(user); + + yield* next; +} diff --git a/handlers/payments/banksimple/controller/invoice.docx b/handlers/payments/banksimple/controller/invoice.docx new file mode 100755 index 000000000..86e9e8c3a Binary files /dev/null and b/handlers/payments/banksimple/controller/invoice.docx differ diff --git a/handlers/payments/banksimple/controller/invoice.js b/handlers/payments/banksimple/controller/invoice.js new file mode 100755 index 000000000..9ceb13a59 --- /dev/null +++ b/handlers/payments/banksimple/controller/invoice.js @@ -0,0 +1,42 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var bankConfig = require('config').payments.modules.banksimple; + +// Load the docx file as a binary +// @see https://github.com/open-xml-templating/docxtemplater +var invoiceDocContent = fs.readFileSync(path.join(__dirname, "invoice.docx"), "binary"); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'banksimple') { + this.log.debug("Improper TX", this.transaction.toObject()); + } + + var invoiceDoc = new Docxtemplater(invoiceDocContent); + + invoiceDoc.setData({ + COMPANY_NAME: bankConfig.COMPANY_NAME, + INN: bankConfig.INN, + ACCOUNT: bankConfig.ACCOUNT, + BANK: bankConfig.BANK, + CORR_ACC: bankConfig.CORR_ACC, + BIK: bankConfig.BIK, + PAYMENT_DESCRIPTION: `Оплата по счёту ${this.transaction.number}`, + AMOUNT: this.transaction.amount + }); + + // apply replacements + invoiceDoc.render(); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; diff --git a/handlers/payments/banksimple/index.js b/handlers/payments/banksimple/index.js new file mode 100644 index 000000000..df87882f8 --- /dev/null +++ b/handlers/payments/banksimple/index.js @@ -0,0 +1,27 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "Банковский перевод", + name: path.basename(__dirname), + hasIcon: false, + cards: ['sberbank'], + subtitle: 'или другой банк' +}; diff --git a/handlers/payments/banksimple/renderForm.js b/handlers/payments/banksimple/renderForm.js new file mode 100755 index 000000000..be726d0d5 --- /dev/null +++ b/handlers/payments/banksimple/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction, order) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + orderNumber: order.number + }); + + return form; + +}; + + diff --git a/handlers/payments/banksimple/router.js b/handlers/payments/banksimple/router.js new file mode 100755 index 000000000..4273d135d --- /dev/null +++ b/handlers/payments/banksimple/router.js @@ -0,0 +1,9 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var invoice = require('./controller/invoice'); + +router.get('/:transactionNumber/invoice.docx', invoice.get); + + diff --git a/handlers/payments/banksimple/templates/form.jade b/handlers/payments/banksimple/templates/form.jade new file mode 100755 index 000000000..de2c71ac0 --- /dev/null +++ b/handlers/payments/banksimple/templates/form.jade @@ -0,0 +1,2 @@ +form(method="POST",action="/payments/common/redirect/order/#{orderNumber}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/common/client/formPayment.js b/handlers/payments/common/client/formPayment.js new file mode 100755 index 000000000..78ccddc15 --- /dev/null +++ b/handlers/payments/common/client/formPayment.js @@ -0,0 +1,181 @@ +var notification = require('client/notification'); +var xhr = require('client/xhr'); +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); + +/** + * Get data from orderForm.getOrderData() + * process payment, ask for more data if needed + */ +class FormPayment { + + constructor(orderForm, paymentMethodElem) { + this.orderForm = orderForm; + this.paymentMethodElem = paymentMethodElem; + } + + request(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; + } + + startRequestIndication() { + + this.paymentMethodElem.classList.add('modal-overlay_light'); + + var spinner = new Spinner({ + elem: this.paymentMethodElem.querySelector('[type="submit"]'), + size: 'small', + class: '', + elemClass: 'button_loading' + }); + spinner.start(); + + return () => { + this.paymentMethodElem.classList.remove('modal-overlay_light'); + if (spinner) spinner.stop(); + }; + + } + + readPaymentData() { + var paymentData = {}; + + [].forEach.call(this.paymentMethodElem.querySelectorAll('input,select,textarea'), function(elem) { + if ( (elem.type == 'radio' || elem.type == 'checkbox') && !elem.checked) return; + paymentData[elem.name] = elem.value; + }); + + return paymentData; + } + + submit() { + + var orderData = this.orderForm.getOrderData(); + if (!orderData) return; + + var paymentData = this.readPaymentData(); + + if (!paymentData.paymentMethod) { + new notification.Error("Выберите метод оплаты."); + return; + } + + if (paymentData.paymentMethod == 'invoice' && !paymentData.invoiceCompanyName) { + new notification.Error("Укажите название компании."); + this.paymentMethodElem.querySelector('[name="invoiceCompanyName"]').focus(); + return; + } + + for (var key in paymentData) { + orderData[key] = paymentData[key]; + } + + var discountCode = window.location.search.match(/[?&]code=(\w+)/); + if (discountCode) { + orderData.discountCode = discountCode[1]; + } + + // response status must be 200 + var request = xhr({ + method: 'POST', + url: '/payments/common/checkout', + normalStatuses: [200, 403, 400], + body: orderData + }); + + if (orderData.orderTemplate) { + window.ga('ec:addProduct', { + id: this.orderForm.product, + variant: orderData.orderTemplate, + price: orderData.amount, + quantity: 1 + }); + } + + window.ga('ec:setAction', 'checkout', { + step: 1, + option: orderData.paymentMethod + }); + + window.metrika.reachGoal('CHECKOUT', { + product: this.orderForm.product, + method: orderData.paymentMethod, + price: orderData.amount + }); + + window.ga('send', 'event', 'payment', 'checkout', 'ebook'); + window.ga('send', 'event', 'payment', 'checkout-method-' + orderData.paymentMethod, this.orderForm.product); + + var onEnd = this.startRequestIndication(); + + request.addEventListener('success', (event) => { + + if (request.status == 403) { + new notification.Error("

    " + (event.result.description || event.result.message) + "

    Пожалуйста, начните оформление заново.

    Если вы считаете, что на сервере ошибка — свяжитесь со службой поддержки.

    "); + onEnd(); + return; + } + + if (request.status == 400) { + new notification.Error("

    " + event.result.message + "

    Если вы считаете, что произошла ошибка — свяжитесь со службой поддержки.

    "); + onEnd(); + return; + } + + var result = event.result; + + if (result.form) { + // don't stop the spinner while submitting the form to the payment system! + // (still in progress) + + window.ga('ec:setAction', 'purchase', { + id: result.orderNumber + }); + + var container = document.createElement('div'); + container.hidden = true; + container.innerHTML = result.form; + document.body.appendChild(container); + + + // submit form after GA or after 500ms, which one comes sooner + var submitForm = function() { + if (!submitForm.called) { + submitForm.called = true; + container.firstChild.submit(); + } + }; + + window.ga('send', 'event', 'payment', 'purchase', 'ebook', { + hitCallback: submitForm + }); + setTimeout(submitForm, 500); + + + window.metrika.reachGoal('PURCHASE', { + product: this.orderForm.product, + method: orderData.paymentMethod, + price: orderData.amount, + number: result.orderNumber + }); + + + } else { + console.error(result); + onEnd(); + new notification.Error("Ошибка на сервере, свяжитесь со службой поддержки."); + } + }); + + request.addEventListener('fail', onEnd); + } +} + +module.exports = FormPayment; diff --git a/handlers/payments/common/client/index.js b/handlers/payments/common/client/index.js new file mode 100755 index 000000000..dfdbd2be3 --- /dev/null +++ b/handlers/payments/common/client/index.js @@ -0,0 +1 @@ +exports.FormPayment = require('./formPayment'); diff --git a/handlers/payments/common/controller/checkout.js b/handlers/payments/common/controller/checkout.js new file mode 100755 index 000000000..edeb1dbb6 --- /dev/null +++ b/handlers/payments/common/controller/checkout.js @@ -0,0 +1,88 @@ +var paymentMethods = require('../../lib/methods'); +var Order = require('../../models/order'); +var OrderTemplate = require('../../models/orderTemplate'); +var OrderCreateError = require('../../lib/orderCreateError'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.post = function*(next) { + + yield* this.loadOrder(); + + var paymentMethod = paymentMethods[this.request.body.paymentMethod]; + + if (!paymentMethod) { + this.throw(403, "Unsupported payment method"); + } + + if (!this.order) { + // if we don't have the order in our database, then make a new one + // (use the incoming order post for that, but don't trust all its fields) + this.log.debug("new order, template:", this.request.body.orderTemplate); + + var orderTemplate = yield OrderTemplate.findOne({ + slug: this.request.body.orderTemplate + }).exec(); + + if (!orderTemplate) { + this.throw(404); + } + + this.log.debug("orderTemplate", orderTemplate); + + try { + this.order = yield* require(orderTemplate.module).createOrderFromTemplate(orderTemplate, this.user, this.request.body); + } catch (e) { + if (e instanceof OrderCreateError) { + this.status = 400; + this.body = { + message: e.message + }; + return; + } else { + throw e; + } + } + + + if (this.user && !~this.user.profileTabsEnabled.indexOf('orders')) { + this.user.profileTabsEnabled.addToSet('orders'); + yield this.user.persist(); + } + + saveOrderNumberToSession(this.session, this.order); + } else { + + // Many waiting transactions not allowed. + // The old one must had been cancelled before this. + yield* this.order.cancelPendingTransactions(); + + } + + this.log.debug("order", this.order); + + // creates transaction and returns the form to submit for its payment OR the result + var transaction = yield* paymentMethod.createTransaction(this.order, this.request.body); + this.log.debug("new transaction", transaction.toObject()); + + var form = yield* paymentMethod.renderForm(transaction, this.order); + + yield* transaction.log('form', form); + + this.body = { + form: form, + orderNumber: this.order.number + }; + +}; + +function saveOrderNumberToSession(session, order) { + if (!session.orders) { + session.orders = []; + } + session.orders.push(order.number); +} diff --git a/handlers/payments/common/controller/order.js b/handlers/payments/common/controller/order.js new file mode 100644 index 000000000..216e4474b --- /dev/null +++ b/handlers/payments/common/controller/order.js @@ -0,0 +1,48 @@ +var loadOrder = require('../../lib/loadOrder'); +var Order = require('../../models/order'); +var _ = require('lodash'); + +// all order modifications pass through this common module +// which may delegate to order modules +exports.patch = function*() { + + yield* this.loadOrder(); + + if (!this.order) { + this.throw(404, 'Нет такого заказа.'); + } + + /* + if (this.isAdmin) { + // support status change + if (this.request.body.status == Order.STATUS_PAID) { + yield* this.order.onPaid(); + } + } + */ + + var orderModule = require(this.order.module); + + if (orderModule.patch) { + yield* orderModule.patch.call(this); + } else { + this.body = {}; + } + +}; + +exports.del = function*() { + + yield* this.loadOrder(); + + if (!this.order) { + this.throw(404, 'Нет такого заказа.'); + } + + yield this.order.persist({ + status: Order.STATUS_CANCEL + }); + + this.body = 'ok'; +}; + diff --git a/handlers/payments/common/controller/ordersByUser.js b/handlers/payments/common/controller/ordersByUser.js new file mode 100644 index 000000000..794e7ddd5 --- /dev/null +++ b/handlers/payments/common/controller/ordersByUser.js @@ -0,0 +1,37 @@ +var paymentMethods = require('../../lib/methods'); +var Order = require('../../models/order'); +var OrderTemplate = require('../../models/orderTemplate'); +var OrderCreateError = require('../../lib/orderCreateError'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.get = function*(next) { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + var orders = yield Order.find({ + user: user._id, + status: { + $ne: Order.STATUS_CANCEL + } + }).sort({created: 1}).populate('user').exec(); + + var ordersToShow = []; + + for (var i = 0; i < orders.length; i++) { + var format = require(orders[i].module).formatOrderForProfile; + if (!format) continue; + ordersToShow.push(yield* format.call(this, orders[i])); + } + + this.body = ordersToShow; + +}; diff --git a/handlers/payments/common/router.js b/handlers/payments/common/router.js new file mode 100755 index 000000000..cf7842e7e --- /dev/null +++ b/handlers/payments/common/router.js @@ -0,0 +1,24 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); +router.param('userById', require('users').routeUserById); + +var order = require('./controller/order'); +var checkout = require('./controller/checkout'); +var ordersByUser = require('./controller/ordersByUser'); + +router.post('/checkout', checkout.post); +router.patch('/order', order.patch); +router.del('/order', order.del); + +router.get('/orders/user/:userById', ordersByUser.get); + +// form for invoices (after generating the transaction) submits here to go back to order, +// without any external service +router.post('/redirect/order/:orderNumber', function*() { + yield this.loadOrder(); + this.redirectToOrder(); +}); + + + diff --git a/handlers/payments/common/templates/invoice-settings.jade b/handlers/payments/common/templates/invoice-settings.jade new file mode 100644 index 000000000..748bd4500 --- /dev/null +++ b/handlers/payments/common/templates/invoice-settings.jade @@ -0,0 +1,31 @@ ++b.payment-setting + +e.item + +e('label').label(for="invoice-company") Название компании: + +b('span').text-input._small + +e('input')(name="invoiceCompanyName").control#invoice-company + +e.item._with_cb + +e('input').cb._invoice-need#invoice-contract(type="checkbox" name="invoiceAgreementRequired" value="1") + +e('label').cb-label(for="invoice-contract") + | Нужен договор + +e('span').small-note  (Договор заключается с компанией зарегистрированной в РФ) + +e.item._hidden + +e('label').label(for="invoice-contract-head") Шапка (для акта и договора): + +b('textarea').textarea-input.__textarea-head#invoice-contract-head(name="invoiceContractHead") ___, именуемое в дальнейшем Заказчик, в лице ___, действующего на основании ___, с одной стороны + +e.small-note Например: Общество с ограниченной ответственностью «Лютики», именуемое в дальнейшем Заказчик, в лице Иванова Петра Сергеевича, действующего на основании Устава, с одной стороны + +e.item_hidden + +e('label').label(for="invoice-company-address") Юридический адрес: + +b('textarea').textarea-input.__textarea-addr#invoice-company-address(name="invoiceCompanyAddress") + +e.item_hidden + +e('label').label(for="invoice-bank-details") Банковские реквизиты: + +b('textarea').textarea-input.__textarea-bank#invoice-bank-details(name="invoiceBankDetails") + | ИНН 12345678901 + = "\n" + | КПП 12345 + = "\n" + | р/сч 0000000000 + = "\n" + | в Банке таком-то + = "\n" + | к/сч 0000000000 + = "\n" + | Тел. +71234567890 diff --git a/handlers/payments/common/templates/payment-methods.jade b/handlers/payments/common/templates/payment-methods.jade new file mode 100644 index 000000000..1c64cea5e --- /dev/null +++ b/handlers/payments/common/templates/payment-methods.jade @@ -0,0 +1,23 @@ ++b.pay-method + +e('ul').methods + + each paymentMethod in paymentMethods + +e('li').method + +e('input').method-radio(type="radio" name="paymentMethod" value=paymentMethod.name id=paymentMethod.name) + +e('label')(class=["method-label", "_" + paymentMethod.name] for=paymentMethod.name) + +e('header').header + if paymentMethod.title && !~['paypal','yandexmoney','webmoney'].indexOf(paymentMethod.name) + +e('h3').method-title !{ paymentMethod.title } + if paymentMethod.cards + +e('span').cards + each card in paymentMethod.cards + +e('img').card(src='/pay-methods/' + card + '.svg', alt=card) + + if paymentMethod.subtitle + +e('h4').method-subtitle!= paymentMethod.subtitle + if paymentMethod.hasIcon + +e('img').logo(src="/pay-methods/pay-" + paymentMethod.name + '.svg' alt=paymentMethod.name[0].toUpperCase() + paymentMethod.name.slice(1)) + if paymentMethod.name == "paypal" + include paypal-settings + if paymentMethod.name == "invoice" + include invoice-settings diff --git a/handlers/payments/common/templates/paypal-settings.jade b/handlers/payments/common/templates/paypal-settings.jade new file mode 100755 index 000000000..4759ba650 --- /dev/null +++ b/handlers/payments/common/templates/paypal-settings.jade @@ -0,0 +1,10 @@ + ++b.payment-setting + +e.item._currency + +e('label').label(for="paypal-currency") Выберите валюту: + +b('select')(name="paypalCurrency").input-select._small.__control#paypal-currency + +e('option').option(value="RUB") RUB + +e('option').option(value="USD") USD + +e('option').option(value="EUR") EUR + +e('span').small-note + | Если у вас Российский Paypal аккаунт, вы
    сможете оплатить только в RUB. diff --git a/handlers/payments/index.js b/handlers/payments/index.js new file mode 100755 index 000000000..bf59b7d2c --- /dev/null +++ b/handlers/payments/index.js @@ -0,0 +1,43 @@ +var config = require('config'); +var path = require('path'); +var assert = require('assert'); + +var log = require('log')(); + +exports.loadOrder = require('./lib/loadOrder'); +exports.loadTransaction = require('./lib/loadTransaction'); +exports.getOrderInfo = require('./lib/getOrderInfo'); + +var Order = exports.Order = require('./models/order'); +var Discount = exports.Discount = require('./models/discount'); +var OrderTemplate = exports.OrderTemplate = require('./models/orderTemplate'); +var Transaction = exports.Transaction = require('./models/transaction'); +var TransactionLog = exports.TransactionLog = require('./models/transactionLog'); +var OrderCreateError = exports.OrderCreateError = require('./lib/orderCreateError'); + +var paymentMethods = exports.methods = require('./lib/methods'); + +// delegate all HTTP calls to payment modules +// mount('/webmoney', webmoney.middleware()) +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); +exports.init = function(app) { + for(var name in paymentMethods) { + app.use(mountHandlerMiddleware('/payments/' + name, path.join(__dirname, name))); + } + + app.use(mountHandlerMiddleware('/payments/common', path.join(__dirname, 'common'))); + + app.csrfChecker.ignore.add('/payments/:any*'); + app.verboseLogger.logPaths.add('/payments/:any*'); +}; + +exports.populateContextMiddleware = function*(next) { + this.redirectToOrder = function(order) { + order = order || this.order; + this.redirect(order.getUrl()); + }; + this.loadOrder = exports.loadOrder; + this.loadTransaction = exports.loadTransaction; + + yield* next; +}; diff --git a/handlers/payments/interkassa/controller/callback.js b/handlers/payments/interkassa/controller/callback.js new file mode 100755 index 000000000..70680bad7 --- /dev/null +++ b/handlers/payments/interkassa/controller/callback.js @@ -0,0 +1,53 @@ +const config = require('config'); +const interkassaConfig = config.payments.modules.interkassa; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const md5 = require('MD5'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no', {skipOwnerCheck: true}); + + yield this.transaction.logRequest('callback unverified', this.request); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'SUCCESS'; +}; + +// Base64(MD5(Implode(Sort(Params) + SecretKey, ':'))) +// Implode(Sort(Params) + SecretKey, ':') +// Sort(Params) + SecretKey +function checkSignature(body) { + + var incomingSignature = body.ik_sign; + + var signature = Object.keys(body) + .filter(function(key) { + return key != 'ik_sign'; + }) + .sort() + .map(function(key) { + return body[key]; + }); + + signature.push(interkassaConfig.secret); + + signature = signature.join(':'); + + signature = new Buffer(md5(signature, {asBytes: true})).toString('base64'); + + return signature == incomingSignature; +} diff --git a/handlers/payments/interkassa/controller/fail.js b/handlers/payments/interkassa/controller/fail.js new file mode 100755 index 000000000..5aff35b34 --- /dev/null +++ b/handlers/payments/interkassa/controller/fail.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'оплата не прошла' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/interkassa/controller/success.js b/handlers/payments/interkassa/controller/success.js new file mode 100755 index 000000000..d1b7fdddf --- /dev/null +++ b/handlers/payments/interkassa/controller/success.js @@ -0,0 +1,8 @@ +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/interkassa/index.js b/handlers/payments/interkassa/index.js new file mode 100644 index 000000000..55fe86610 --- /dev/null +++ b/handlers/payments/interkassa/index.js @@ -0,0 +1,29 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + + +exports.info = { + title: "Интеркасса", + name: path.basename(__dirname), + hasIcon: false, + cards: ['privatbank'], + subtitle: "банковская квитанция для Украины" +}; diff --git a/handlers/payments/interkassa/renderForm.js b/handlers/payments/interkassa/renderForm.js new file mode 100755 index 000000000..63cf954f7 --- /dev/null +++ b/handlers/payments/interkassa/renderForm.js @@ -0,0 +1,18 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + currency: config.payments.currency, + id: config.payments.modules.interkassa.id + }); + + return form; + +}; + + diff --git a/handlers/payments/interkassa/router.js b/handlers/payments/interkassa/router.js new file mode 100755 index 000000000..a5ce02fc5 --- /dev/null +++ b/handlers/payments/interkassa/router.js @@ -0,0 +1,16 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); + +var success = require('./controller/success'); +var fail = require('./controller/fail'); + +router.post('/callback', callback.post); + +router.post('/success', success.post); + +router.post('/fail', fail.post); + + diff --git a/handlers/payments/interkassa/templates/form.jade b/handlers/payments/interkassa/templates/form.jade new file mode 100755 index 000000000..c6b2c841e --- /dev/null +++ b/handlers/payments/interkassa/templates/form.jade @@ -0,0 +1,8 @@ + +form(method="POST" name="payment" action="https://sci.interkassa.com/" accept-charset="UTF-8") + input(type="hidden",name="ik_co_id",value=id) + input(type="hidden",name="ik_pm_no",value=number) + input(type="hidden",name="ik_cur",value=currency) + input(type="hidden",name="ik_am",value=amount) + input(type="hidden",name="ik_desc",value="Оплата по счёту #{number}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/invoice/controller/agreement.js b/handlers/payments/invoice/controller/agreement.js new file mode 100644 index 000000000..06bf7f1c5 --- /dev/null +++ b/handlers/payments/invoice/controller/agreement.js @@ -0,0 +1,28 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const priceInWords = require('textUtil/priceInWords'); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'invoice') { + this.log.debug("Improper transaction", this.transaction.toObject()); + this.throw(400); + } + + var orderModule = require(this.transaction.order.module); + var invoiceDoc = yield orderModule.getAgreement(this.transaction); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; diff --git a/handlers/payments/invoice/controller/invoice.js b/handlers/payments/invoice/controller/invoice.js new file mode 100644 index 000000000..10daca2d2 --- /dev/null +++ b/handlers/payments/invoice/controller/invoice.js @@ -0,0 +1,59 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const priceInWords = require('textUtil/priceInWords'); + +// Same generic invoice for all modules +var invoiceDocContent = fs.readFileSync(path.join(__dirname, "../doc/invoice.docx"), "binary"); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'invoice') { + this.log.debug("Improper transaction", this.transaction.toObject()); + this.throw(400); + } + + var invoiceDoc = getInvoice(this.transaction); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; + +function getInvoice(transaction) { + var invoiceDoc = new Docxtemplater(invoiceDocContent); + + var data = { + COMPANY_NAME: invoiceConfig.COMPANY_NAME, + INN: invoiceConfig.INN, + ACCOUNT: invoiceConfig.ACCOUNT, + BANK: invoiceConfig.BANK, + CORR_ACC: invoiceConfig.CORR_ACC, + BIK: invoiceConfig.BIK, + TRANSACTION_NUMBER: String(transaction.number), + TRANSACTION_DATE: moment(transaction.created).format('DD.MM.YYYY'), + BUYER_COMPANY_NAME: transaction.paymentDetails.companyName, + PAYMENT_DESCRIPTION: `Оплата за информационно-консультационные услуги по счёту ${transaction.number}`, + AMOUNT: transaction.amount + 'р.', + AMOUNT_IN_WORDS: priceInWords(transaction.amount), + SIGN_TITLE: invoiceConfig.SIGN_TITLE, + SIGN_NAME: invoiceConfig.SIGN_NAME, + SIGN_SHORT_NAME: invoiceConfig.SIGN_SHORT_NAME + }; + + invoiceDoc.setData(data); + + // apply replacements + invoiceDoc.render(); + + return invoiceDoc; +} diff --git a/handlers/payments/invoice/doc/invoice.docx b/handlers/payments/invoice/doc/invoice.docx new file mode 100644 index 000000000..4f0f34de8 Binary files /dev/null and b/handlers/payments/invoice/doc/invoice.docx differ diff --git a/handlers/payments/invoice/index.js b/handlers/payments/invoice/index.js new file mode 100644 index 000000000..d43d6e1d7 --- /dev/null +++ b/handlers/payments/invoice/index.js @@ -0,0 +1,34 @@ + +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order, body) { + + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname), + paymentDetails: { + companyName: String(body.invoiceCompanyName), + agreementRequired: Boolean(body.invoiceAgreementRequired), + contractHead: String(body.invoiceContractHead), + companyAddress: String(body.invoiceCompanyAddress), + bankDetails: String(body.invoiceBankDetails) + } + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "Счёт на компанию", + subtitle: '(для юрлиц из России)', + name: path.basename(__dirname) +}; diff --git a/handlers/payments/invoice/renderForm.js b/handlers/payments/invoice/renderForm.js new file mode 100755 index 000000000..be726d0d5 --- /dev/null +++ b/handlers/payments/invoice/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction, order) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + orderNumber: order.number + }); + + return form; + +}; + + diff --git a/handlers/payments/invoice/router.js b/handlers/payments/invoice/router.js new file mode 100755 index 000000000..f3d7dc897 --- /dev/null +++ b/handlers/payments/invoice/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var invoice = require('./controller/invoice'); +var agreement = require('./controller/agreement'); + +router.get('/:transactionNumber/invoice.docx', invoice.get); +router.get('/:transactionNumber/agreement.docx', agreement.get); + diff --git a/handlers/payments/invoice/templates/form.jade b/handlers/payments/invoice/templates/form.jade new file mode 100755 index 000000000..de2c71ac0 --- /dev/null +++ b/handlers/payments/invoice/templates/form.jade @@ -0,0 +1,2 @@ +form(method="POST",action="/payments/common/redirect/order/#{orderNumber}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/lib/getOrderInfo.js b/handlers/payments/lib/getOrderInfo.js new file mode 100755 index 000000000..f2997e682 --- /dev/null +++ b/handlers/payments/lib/getOrderInfo.js @@ -0,0 +1,205 @@ +const Order = require('../models/order'); +const Transaction = require('../models/transaction'); +const log = require('log')(); +const escapeHtml = require('escape-html'); + +/** + * high-level order status & transaction which caused it & messages to show + * success -- order success: paid, processed + * paid -- order paid, but not yet success + * error -- server-side error + * fail -- payment failed + * pending -- waiting for payment + * cancel -- order canceled + * @param order + * @returns {*} + */ +module.exports = function*(order) { + var info = yield* getOrderInfo(order); + + var linkToProfile = ''; + if (order.user && require(order.module).formatOrderForProfile) { + linkToProfile = `

    Информацию о заказе вы также можете найти в своём профиле.

    `; + } + + info.linkToProfile = linkToProfile; + + return info; +}; + +function* getOrderInfo(order) { + // get transaction which defines current status + + var mailUrl = 'orders@javascript.ru'; + var transaction; + + if (order.status == Order.STATUS_SUCCESS) { + // may not be the last transaction by modified + // because theoretically it's possible to have 2 transactions: + // pending (1tx) -> fail, pending (2nx tx came) -> success, pending (1st tx got money) + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + // it is possible that there is no transaction at all + // (if order status is set manually) + return { + number: order.number, + status: "success", + statusText: "Оплата получена", + transaction: transaction + // no title/accent/description, because the action on success is order-module-dependant + }; + } + + if (order.status == Order.STATUS_PAID) { + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + return { + number: order.number, + status: "paid", + statusText: "Ожидает обработки", + transaction: transaction, + title: "Спасибо за заказ!", + accent: "Оплата получена, заказ обрабатывается.", + description: `

    По окончании вам будет отправлено письмо на электронный адрес ${order.email}.

    +

    Если у вас возникли какие-нибудь вопросы, присылайте их на ${mailUrl}.

    ` + }; + + } + + if (order.status == Order.STATUS_PENDING) { + // PENDING order, but Transaction.STATUS_SUCCESS? + // impossible! + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + if (transaction) { + log.error("Transaction success, but order pending?!? Impossible! Must be paid", transaction, order); + return { + // our error, the visitor can do nothing + status: "error", + statusText: "Произошла ошибка", + transaction: transaction, + title: "Произошла ошибка.", + accent: "При обработке платежа произошла ошибка.", + description: `

    Пожалуйста, напишите в поддержку ${mailUrl}.

    `, + number: order.number + }; + } + + // NO CALLBACK from online-system yet + // probably he just pressed the "back" button + // OR + // selected the offline method of payment + // OR + // callback will come later + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_PENDING // there may be only 1 pending tx at time + }).exec(); + + log.debug("findOne pending transaction: ", transaction && transaction.toObject()); + + if (transaction) { + + // Waiting for payment + + if (transaction.paymentMethod == 'banksimple') { + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Для завершения заказа скачайте квитанцию и оплатите ее через банк.`, + description: `
    +

    Квитанция действительна три дня. Оплатить можно в Сбербанке (3% комиссия) или любом банке, где у вас есть счёт.

    +

    После оплаты в течение двух рабочих дней мы вышлем вам всю необходимую информацию на адрес ${order.email}.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    + `, + descriptionProfile: `
    Вы можете повторно скачать квитанцию. Изменить метод оплаты можно нажатием на кнопку ниже.
    ` + }; + } else if (transaction.paymentMethod == 'invoice') { + var invoiceButton = ``; + var agreementButton = transaction.paymentDetails.agreementRequired ? + `` : + ''; + + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Для завершения заказа произведите оплату по счёту.`, + description: ` +
    ${invoiceButton} ${agreementButton}
    +

    Счёт действителен пять рабочих дней.

    +

    После оплаты мы вышлем вам всю необходимую информацию на адрес ${order.email}.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    + `, + descriptionProfile: `
    Вы можете повторно скачать счёт` + + (transaction.paymentDetails.agreementRequired ? ` и договор с актом` : '') + + `. Изменить детали и метод оплаты можно нажатием на кнопку ниже.
    ` + }; + } else { + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Как только мы получим подтверждение от платёжной системы, мы вышлем вам всю необходимую информацию на адрес ${order.email}.`, + description: ` +

    Если у вас возникли проблемы при работе с платежной системой, и вы не оплатили заказ, + вы можете выбрать другой метод оплаты и оплатить заново.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    ` + }; + } + } + + // Failed? + // Show the latest error and let him pay + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_FAIL + }).sort({created: -1}).exec(); + + log.debug("findOne failed transaction: ", transaction && transaction.toObject()); + + return { + number: order.number, + status: "fail", + statusText: "Оплата не прошла", + title: "Оплата не прошла.", + transaction: transaction, + accent: "Оплата не прошла, попробуйте ещё раз.", + description: (transaction.statusMessage ? `
    Причина: ${escapeHtml(transaction.statusMessage)}
    ` : '') + + `

    По вопросам, касающимся оплаты, пишите на ${mailUrl}.

    ` + }; + + + } + + + if (order.status == Order.STATUS_CANCEL) { + return { + number: order.number, + status: "cancel", + statusText: "Заказ отменён", + title: "Заказ отменён.", + description: `

    По вопросам, касающимся заказа, пишите на ${mailUrl}.

    .` + }; + } + + log.error("order", order); + throw new Error("Must never reach this point. No transaction?"); + +} diff --git a/handlers/payments/lib/loadOrder.js b/handlers/payments/lib/loadOrder.js new file mode 100755 index 000000000..b102d8c9f --- /dev/null +++ b/handlers/payments/lib/loadOrder.js @@ -0,0 +1,105 @@ +var mongoose = require('mongoose'); +var Order = require('../models/order'); +var Transaction = require('../models/transaction'); +var assert = require('assert'); + +// Populates this.order with the order by "orderNumber" parameter +module.exports = function* (options) { + options = options || {}; + + var field = options.field || 'orderNumber'; + + var orderNumber = Number(this.request.body && this.request.body[field] || this.params[field] || this.query[field]); + + if (!orderNumber) { + if (options.throwIfNotFound) { + this.throw(404, 'Отсутствует номер заказа'); + } else { + return; + } + } + + function findOrder() { + return Order.findOne({number: orderNumber}).populate('user').exec(); + } + + var order = yield findOrder(); + + if (!order) { + this.throw(404, 'Нет такого заказа'); + } + + + // order.module.onPaid hook may take some time + // it happens that the transaction is already SUCCESS, but the order is still PAID, not SUCCESS + // in this case reload the order + if (order.status == Order.STATUS_PAID && options.ensureSuccessTimeout) { + + // let the onPaid hook to finish + var datediff = new Date() - new Date(order.modified); + while (datediff < options.ensureSuccessTimeout) { + // give it a second to finish and retry, usually up to max 5 seconds + this.log.debug("tx success, but order pending => wait 1s until onPaid hook (maybe?) finishes"); + yield function(callback) { + setTimeout(callback, 1000); + }; + datediff += 1000; + order = yield findOrder(); + } + } + + + //console.log("CHECK", this.req.user._id, order); + + var belongsToUser = this.user && order.user && (String(this.user._id) == String(order.user._id)); + + var orderInSession = this.session.orders && this.session.orders.indexOf(order.number) != -1; + + // allow to load order which belongs to the user or in the current session + // if the order is not in session ( + if (!orderInSession && !belongsToUser && !this.isAdmin) { + this.throw(403, 'Access denied', { + message: 'Доступ запрещён', + description: 'Возможно, этот заказ не ваш, вы не авторизованы, или сессия истекла.' + }); + } + + if (!options.reload) { + // order must be loaded only once + // (otherwise it's probably a bug) + // (unless we know what we're doing) + assert(!this.order, "this.order is already set (by loadTransaction?)"); + } + + this.log.debug("order", order.toObject()); + + this.order = order; + +}; + +/* +function* reloadOrderUntilSuccessFinish() { + + var lastTransaction = yield Transaction.findOne({ + order: this.order._id + }).sort({modified: -1}).limit(1).exec(); + + if (lastTransaction.status == Transaction.STATUS_SUCCESS && + this.order.status == Order.STATUS_PENDING) { + // PENDING order, but Transaction.STATUS_SUCCESS? + // means that order onPaid failed to finalize the job + // OR just did not finish it yet + var datediff = new Date() - new Date(lastTransaction.modified); + while(datediff < Order.MAX_ONSUCCESS_TIME) { + // give it a second to finish and retry, up to max 5 seconds + this.log.debug("tx success, but order pending => wait 1s until onPaid hook (maybe?) finishes"); + yield function(callback) { + setTimeout(callback, 1000); + }; + datediff += 1000; + yield* this.loadOrder({reload: true}); + } + } + +} +*/ diff --git a/handlers/payments/lib/loadTransaction.js b/handlers/payments/lib/loadTransaction.js new file mode 100755 index 000000000..c06c76364 --- /dev/null +++ b/handlers/payments/lib/loadTransaction.js @@ -0,0 +1,46 @@ +var mongoose = require('mongoose'); +var Transaction = require('../models/transaction'); +var User = require('users').User; +var assert = require('assert'); + +// Populates this.transaction with the transaction by "transactionNumber" parameter +// options.skipOwnerCheck is for signed submissions, set to true allows anyone to load transaction +module.exports = function* (field, options) { + options = options || {}; + if (!field) field = 'transactionNumber'; + + var transactionNumber = this.request.body && this.request.body[field] || this.params[field] || this.query[field]; + + this.log.debug('loadTransaction number: ' + transactionNumber); + if (!transactionNumber) { + return; + } + + var transaction = yield Transaction.findOne({number: transactionNumber}).populate('order').exec(); + + if (!transaction) { + this.throw(404, 'Transaction not found'); + } + + yield function(callback) { + transaction.order.populate('user', callback); + }; + + if (!options.skipOwnerCheck) { + var belongsToUser = this.user && transaction.order.user && (String(this.user._id) == String(transaction.order.user._id)); + var orderInSession = this.session.orders && this.session.orders.indexOf(transaction.order.number) != -1; + + if (!belongsToUser && !orderInSession && !this.isAdmin) { + this.throw(403, 'The order is not in session'); + } + } + + assert(!this.transaction, "this.transaction is already set"); + assert(!this.order, "this.order is already set"); + + this.transaction = transaction; + this.order = transaction.order; + + this.log.debug("tx loaded"); + +}; diff --git a/handlers/payments/lib/methods.js b/handlers/payments/lib/methods.js new file mode 100755 index 000000000..311f3c416 --- /dev/null +++ b/handlers/payments/lib/methods.js @@ -0,0 +1,13 @@ +var config = require('config'); +var assert = require('assert'); + +/** + * Configured (only) payment methods + */ +var paymentMethods = {}; +for(var key in config.payments.modules) { + paymentMethods[key] = require('../' + key); + assert(paymentMethods[key].renderForm, key + ": no renderForm"); +} + +module.exports = paymentMethods; diff --git a/handlers/payments/lib/orderCreateError.js b/handlers/payments/lib/orderCreateError.js new file mode 100644 index 000000000..106e7c3e1 --- /dev/null +++ b/handlers/payments/lib/orderCreateError.js @@ -0,0 +1,10 @@ + +function OrderCreateError(message) { + this.name = "OrderCreateError"; + this.message = message; +} + +OrderCreateError.prototype = Object.create(Error.prototype); +OrderCreateError.prototype.constructor = OrderCreateError; + +module.exports = OrderCreateError; diff --git a/handlers/payments/models/discount.js b/handlers/payments/models/discount.js new file mode 100644 index 000000000..2f0c3e8b9 --- /dev/null +++ b/handlers/payments/models/discount.js @@ -0,0 +1,62 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var autoIncrement = require('mongoose-auto-increment'); +var OrderTemplate = require('./orderTemplate'); +var Transaction = require('./transaction'); +var _ = require('lodash'); + +var schema = new Schema({ + discount: { + type: Number, + required: true + }, + + + module: { + type: String, + required: true + }, + + // data for the module + data: {}, + + code: { + type: String, + required: true, + unique: true, + default: function() { + return Math.random().toString(36).slice(2, 10); + } + }, + + isActive: { + type: Boolean, + default: true + }, + + created: { + type: Date, + default: Date.now + } + +}); + +/** + * find active discount with the code + * if discount has onlyModule set, ensure the match + * @param code + * @param onlyModule + * @returns {*} + */ +schema.statics.findByCodeAndModule = function*(code, module) { + return yield Discount.findOne({code: code, isActive: true, module: module}).exec(); +}; + +schema.methods.adjustAmount = function(amount) { + return this.discount == 1 ? amount : + this.discount < 1 ? amount * this.discount : + this.discount; +}; + +var Discount = module.exports = mongoose.model('Discount', schema); + diff --git a/handlers/payments/models/order.js b/handlers/payments/models/order.js new file mode 100755 index 000000000..ddbce6de0 --- /dev/null +++ b/handlers/payments/models/order.js @@ -0,0 +1,110 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var autoIncrement = require('mongoose-auto-increment'); +var OrderTemplate = require('./orderTemplate'); +var Transaction = require('./transaction'); +var _ = require('lodash'); + +var schema = new Schema({ + amount: { + type: Number, + required: true + }, + module: { // module so that transaction handler knows where to go back e.g. 'ebook' + type: String, + required: true + }, + title: { + type: String, + required: true + }, + description: { + type: String + }, + status: { + type: String, + enum: ['success', 'cancel', 'pending', 'paid'], + default: 'pending' + }, + + // order can be bound to either an email or a user + email: { + type: String, + index: true + }, + + user: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true + }, + + data: { + type: Schema.Types.Mixed, + default: {} + }, + + created: { + type: Date, + default: Date.now + }, + modified: { + type: Date + } + +}); + +schema.pre('save', function(next) { + this.modified = new Date(); + next(); +}); + +// order must have only 1 pending transaction at 1 time. +// finish one payment then create another +// UI does not allow to create multiple pending transaction +// that's to easily find/cancel a pending method +// Here I guard against hand-made POST requests (just to be sure) +// P.S. it is ok to create a transaction if a SUCCESS one exists (maybe split payment?) +schema.methods.cancelPendingTransactions = function*() { + + yield Transaction.findOneAndUpdate({ + order: this._id, + status: Transaction.STATUS_PENDING + }, { + status: Transaction.STATUS_FAIL, + statusMessage: "смена способа оплаты." + }).exec(); + +}; + +schema.methods.onPaid = function*() { + this.persist({ + status: Order.STATUS_PAID + }); + yield* require(this.module).onPaid(this); +}; + +schema.methods.cancelIfPendingTooLong = function*() { + yield* require(this.module).cancelIfPendingTooLong(this); +}; + +schema.plugin(autoIncrement.plugin, {model: 'Order', field: 'number', startAt: 1}); + +// order is ready for delivery, hooks finished +schema.statics.STATUS_SUCCESS = 'success'; + +// payment received, but the order hooks did not finish yet +schema.statics.STATUS_PAID = 'paid'; + +// awaiting payment +schema.statics.STATUS_PENDING = 'pending'; + +// not awaiting payment any more +schema.statics.STATUS_CANCEL = 'cancel'; + +schema.methods.getUrl = function() { + return '/' + this.module + '/orders/' + this.number; +}; + +var Order = module.exports = mongoose.model('Order', schema); + diff --git a/handlers/payments/models/orderTemplate.js b/handlers/payments/models/orderTemplate.js new file mode 100644 index 000000000..17b2d2a62 --- /dev/null +++ b/handlers/payments/models/orderTemplate.js @@ -0,0 +1,47 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +/** + * In other words, "Store items" + * New orders do *not* reference the items, because store items may change + * Instead new orders contain full information about themselves. + * + * Order template must have + * module (which handles it) + * slug (unique mnemo to search, may be many per module) + * + * OrderTemplate can be deleted, but the order is self-contained. + * @type {Schema} + */ +var schema = new Schema({ + title: { + type: String + }, + description: { + type: String + }, + // on checkout /order/slug, the new order is created from this template + slug: { + type: String, + required: true, + unique: true + }, + weight: { + type: Number + }, + amount: { + type: Number + }, + created: { + type: Date, + default: Date.now + }, + module: { + type: String, + required: true + }, + data: {} +}); + +module.exports = mongoose.model('OrderTemplate', schema); + diff --git a/handlers/payments/models/transaction.js b/handlers/payments/models/transaction.js new file mode 100755 index 000000000..79581401e --- /dev/null +++ b/handlers/payments/models/transaction.js @@ -0,0 +1,185 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var crypto = require('crypto'); +var TransactionLog = require('./transactionLog'); + +/** + * Transaction is an actual payment attempt (successful or not) for something + * - Order may exist without any transactions (pay later) + * - Transaction has it's own separate number (payment attempt) + * - Transaction amount can be different from order amount (partial payment) + * - Every transaction save generates a log record + * @type {Schema} + */ +var schema = new Schema({ + order: { + type: Schema.Types.ObjectId, + ref: 'Order', + required: true + }, + amount: { + type: Number, + required: true + }, + + // which method created this TX + paymentMethod: { + type: String, + required: true + }, + + currency: { + // sometimes needed, e.g. paypal allows many currencies + type: String, + enum: ['USD', 'EUR', 'RUB'] + }, + // payment method may initiate the payment + // and provide the token value to track it + // other details are also possible + paymentDetails: { + type: { + nextRetry: Number, // for Ya.Money processPayments + processing: Boolean, // for Ya.Money processPayments & Paypal PDT/IPN locking not to onPaid twice, + oauthToken: String, // for Ya.Money processPayments + requestId: String, // for Ya.Money processPayments, + + // for invoices + companyName: String, + agreementRequired: Boolean, + contractHead: String, + companyAddress: String, + bankDetails: String + }, + default: {} + }, + + // transaction number, external analog for _id + // always a number to be accepted by any payment system + // random, not autoincrement, because more convenient for development, doesn't repeat on dropdb + number: { + type: Number, + default: function() { + // webmoney requires transaction number to be a number 0 < LMI_PAYMENT_NO < 2147483647 + return parseInt(crypto.randomBytes(4).toString('hex'), 16) % 2147483647; + }, + unique: true + }, + created: { + type: Date, + required: true, + default: Date.now + }, + modified: { + type: Date + }, + status: { + type: String, + required: true + }, + statusMessage: { + type: String + } +}); + +// Awaiting for the payment system callback, +// when the user opens the order, let him wait and refresh the page +// we don't know if it's an offline payment or not +schema.statics.STATUS_PENDING = 'pending'; + +schema.statics.STATUS_SUCCESS = 'success'; + +schema.statics.STATUS_FAIL = 'fail'; + +// autolog all changes +schema.pre('save', function logChanges(next) { + + var log = new TransactionLog({ + transaction: this._id, + event: 'save', + data: { + status: this.status, + statusMessage: this.statusMessage, + amount: this.amount + } + }); + + log.save(function(err, doc) { + next(err); + }); +}); + +schema.pre('save', function(next){ + this.modified = new Date(); + next(); +}); + +// allow many failed transactions +// forbid many pending/successful +schema.pre('validate', function ensureSingleTransactionPerOrder(next) { + Transaction.findOne({ + order: this.order, + status: { + $in: [ + Transaction.STATUS_PENDING, + Transaction.STATUS_SUCCESS // enforce payment with a single tx + ] + }, + _id: { + $ne: this._id + } + }, function (err, tx) { + if (err) return next(err); + if (tx) return next(new Error("A transaction " + tx._id + " with status " + tx.status + " already exists for the same order " + tx.order)); + next(); + }); +}); + + + +/* +schema.methods.getStatusDescription = function() { + if (this.status == Transaction.STATUS_SUCCESS) { + return 'Оплата прошла успешно.'; + } + if (this.status == Transaction.STATUS_PENDING) { + return 'Оплата ожидается, о её успешномо окончании вы будете извещены по e-mail.'; + } + + if (this.status == Transaction.STATUS_FAIL) { + var result = 'Оплата не прошла'; + if (this.statusMessage) result += ': ' + this.statusMessage; + return result + '.'; + } + + if (!this.status) { + return 'нет информации об оплате'; + } + + throw new Error("неподдерживаемый статус транзакции"); +}; +*/ + +schema.methods.logRequest = function*(event, request) { + yield this.log(event, {url: request.originalUrl, body: request.body}); +}; + +// log anything related to the transaction +schema.methods.log = function*(event, data) { + + if (typeof event != "string") { + throw new Error("event name must be a string"); + } + + var options = { + transaction: this._id, + event: event, + data: data + }; + + var log = new TransactionLog(options); + yield log.persist(); +}; + +/* jshint -W003 */ +var Transaction = module.exports = mongoose.model('Transaction', schema); + diff --git a/handlers/payments/models/transactionLog.js b/handlers/payments/models/transactionLog.js new file mode 100755 index 000000000..775581da4 --- /dev/null +++ b/handlers/payments/models/transactionLog.js @@ -0,0 +1,39 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var schema = new Schema({ + + transaction: { + type: Schema.Types.ObjectId, + ref: 'Transaction', + index: true + }, + event: { + type: String, + index: true + }, + + + // for complex objects -> prior to logging make them simple (must be jsonable) + // e.g for HTTP response (HTTP.IncomingMessage) + // object keys may not contain "." in mongodb, so I may not store arbitrary objects + // only json can help + json: String, + + created: { + type: Date, + default: Date.now + } +}); + + +schema.virtual('data').get(function() { + return this.json ? JSON.parse(this.json) : {}; +}); + +schema.virtual('data').set(function(data) { + this.json = JSON.stringify(data); +}); + +module.exports = mongoose.model('TransactionLog', schema); + diff --git a/handlers/payments/payanyway/controller/callback.js b/handlers/payments/payanyway/controller/callback.js new file mode 100755 index 000000000..c84b51672 --- /dev/null +++ b/handlers/payments/payanyway/controller/callback.js @@ -0,0 +1,50 @@ +const config = require('config'); +//require('config/mongoose'); +const payanywayConfig = config.payments.modules.payanyway; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const md5 = require('MD5'); + +exports.post = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID', {skipOwnerCheck : true}); + + + yield this.transaction.logRequest('callback unverified', this.request); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + // signature is valid, so everything MUST be fine + if (this.transaction.amount != parseFloat(this.request.body.MNT_AMOUNT) || + this.request.body.MNT_ID != payanywayConfig.id) { + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: "данные транзакции не совпадают с базой, свяжитесь с поддержкой" + }); + this.throw(404, "transaction data doesn't match the POST body"); + } + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'SUCCESS'; +}; + +function checkSignature(body) { + + var signature = body.MNT_ID + body.MNT_TRANSACTION_ID + body.MNT_OPERATION_ID + body.MNT_AMOUNT + + body.MNT_CURRENCY_CODE + (body.MNT_SUBSCRIBER_ID || '') + (+body.MNT_TEST_MODE ? '1' : '0') + payanywayConfig.secret; + + signature = md5(signature); + + return signature == body.MNT_SIGNATURE; +} diff --git a/handlers/payments/payanyway/controller/cancel.js b/handlers/payments/payanyway/controller/cancel.js new file mode 100755 index 000000000..3f8f8982f --- /dev/null +++ b/handlers/payments/payanyway/controller/cancel.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'отказ от оплаты' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/payanyway/controller/fail.js b/handlers/payments/payanyway/controller/fail.js new file mode 100755 index 000000000..0a6f35b0e --- /dev/null +++ b/handlers/payments/payanyway/controller/fail.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'оплата не прошла' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/payanyway/controller/success.js b/handlers/payments/payanyway/controller/success.js new file mode 100755 index 000000000..f4980df2f --- /dev/null +++ b/handlers/payments/payanyway/controller/success.js @@ -0,0 +1,8 @@ +const Transaction = require('../../models/transaction'); + +exports.all = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/payanyway/index.js b/handlers/payments/payanyway/index.js new file mode 100644 index 000000000..bca681bba --- /dev/null +++ b/handlers/payments/payanyway/index.js @@ -0,0 +1,29 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + +exports.info = { + title: "Payanyway", + name: path.basename(__dirname), + subtitle: "и много других методов", + cards: ['visa-mastercard'], + hasIcon: false +}; + diff --git a/handlers/payments/payanyway/renderForm.js b/handlers/payments/payanyway/renderForm.js new file mode 100755 index 000000000..7aba59dc1 --- /dev/null +++ b/handlers/payments/payanyway/renderForm.js @@ -0,0 +1,19 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + currency: config.payments.currency, + id: config.payments.modules.payanyway.id, + limitIds: process.env.NODE_ENV == 'development' ? '' : '843858,248362,822360,545234,1028,499669' + }); + + return form; + +}; + + diff --git a/handlers/payments/payanyway/router.js b/handlers/payments/payanyway/router.js new file mode 100755 index 000000000..a598bb612 --- /dev/null +++ b/handlers/payments/payanyway/router.js @@ -0,0 +1,18 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); + +var success = require('./controller/success'); +var cancel = require('./controller/cancel'); +var fail = require('./controller/fail'); + +router.post('/callback', callback.post); + +router.all('/success', success.all); + +router.get('/cancel', cancel.get); +router.get('/fail', fail.get); + + diff --git a/handlers/payments/payanyway/templates/form.jade b/handlers/payments/payanyway/templates/form.jade new file mode 100755 index 000000000..afe7fdb6c --- /dev/null +++ b/handlers/payments/payanyway/templates/form.jade @@ -0,0 +1,10 @@ + +form(method="POST" action="https://www.moneta.ru/assistant.htm" accept-charset="UTF-8") + input(type="hidden",name="MNT_ID",value=id) + input(type="hidden",name="MNT_TRANSACTION_ID",value=number) + input(type="hidden",name="MNT_CURRENCY_CODE",value=currency) + input(type="hidden",name="MNT_AMOUNT",value=amount) + if limitIds + input(type="hidden",name="paymentSystem.limitIds",value=limitIds) + input(type="submit",value="Оплатить") + diff --git a/handlers/payments/paypal/controller/autoreturn.js b/handlers/payments/paypal/controller/autoreturn.js new file mode 100755 index 000000000..6ce46d62b --- /dev/null +++ b/handlers/payments/paypal/controller/autoreturn.js @@ -0,0 +1,4 @@ + +exports.get = function*() { + this.body = 'Thank you for your payment. Your transaction has been completed, and a receipt for your purchase has been emailed to you. You may log into your account at PayPal to view details of this transaction.'; +}; \ No newline at end of file diff --git a/handlers/payments/paypal/controller/cancel.js b/handlers/payments/paypal/controller/cancel.js new file mode 100755 index 000000000..22871d037 --- /dev/null +++ b/handlers/payments/paypal/controller/cancel.js @@ -0,0 +1,14 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction(); + + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'отказ от оплаты' + }); + + this.redirectToOrder(); +}; + diff --git a/handlers/payments/paypal/controller/ipn.js b/handlers/payments/paypal/controller/ipn.js new file mode 100755 index 000000000..82e0f9d30 --- /dev/null +++ b/handlers/payments/paypal/controller/ipn.js @@ -0,0 +1,142 @@ +const config = require('config') +const paypalConfig = config.payments.modules.paypal; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const TransactionLog = require('../../models/transactionLog'); +const request = require('koa-request'); + +// docs: +// +// https://developer.paypal.com/webapps/developer/docs/classic/ipn/integration-guide/IPNIntro/ + +/* jshint -W106 */ +exports.post = function* (next) { + + yield* this.loadTransaction('invoice', {skipOwnerCheck: true}); + + + yield this.transaction.logRequest('ipn: request received', this.request); + + + var qs = { + 'cmd': '_notify-validate' + }; + + for (var field in this.request.body) { + qs[field] = this.request.body[field]; + } + + // request oauth token + var options = { + method: 'GET', + qs: qs, + url: 'https://www.paypal.com/cgi-bin/webscr', + headers: { + 'User-Agent': 'request' + } + }; + + yield this.transaction.log('ipn: request verify', options); + + var response; + try { + response = yield request(options); + } catch(e) { + yield this.transaction.log('ipn: request verify failed', e.message); + this.throw(403, "Couldn't verify ipn"); + } + + if (response.body != "VERIFIED") { + yield this.transaction.log('ipn: invalid IPN', response.body); + this.throw(403, "Invalid IPN"); + } + + // ipn is verified now! But we check if it's data matches the transaction (as recommended in docs) + if (this.transaction.amount != parseFloat(this.request.body.mc_gross) || + this.request.body.receiver_email != paypalConfig.email || + this.request.body.mc_currency != this.transaction.currency) { + + yield this.transaction.log("ipn: the response POST data doesn't match the transaction data", response.body); + this.throw(404, "transaction data doesn't match the POST body, strange"); + } + + // IPN is fully verified and valid + + // match agains latest ipn in logs as recommended: + // if there just was an IPN about the same transaction, and it's state is the same + // => then the current one is a duplicate + var previousIpn = yield TransactionLog.findOne({ + event: "ipn: VALIDATED_IN_PROCESS", + transaction: this.transaction._id + }).sort({created: -1}).exec(); + + if (previousIpn && previousIpn.data.payment_status == this.request.body.payment_status) { + yield this.transaction.log("ipn: duplicate", this.request.body); + // ignore duplicate + this.body = ''; + return; + } + + yield this.transaction.log("ipn: VALIDATED_IN_PROCESS", this.request.body); + + // IPN is fully verified, valid, non-duplicate + + // Do not perform any processing on WPS transactions here that do not have + // transaction IDs, indicating they are non-payment IPNs such as those used + // for subscription signup requests. + if (!this.request.body.txn_id) { + yield this.transaction.log("ipn: without txn_id", this.request.body); + this.body = ''; + return; + } + + switch(this.request.body.payment_status) { + case 'Failed': + case 'Voided': + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL + }); + this.body = ''; + return; + case 'Pending': + yield this.transaction.persist({ + status: Transaction.STATUS_PENDING, + statusMessage: this.request.body.pending_reason + }); + this.body = ''; + return; + case 'Completed': + + // Now let's see if the transaction was already processed by PDT or another IPN + var refreshedTransaction = yield Transaction.findOne({ + _id: this.transaction._id + }).exec(); + + if (refreshedTransaction.status == Transaction.STATUS_SUCCESS) { + // done :) + yield this.transaction.log("ipn: transaction is already processed to success by PDT/IPN"); + } else { + + yield refreshedTransaction.persist({ + status: Transaction.STATUS_SUCCESS, + statusMessage: 'Paypal подтвердил оплату' + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + } + + this.body = ''; + return; + default: + // Refunded ... + yield this.transaction.log("ipn: payment_status unknown", this.request.body); + + this.body = ''; + return; + } + + + +}; diff --git a/handlers/payments/paypal/controller/success.js b/handlers/payments/paypal/controller/success.js new file mode 100755 index 000000000..88696d49c --- /dev/null +++ b/handlers/payments/paypal/controller/success.js @@ -0,0 +1,87 @@ +const Transaction = require('../../models/transaction'); +const Order = require('../../models/order'); +const request = require('koa-request'); +const config = require('config'); +const qs = require('querystring'); + +// /payments/paypal/success?transactionNumber=1481381892&tx=76G37726XX923073E&st=Completed&amt=1%2e00&cc=RUB&cm=&item_number= +exports.get = function* (next) { + yield* this.loadTransaction(); + + yield this.transaction.log('success: return', this.originalUrl); + + // trust only tx parameter (transaction token), other params can be user-generated + // ask the details from Paypal + var tx = this.query.tx; + + // Verify the success url as explained here: + // https://developer.paypal.com/docs/classic/paypal-payments-standard/integration-guide/paymentdatatransfer/ + var options = { + method: 'POST', + url: 'https://www.paypal.com/cgi-bin/webscr', + form: { + cmd: '_notify-synch', + tx: tx, + at: config.payments.modules.paypal.pdtToken + }, + headers: { + 'User-Agent': 'request' + } + }; + + yield this.transaction.log('success: request verify', options); + + var response; + try { + response = yield request(options); + } catch(e) { + yield this.transaction.log('success: request verify failed, error', e.message); + this.throw(403, "Couldn't verify success"); + } + + if (response.body.startsWith("FAIL")) { + yield this.transaction.log('success: request verify failed', response.body); + this.throw(403, "Verification Failed"); + } else if (!response.body.startsWith("SUCCESS")) { + // if it's not fail, must be success (error otherwise) + yield this.transaction.log('success: request verify error', response.body); + this.throw(500, "Verification Error"); + } else { + yield this.transaction.log('success: request verify success', response.body); + } + + // turn response into query string + var queryString = response.body.replace(/\n/g, '&'); + var responseParsed = qs.parse(queryString); + + // don't actually need to check it, but still checking that the amount is correct + // that's an extra check for validity + if (responseParsed.mc_gross != this.transaction.amount) { + yield this.transaction.log('success: request but mc_gross != transaction.amount', response.body); + this.throw(500, "Verification amount error"); + } + + // ...Verified + + // Now let's see if the transaction was already processed by IPN + var refreshedTransaction = yield Transaction.findOne({ + _id: this.transaction._id + }).exec(); + + if (refreshedTransaction.status == Transaction.STATUS_SUCCESS) { + // done :) + yield this.transaction.log("success: transaction is already processed by IPN"); + } else { + + yield refreshedTransaction.persist({ + status: Transaction.STATUS_SUCCESS, + statusMessage: 'Paypal подтвердил оплату' + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + } + + this.redirectToOrder(); +}; + diff --git a/handlers/payments/paypal/index.js b/handlers/payments/paypal/index.js new file mode 100755 index 000000000..9e69ee200 --- /dev/null +++ b/handlers/payments/paypal/index.js @@ -0,0 +1,42 @@ +const path = require('path'); +const Transaction = require('../models/transaction'); +const money = require('money'); +const config = require('config'); + +exports.renderForm = require('./renderForm'); + +/** + * Create transaction from order, using optional info in requestBody + * @param order + * @param requestBody + * @returns {*|exports|module.exports} + */ +exports.createTransaction = function*(order, requestBody) { + + var currency = requestBody.paypalCurrency; + if (!~Transaction.schema.path('currency').enumValues.indexOf(currency)) { + throw(new Error("Unsupported currency:" + currency)); + } + + var amount = (order.currency == config.payments.currency) ? + order.amount : Math.round(money.convert(order.amount, {from: config.payments.currency, to: currency})); + + var transaction = new Transaction({ + order: order._id, + amount: amount, + status: Transaction.STATUS_PENDING, + currency: currency, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; + +}; + +exports.info = { + title: 'PayPal', + name: path.basename(__dirname), + hasIcon: true +}; diff --git a/handlers/payments/paypal/renderForm.js b/handlers/payments/paypal/renderForm.js new file mode 100755 index 000000000..35639b5c9 --- /dev/null +++ b/handlers/payments/paypal/renderForm.js @@ -0,0 +1,37 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const paypalConfig = config.payments.modules.paypal; +const path = require('path'); + +module.exports = function* (transaction) { + + /* jshint -W106 */ + var fields = { + business: paypalConfig.email, + invoice: transaction.number, + amount: transaction.amount, + item_name: "Оплата по счёту " + transaction.number, + charset: "utf-8", + cmd: "_xclick", + no_note: 1, + no_shipping: 1, + rm: 2, // the buyer's browser is redirected to the return URL by using the POST method, and all payment variables are included + currency_code: transaction.currency, + lc: "RU", + notify_url: process.env.SITE_HOST + '/payments/paypal/ipn?transactionNumber=' + transaction.number, + cancel_url: process.env.SITE_HOST + '/payments/paypal/cancel?transactionNumber=' + transaction.number, + return: process.env.SITE_HOST + '/payments/paypal/success?transactionNumber=' + transaction.number + }; + + + yield transaction.log("form fields", fields); + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + fields: fields + }); + + return form; + +}; + + diff --git a/handlers/payments/paypal/router.js b/handlers/payments/paypal/router.js new file mode 100755 index 000000000..ee45840e0 --- /dev/null +++ b/handlers/payments/paypal/router.js @@ -0,0 +1,17 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var ipn = require('./controller/ipn'); +var success = require('./controller/success'); +var cancel = require('./controller/cancel'); + +// webmoney server posts here (in background) +router.post('/ipn', ipn.post); + +// webmoney server redirects here if payment successful +router.get('/success', success.get); + +router.get('/cancel', cancel.get); + + diff --git a/handlers/payments/paypal/templates/form.jade b/handlers/payments/paypal/templates/form.jade new file mode 100755 index 000000000..a10cb4864 --- /dev/null +++ b/handlers/payments/paypal/templates/form.jade @@ -0,0 +1,5 @@ + +form(method="post" action="https://www.paypal.com/cgi-bin/webscr") + each value, name in fields + input(type="hidden", name=name,value=value) + input(type="submit" value="submit") diff --git a/handlers/payments/readme.txt b/handlers/payments/readme.txt new file mode 100755 index 000000000..9de837efe --- /dev/null +++ b/handlers/payments/readme.txt @@ -0,0 +1,7 @@ +IPNs default to charset = windows-1252, however, you can change this by going to your Go to Profile > My Selling Tools +Down at the bottom click on "PayPal button language encoding" and then More Options. On this page you can change your character set and languages for various notifications. + +Profile > My selling tools > Website preferences > + Auto Return ON (url is not used actually) + Payment Data Transfer ON + diff --git a/handlers/payments/tasks/orderCancelPending.js b/handlers/payments/tasks/orderCancelPending.js new file mode 100644 index 000000000..fdd7d92a0 --- /dev/null +++ b/handlers/payments/tasks/orderCancelPending.js @@ -0,0 +1,32 @@ +var co = require('co'); +var gutil = require('gulp-util'); +var Order = require('../models/order'); + +/** + * Update prod build dir from master, rebuild and commit to prod + * @returns {Function} + */ +module.exports = function() { + + return function() { + + return co(function*() { + + var lastNumber = 0; + while(true) { + + var order = yield Order.findOne({ + status: Order.STATUS_PENDING, + number: { $gt: lastNumber } + }).sort({number: 1}).limit(1).exec(); + + if (!order) break; + lastNumber = order.number; + + yield* order.cancelIfPendingTooLong(); + } + }); + + }; +}; + diff --git a/handlers/payments/tasks/orderPaid.js b/handlers/payments/tasks/orderPaid.js new file mode 100644 index 000000000..95fe9107c --- /dev/null +++ b/handlers/payments/tasks/orderPaid.js @@ -0,0 +1,36 @@ +var co = require('co'); +var gutil = require('gulp-util'); +var Order = require('../models/order'); + +module.exports = function() { + + var args = require('yargs') + .example('gulp payments:order:paid --number 484') + .demand(['number']) + .argv; + + return function() { + + return co(function*() { + + var order = yield Order.findOne({number: args.number}).exec(); + + if (!order) { + throw new Error("No order with number " + args.number); + } + + if (order.status == Order.STATUS_SUCCESS && !args.force) { + throw new Error("Order already success " + args.number); + } + + yield* order.onPaid(); + + yield order.persist({ + status: Order.STATUS_SUCCESS + }); + + }); + + }; +}; + diff --git a/handlers/payments/tasks/transactionPaid.js b/handlers/payments/tasks/transactionPaid.js new file mode 100644 index 000000000..2561c25e7 --- /dev/null +++ b/handlers/payments/tasks/transactionPaid.js @@ -0,0 +1,49 @@ +var co = require('co'); +var gutil = require('gulp-util'); +var Transaction = require('../models/transaction'); +var Order = require('../models/order'); + +/** + * Mark TX as paid + * @returns {Function} + */ +module.exports = function() { + + var args = require('yargs') + .example('gulp payments:transaction:paid --number 12345678') + .example('gulp payments:transaction:paid --number 12345678 --force') + .demand(['number']) + .argv; + + return function() { + + return co(function*() { + + var transaction = yield Transaction.findOne({number: args.number}).populate('order').exec(); + + if (!transaction) { + throw new Error("No transaction with number " + args.number); + } + + gutil.log("Order number:" + transaction.order.number); + + if (transaction.order.status == Order.STATUS_SUCCESS && !args.force) { + throw new Error("Order already success " + transaction.order.number); + } + + yield transaction.log('payments:transaction:paid'); + + yield transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + yield* transaction.order.onPaid(); + + yield transaction.order.persist({ + status: Order.STATUS_SUCCESS + }); + }); + + }; +}; + diff --git a/handlers/payments/webmoney/controller/callback.js b/handlers/payments/webmoney/controller/callback.js new file mode 100755 index 000000000..5357bda30 --- /dev/null +++ b/handlers/payments/webmoney/controller/callback.js @@ -0,0 +1,66 @@ +const webmoneyConfig = require('config').payments.modules.webmoney; +const mongoose = require('mongoose'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const sha256 = require('sha256'); + +// ONLY ACCESSED from WEBMONEY SERVER +exports.prerequest = function* (next) { + yield* this.loadTransaction('LMI_PAYMENT_NO', {skipOwnerCheck : true}); + + this.log.debug("prerequest"); + + yield this.transaction.logRequest('prerequest', this.request); + + if (this.transaction.status == Transaction.STATUS_SUCCESS || + this.transaction.amount != parseFloat(this.request.body.LMI_PAYMENT_AMOUNT) || + this.request.body.LMI_PAYEE_PURSE != webmoneyConfig.purse + ) { + this.log.debug("no pending transaction " + this.request.body.LMI_PAYMENT_NO); + this.throw(404, 'unfinished transaction with given params not found'); + } + + this.body = 'YES'; + +}; + +exports.post = function* (next) { + + yield* this.loadTransaction('LMI_PAYMENT_NO', {skipOwnerCheck : true}); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + if (this.transaction.amount != parseFloat(this.request.body.LMI_PAYMENT_AMOUNT) || + this.request.body.LMI_PAYEE_PURSE != webmoneyConfig.purse) { + // STRANGE, signature is correct + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: "данные транзакции не совпадают с базой, свяжитесь с поддержкой" + }); + this.throw(404, "transaction data doesn't match the POST body"); + } + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'OK'; + +}; + +function checkSignature(body) { + + var signature = sha256(body.LMI_PAYEE_PURSE + body.LMI_PAYMENT_AMOUNT + body.LMI_PAYMENT_NO + + body.LMI_MODE + body.LMI_SYS_INVS_NO + body.LMI_SYS_TRANS_NO + body.LMI_SYS_TRANS_DATE + + webmoneyConfig.secretKey + body.LMI_PAYER_PURSE + body.LMI_PAYER_WM).toUpperCase(); + + return signature == body.LMI_HASH; +} diff --git a/handlers/payments/webmoney/controller/fail.js b/handlers/payments/webmoney/controller/fail.js new file mode 100755 index 000000000..1c6a9d57f --- /dev/null +++ b/handlers/payments/webmoney/controller/fail.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('LMI_PAYMENT_NO'); + + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL + }); + + this.redirectToOrder(); + +}; diff --git a/handlers/payments/webmoney/controller/success.js b/handlers/payments/webmoney/controller/success.js new file mode 100755 index 000000000..bcef36e3c --- /dev/null +++ b/handlers/payments/webmoney/controller/success.js @@ -0,0 +1,8 @@ +const config = require('config'); +const mongoose = require('mongoose'); + +exports.post = function* (next) { + yield* this.loadTransaction('LMI_PAYMENT_NO'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/webmoney/index.js b/handlers/payments/webmoney/index.js new file mode 100755 index 000000000..17bdcd68f --- /dev/null +++ b/handlers/payments/webmoney/index.js @@ -0,0 +1,26 @@ + +const path = require('path'); +const Transaction = require('../models/transaction'); + +exports.renderForm = require('./renderForm'); + +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "WebMoney", + name: path.basename(__dirname), + hasIcon: true +}; + diff --git a/handlers/payments/webmoney/renderForm.js b/handlers/payments/webmoney/renderForm.js new file mode 100755 index 000000000..2431209ad --- /dev/null +++ b/handlers/payments/webmoney/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + return jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + webmoney: config.payments.modules.webmoney + }); + +}; + + diff --git a/handlers/payments/webmoney/router.js b/handlers/payments/webmoney/router.js new file mode 100755 index 000000000..adcb5361e --- /dev/null +++ b/handlers/payments/webmoney/router.js @@ -0,0 +1,25 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); +var success = require('./controller/success'); +var fail = require('./controller/fail'); + + +// webmoney server posts here (in background) +router.post('/callback', function* (next) { + if (this.request.body.LMI_PREREQUEST == '1') { + yield* callback.prerequest.call(this, next); + } else { + yield* callback.post.call(this, next); + } +}); + +// webmoney server redirects here if payment successful +router.post('/success', success.post); + +// webmoney server redirects here if payment failed +router.post('/fail', fail.post); + + diff --git a/handlers/payments/webmoney/templates/form.jade b/handlers/payments/webmoney/templates/form.jade new file mode 100755 index 000000000..8ad6d67ce --- /dev/null +++ b/handlers/payments/webmoney/templates/form.jade @@ -0,0 +1,7 @@ +form(method="POST",action="https://merchant.webmoney.ru/lmi/payment.asp") + input(type="hidden",name="LMI_PAYMENT_AMOUNT",value=amount) + input(type="hidden",name="LMI_PAYMENT_DESC_BASE64",value=new Buffer('оплата по счету ' + number).toString('base64')) + input(type="hidden",name="LMI_PAYMENT_NO",value=number) + input(type="hidden",name="LMI_PAYEE_PURSE",value=webmoney.purse) + input(type="hidden",name="LMI_SIM_MODE",value=(isTest ? 1 : 0)) + input(type="submit",value="Оплатить") diff --git a/handlers/payments/webmoney/test/.jshintrc b/handlers/payments/webmoney/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/payments/webmoney/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/payments/yandexmoney/controller/back.js b/handlers/payments/yandexmoney/controller/back.js new file mode 100755 index 000000000..c5c7747e2 --- /dev/null +++ b/handlers/payments/yandexmoney/controller/back.js @@ -0,0 +1,157 @@ +const config = require('config'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const request = require('koa-request'); +const log = require('log')(); + +var updatePendingOnlineTransactionStatus = require('../lib/updatePendingOnlineTransactionStatus'); + +/* jshint -W106 */ +exports.get = function* () { + + var self = this; + + yield* this.loadTransaction(); + + yield this.transaction.logRequest('back', this.request); + + if (!this.query.code) { + yield* fail(this.query.error_description || this.query.error); + return; + } + + try { + var oauthTokenResponse = yield* requestOauthToken.call(this, this.query.code); + + var oauthToken = oauthTokenResponse.access_token; + + if (!oauthToken) { + throwResponseError(oauthTokenResponse); + } + + var requestPaymentResponse = yield* requestPayment.call(this, oauthToken); + + if (requestPaymentResponse.status != "success") { + if (requestPaymentResponse.error == 'ext_action_required') { + self.redirect(requestPaymentResponse.ext_action_uri); + return; + } + + throwResponseError(requestPaymentResponse); + } + + // payment approved, success + this.transaction.paymentDetails.oauthToken = oauthToken; + this.transaction.paymentDetails.requestId = requestPaymentResponse.request_id; + yield this.transaction.persist(); + + // payment may not succeed yet, + // so this can be called later too with HTTP GET + yield* updatePendingOnlineTransactionStatus(this.transaction); + + self.redirectToOrder(); + + } catch (e) { + if (e instanceof URIError) { + yield* fail(e.message); + return; + } else if (e instanceof SyntaxError) { + yield* fail("некорректный ответ платёжной системы"); + return; + } else { + throw e; + } + } + + /* jshint -W106 */ + function* fail(reason) { + self.transaction.status = Transaction.STATUS_FAIL; + self.transaction.statusMessage = reason; + + self.log.debug("fail transaction", self.transaction.toObject()); + yield self.transaction.persist(); + + self.redirectToOrder(); + } + + + +}; + + + +function* requestOauthToken(code) { + + // request oauth token + var options = { + method: 'POST', + form: { + code: code, + client_id: config.payments.modules.yandexmoney.clientId, + grant_type: 'authorization_code', + redirect_uri: config.payments.modules.yandexmoney.redirectUri + '?transactionNumber=' + + this.transaction.number, + client_secret: config.payments.modules.yandexmoney.clientSecret + }, + url: 'https://sp-money.yandex.ru/oauth/token' + }; + + + yield this.transaction.log('request oauth/token', options); + + var response = yield request(options); + + yield this.transaction.log('response oauth/token', response.body); + + return JSON.parse(response.body); +} + + +// request payment +// return return request_id +function* requestPayment(oauthToken) { + var options = { + method: 'POST', + form: { + pattern_id: 'p2p', + to: config.payments.modules.yandexmoney.purse, + amount: this.transaction.amount, + comment: 'оплата по счету ' + this.transaction.number, + message: 'оплата по счету ' + this.transaction.number, + identifier_type: 'account' + }, + headers: { + 'Authorization': 'Bearer ' + oauthToken + }, + url: 'https://money.yandex.ru/api/request-payment' + }; + + this.log.debug('request api/request-payment', options); + yield this.transaction.log('request api/request-payment', options); + + var response = yield request(options); + this.log.debug('response api/request-payment', response.body); + yield this.transaction.log('response api/request-payment', response.body); + + return JSON.parse(response.body); +} + + +function throwResponseError(response) { + var message; + + var error = (response.error == 'not_enough_funds') ? 'недостаточно средств.' : + (response.error == 'limit_exceeded') ? 'превышен лимит.' : + (response.error == 'account_blocked') ? 'счёт заблокирован.' : response.error; + + if (error && response.error_description) { + message = '[' + error + '] ' + response.error_description; + } else if (error) { + message = error; + } else { + message = "детали ошибки не указаны."; + } + + + throw new URIError(message); +} diff --git a/handlers/payments/yandexmoney/controller/processPayments.js b/handlers/payments/yandexmoney/controller/processPayments.js new file mode 100755 index 000000000..dbf060ff2 --- /dev/null +++ b/handlers/payments/yandexmoney/controller/processPayments.js @@ -0,0 +1,35 @@ +/** + * Process all unfinished payments for Ya.Money, + * CRONTAB: call every minute + * @type {exports} + */ + +const config = require('config'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const request = require('koa-request'); + +var updatePendingOnlineTransactionStatus = require('../lib/updatePendingOnlineTransactionStatus'); + +/* jshint -W106 */ +exports.get = function* () { + + var transactions = yield Transaction.find({ + order: this.order._id, + status: Transaction.STATUS_PENDING, + paymentMethod: 'yandexmoney', + 'paymentDetails.nextRetry': { + $gte: Date.now() + }, + 'paymentDetails.processing': { + $ne: true + } + }).exec(); + + for (var i = 0; i < transactions.length; i++) { + var transaction = transactions[i]; + this.log("processPayments", transaction); + yield* updatePendingOnlineTransactionStatus(transaction); + } + +}; \ No newline at end of file diff --git a/handlers/payments/yandexmoney/index.js b/handlers/payments/yandexmoney/index.js new file mode 100755 index 000000000..64bc13197 --- /dev/null +++ b/handlers/payments/yandexmoney/index.js @@ -0,0 +1,28 @@ +const config = require('config'); +const jade = require('lib/serverJade'); +const path = require('path'); +const Transaction = require('../models/transaction'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + +exports.info = { + title: "Яндекс.Деньги", + name: path.basename(__dirname), + hasIcon: true +}; diff --git a/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js b/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js new file mode 100755 index 000000000..7e24dd0ef --- /dev/null +++ b/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js @@ -0,0 +1,78 @@ + +const request = require('koa-request'); +const Transaction = require('../../models/transaction'); +const Order = require('../../models/order'); +const assert = require('assert'); + +// update order status if possible, check transactions +/* jshint -W106 */ +module.exports = function*(transaction) { + assert(transaction.status == Transaction.STATUS_PENDING); + + // to avoid race condition with regular update + // not really atomic locking, but much safer than w/o it + if (transaction.paymentDetails.processing) return; + transaction.paymentDetails.processing = true; + yield transaction.persist(); + + var processPaymentResponse = yield* processPayment(transaction); + + yield function(callback) { + transaction.populate('order', callback); + }; + + var order = transaction.order; + + switch (processPaymentResponse.status) { + case 'success': + transaction.status = Transaction.STATUS_SUCCESS; + break; + + case 'refused': + transaction.status = Transaction.STATUS_FAIL; + transaction.statusMessage = processPaymentResponse.error; + break; + + case 'in_progress': + transaction.paymentDetails.nextRetry = Date.now() + processPaymentResponse.next_retry; + break; + + default: + this.log.error("Unexprected response from yandex ", processPaymentResponse); + this.throw(500, "Unexpected response from yandex.money"); + } + + transaction.paymentDetails.processing = false; + + yield transaction.persist(); + + if (transaction.status == Transaction.STATUS_SUCCESS) { + // success! + + yield* order.onPaid(); + + } + +}; + + +function* processPayment(transaction) { + var options = { + method: 'POST', + form: { + request_id: transaction.paymentDetails.requestId + }, + headers: { + 'Authorization': 'Bearer ' + transaction.paymentDetails.oauthToken + }, + url: 'https://money.yandex.ru/api/process-payment' + }; + + yield* transaction.log('request api/process-payment', options); + + var response = yield request(options); + yield* transaction.log('response api/process-payment', response.body); + + return JSON.parse(response.body); +} + diff --git a/handlers/payments/yandexmoney/renderForm.js b/handlers/payments/yandexmoney/renderForm.js new file mode 100755 index 000000000..916b72f20 --- /dev/null +++ b/handlers/payments/yandexmoney/renderForm.js @@ -0,0 +1,21 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); +const assert = require('assert'); + +module.exports = function* (transaction) { + + assert(config.payments.modules.yandexmoney.redirectUri.startsWith('http')); + + return jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + clientId: config.payments.modules.yandexmoney.clientId, + redirectUri: config.payments.modules.yandexmoney.redirectUri, + purse: config.payments.modules.yandexmoney.purse, + transactionNumber: transaction.number, + amount: transaction.amount + }); + +}; + + + diff --git a/handlers/payments/yandexmoney/router.js b/handlers/payments/yandexmoney/router.js new file mode 100755 index 000000000..6c5d486f6 --- /dev/null +++ b/handlers/payments/yandexmoney/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); +var mustBeAdmin = require('auth').mustBeAdmin; +var router = module.exports = new Router(); + +var processPayments = require('./controller/processPayments'); +var back = require('./controller/back'); + +router.get('/back', back.get); +router.get('/processPayments', mustBeAdmin, processPayments.get); + diff --git a/handlers/payments/yandexmoney/templates/form.jade b/handlers/payments/yandexmoney/templates/form.jade new file mode 100755 index 000000000..5612a281c --- /dev/null +++ b/handlers/payments/yandexmoney/templates/form.jade @@ -0,0 +1,7 @@ + +form(method="POST",action="https://sp-money.yandex.ru/oauth/authorize") + input(type="hidden",name="client_id",value=clientId) + input(type="hidden",name="response_type",value="code") + input(type="hidden",name="redirect_uri",value=(redirectUri + '?transactionNumber=' + transactionNumber)) + input(type="hidden",name="scope",value=('payment.to-account("' + purse + '").limit(,' + amount + ')')) + input(type="submit",value="Оплатить") diff --git a/handlers/paymentsMethods.js b/handlers/paymentsMethods.js new file mode 100755 index 000000000..3028afc8b --- /dev/null +++ b/handlers/paymentsMethods.js @@ -0,0 +1,8 @@ +const mongoose = require('mongoose'); +const payments = require('payments'); +const config = require('config'); + +exports.init = function(app) { + app.use(payments.populateContextMiddleware); +}; + diff --git a/handlers/play/controllers/play.js b/handlers/play/controllers/play.js new file mode 100755 index 000000000..2a0f87db6 --- /dev/null +++ b/handlers/play/controllers/play.js @@ -0,0 +1,31 @@ +var path = require('path'); +var fs = require('mz/fs'); +var config = require('config'); + +exports.get = function*() { + + var playId = this.params.playId; + + if (playId) { + playId = playId.replace(/\W/g, ''); // must be alphpanumeric + + var playPath = playId.slice(0,2).toLowerCase() + '/' + playId.slice(2,4).toLowerCase() + '/' + playId + '.zip'; + + //console.log(playPath); + + var exists = yield fs.exists(path.join(config.projectRoot, 'play', playPath)); + if (!exists) { + this.throw(404); + } + + this.locals.play = { + url: `/play/${playPath}`, + title: `${playId}.zip` + }; + + this.body = this.render('play'); + } else { + this.body = this.render('index'); + } + +}; diff --git a/handlers/play/index.js b/handlers/play/index.js new file mode 100755 index 000000000..c5b957c9c --- /dev/null +++ b/handlers/play/index.js @@ -0,0 +1,8 @@ + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/play', __dirname)); +}; + diff --git a/handlers/play/router.js b/handlers/play/router.js new file mode 100755 index 000000000..7dc34f8fe --- /dev/null +++ b/handlers/play/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var play = require('./controllers/play'); + +var router = module.exports = new Router(); + +router.get('/:playId?', play.get); diff --git a/handlers/play/templates/index.jade b/handlers/play/templates/index.jade new file mode 100755 index 000000000..fc1a63228 --- /dev/null +++ b/handlers/play/templates/index.jade @@ -0,0 +1,18 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Песочница" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._warning + +e.content + + p Извините, сервис "Песочница" на сайте больше не предоставляется. + + p Все сохранённые пользователями примеры доступны для скачивания по тем же ссылкам, но создавать новые песочницы нельзя. + + p В качестве альтернативы можно использовать plnkr.co, codepen.io или jsfiddle.net. diff --git a/handlers/play/templates/play.jade b/handlers/play/templates/play.jade new file mode 100755 index 000000000..c872fb4af --- /dev/null +++ b/handlers/play/templates/play.jade @@ -0,0 +1,19 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Песочница" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._warning + +e.content + + p Извините, сервис "Песочница" на сайте больше не предоставляется. + + p Вы можете скачать содержимое этой песочницы в виде архива + = ' ' + a(href=play.url)= play.title + | . diff --git a/handlers/profile/client/.jshintrc b/handlers/profile/client/.jshintrc new file mode 100755 index 000000000..89a1dcf00 --- /dev/null +++ b/handlers/profile/client/.jshintrc @@ -0,0 +1,18 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": true, + "node": true, // for browserify require etc + "globals": ["$", "Prism"], + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "multistr": true, + "esnext": true, + "noyield": true, + "devel": true, + "loopfunc": true, + "-W004": true, + "-W030": true // for yield* ... +} diff --git a/handlers/profile/client/config.js b/handlers/profile/client/config.js new file mode 100644 index 000000000..f4be8f222 --- /dev/null +++ b/handlers/profile/client/config.js @@ -0,0 +1,71 @@ +var angular = require('angular'); + +/** + * WARNING: must use @ngInject (@see https://github.com/olov/ng-annotate) + * for resolve factories, otherwise uglify will break the script! + * will not be auto-injected + */ +angular.module('profile').config(($locationProvider, $stateProvider, $urlRouterProvider) => { + $locationProvider.html5Mode(true); + + // For any unmatched url, redirect to / + $urlRouterProvider.otherwise("/"); + + $stateProvider + .state('root', { + abstract: true, + resolve: { + me: (Me) => Me.get() + }, + templateUrl: "/profile/templates/partials/root", + controller: 'ProfileRootCtrl' + }); + + var states = { + 'root.aboutme': { + url: "/", + title: 'Публичный профиль', + templateUrl: "/profile/templates/partials/aboutme", + controller: 'ProfileAboutMeCtrl' + }, + 'root.account': { + url: '/account', + title: 'Аккаунт', + templateUrl: "/profile/templates/partials/account", + controller: 'ProfileAccountCtrl' + }, + 'root.quiz': { + url: '/quiz', + title: 'Тесты', + templateUrl: "/profile/templates/partials/quiz", + controller: 'ProfileQuizResultsCtrl', + resolve: { + quizResults: /*@ngInject*/ (QuizResults) => QuizResults.query() + } + }, + 'root.orders': { + url: '/orders', + title: 'Заказы', + templateUrl: "/profile/templates/partials/orders", + controller: 'ProfileOrdersCtrl', + resolve: { + orders: /*@ngInject*/ (Orders) => Orders.query() + } + }, + 'root.courses': { + url: '/courses', + title: 'Курсы', + templateUrl: "/profile/templates/partials/courseGroups", + controller: 'ProfileCourseGroupsCtrl', + resolve: { + courseGroups: /*@ngInject*/ (CourseGroups) => CourseGroups.query() + } + } + }; + + // enable all states, but show in tabs only those which have info + for (var key in states) { + $stateProvider.state(key, states[key]); + } + +}); diff --git a/handlers/profile/client/controller/aboutme.js b/handlers/profile/client/controller/aboutme.js new file mode 100644 index 000000000..7ed5bbd3c --- /dev/null +++ b/handlers/profile/client/controller/aboutme.js @@ -0,0 +1,8 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileAboutMeCtrl', ($scope, me) => { + + $scope.me = me; + +}); diff --git a/handlers/profile/client/controller/account.js b/handlers/profile/client/controller/account.js new file mode 100644 index 000000000..75614f1e3 --- /dev/null +++ b/handlers/profile/client/controller/account.js @@ -0,0 +1,52 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var profile = angular.module('profile'); + +profile.controller('ProfileAccountCtrl', ($scope, $http, me, Me) => { + + $scope.me = me; + + $scope.remove = function() { + var isSure = confirm(`${me.displayName} (${me.email}) - удалить пользователя без возможности восстановления?`); + + if (!isSure) return; + + $http({ + method: 'DELETE', + url: '/users/me', + tracker: $scope.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: new FormData() + }).then((response) => { + + new notification.Success('Пользователь удалён.'); + setTimeout(function() { + window.location.href = '/'; + }, 1500); + + }, (response) => { + new notification.Error("Ошибка загрузки, статус " + response.status); + }); + }; + + $scope.removeProvider = function(providerName) { + var isSure = confirm(`${providerName} - удалить привязку?`); + + if (!isSure) return; + + $http({ + method: 'POST', + url: '/auth/disconnect/' + providerName, + tracker: this.loadingTracker + }).then((response) => { + // refresh user + $scope.me = Me.get(); + }, (response) => { + new notification.Error("Ошибка загрузки, статус " + response.status); + }); + + }; + +}); diff --git a/handlers/profile/client/controller/courseGroups.js b/handlers/profile/client/controller/courseGroups.js new file mode 100644 index 000000000..a3ab033b1 --- /dev/null +++ b/handlers/profile/client/controller/courseGroups.js @@ -0,0 +1,7 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var profile = angular.module('profile'); + +profile.controller('ProfileCourseGroupsCtrl', ($scope, $http, $window, courseGroups) => { + $scope.courseGroups = courseGroups; +}); diff --git a/handlers/profile/client/controller/orders.js b/handlers/profile/client/controller/orders.js new file mode 100644 index 000000000..03414046a --- /dev/null +++ b/handlers/profile/client/controller/orders.js @@ -0,0 +1,42 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var profile = angular.module('profile'); + +profile.controller('ProfileOrdersCtrl', ($scope, $http, $window, orders) => { + $scope.orders = orders; + + $scope.changePayment = function(order) { + $window.location.href = `/courses/orders/${order.number}?changePayment=1`; + }; + + $scope.cancelOrder = function(order) { + + var isOk = confirm("Заказ будет отменён, без возможности восстановления. Продолжать?"); + + if (!isOk) return; + + var formData = new FormData(); + formData.append("orderNumber", order.number); + + $http({ + method: 'DELETE', + url: '/payments/common/order', + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + orders.splice(orders.indexOf(order), 1); + new notification.Success("Заказ удалён."); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; +}); diff --git a/handlers/profile/client/controller/quizResults.js b/handlers/profile/client/controller/quizResults.js new file mode 100644 index 000000000..2a65a3189 --- /dev/null +++ b/handlers/profile/client/controller/quizResults.js @@ -0,0 +1,6 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileQuizResultsCtrl', ($scope, quizResults) => { + $scope.quizResults = quizResults; +}); diff --git a/handlers/profile/client/controller/root.js b/handlers/profile/client/controller/root.js new file mode 100644 index 000000000..2c0a61409 --- /dev/null +++ b/handlers/profile/client/controller/root.js @@ -0,0 +1,25 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileRootCtrl', ($scope, $state, $timeout, $http, me, promiseTracker) => { + + //window.me = me; + $scope.me = me; + + $scope.loadingTracker = promiseTracker(); + + var tabs = ['root.aboutme', 'root.account']; + window.currentUser.profileTabsEnabled.forEach(function(tab) { + tabs.push('root.' + tab); + }); + + $scope.tabs = tabs.map((stateName) => { + var state = $state.get(stateName); + return { + title: state.title, + name: state.name, + url: state.url + }; + }); + +}); diff --git a/handlers/profile/client/directive/dateInput.js b/handlers/profile/client/directive/dateInput.js new file mode 100644 index 000000000..348eee2e4 --- /dev/null +++ b/handlers/profile/client/directive/dateInput.js @@ -0,0 +1,44 @@ +var angular = require('angular'); +var moment = require('momentWithLocale'); + +angular.module('profile') + .directive('dateInput', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + + if (0) ngModel.$validators.date = function(modelValue, viewValue) { + // modelValue is + if (!viewValue) return true; + + var value = modelValue || viewValue; + if (!value) return true; + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1] - 1, split[0]); + + if (split[2].length != 4) return false; + + return date.getFullYear() == split[2] && date.getMonth() == split[1] - 1 && date.getDate() == split[0]; + }; + + //Set the initial value to the View and the Model + ngModel.$formatters.unshift(function(modelValue) { + if (!modelValue) return ""; + return moment(modelValue).format("DD.MM.YYYY"); + }); + + ngModel.$parsers.unshift(function(inputValue) { + if (!inputValue) return; + var momentDate = moment(inputValue, "DD.MM.YYYY"); + + ngModel.$setValidity('date', momentDate.isValid()); + + return momentDate.toDate(); + }); + + } + }; + }); + diff --git a/handlers/profile/client/directive/dateRangeValidator.js b/handlers/profile/client/directive/dateRangeValidator.js new file mode 100644 index 000000000..ad93666df --- /dev/null +++ b/handlers/profile/client/directive/dateRangeValidator.js @@ -0,0 +1,33 @@ +var notification = require('client/notification'); +var angular = require('angular'); +var moment = require('momentWithLocale'); + +angular.module('profile') + .directive('dateRangeValidator', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + var range = attrs.dateRangeValidator.split('-'); + var from = range[0] ? moment(range[0], "DD.MM.YYYY").toDate() : new Date(); + var to = range[1] ? moment(range[1], "DD.MM.YYYY").toDate() : new Date(); + + ngModel.$validators.dateRange = function(modelValue, viewValue) { + var value = modelValue || viewValue; + if (!value) return true; + + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1]-1, split[0]); + + if (split[2].length != 4) return false; + + return date >= from && date <= to; + }; + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/dateValidator.js b/handlers/profile/client/directive/dateValidator.js new file mode 100644 index 000000000..c91829226 --- /dev/null +++ b/handlers/profile/client/directive/dateValidator.js @@ -0,0 +1,26 @@ +var angular = require('angular'); + +angular.module('profile') + .directive('dateValidator', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + ngModel.$validators.date = function(modelValue, viewValue) { + var value = modelValue || viewValue; + if (!value) return true; + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1]-1, split[0]); + + if (split[2].length != 4) return false; + + return date.getFullYear() == split[2] && date.getMonth() == split[1]-1 && date.getDate() == split[0]; + }; + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/orderContact.js b/handlers/profile/client/directive/orderContact.js new file mode 100644 index 000000000..22938851a --- /dev/null +++ b/handlers/profile/client/directive/orderContact.js @@ -0,0 +1,59 @@ +var notification = require('client/notification'); +var angular = require('angular'); + +angular.module('profile') + .directive('orderContact', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/orderContact', + scope: { + order: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.contactName = scope.order.contactName; + scope.contactPhone = scope.order.contactPhone; + + scope.loadingTracker = promiseTracker(); + + scope.submit = function() { + if (this.contactForm.$invalid) return; + + var formData = new FormData(); + + formData.append("orderNumber", scope.order.number); + formData.append("contactName", scope.contactName); + formData.append("contactPhone", scope.contactPhone); + + $http({ + method: 'PATCH', + url: '/payments/common/order', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + new notification.Success("Информация обновлена."); + scope.order.contactName = scope.contactName; + scope.order.contactPhone = scope.contactPhone; + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/orderParticipants.js b/handlers/profile/client/directive/orderParticipants.js new file mode 100644 index 000000000..92b246d0e --- /dev/null +++ b/handlers/profile/client/directive/orderParticipants.js @@ -0,0 +1,105 @@ +var notification = require('client/notification'); +var angular = require('angular'); + +angular.module('profile') + .directive('orderParticipants', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/orderParticipants', + scope: { + order: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.participants = angular.copy(scope.order.participants); + + // add empty fields up to order.count + while (scope.participants.length != scope.order.count) { + scope.participants.push({ + inGroup: false, + email: "" + }); + } + + scope.loadingTracker = promiseTracker(); + + // returns true iff any of participants was removed + function editingParticipantsRemoved(order) { + var removedEmails = []; + for (var i = 0; i < order.participants.length; i++) { + var oldParticipant = order.participants[i]; + var wasRemoved = !scope.participants.some(function(newParticipant) { + return newParticipant.email == oldParticipant.email; + }); + if (wasRemoved) removedEmails.push(oldParticipant.email); + } + + return removedEmails; + } + + // on enter - next participant + scope.onEmailKeyDown = function($event) { + if ($event.keyCode != 13) return; + + var nextName = $event.target.name.split('_'); + nextName.push( +nextName.pop() + 1 ); + nextName = nextName.join('_'); + + var nextInput = document.getElementById(nextName); + if (nextInput) { + nextInput.focus(); + } + }; + + + scope.submit = function() { + if (this.participantsForm.$invalid) return; + + // if the order is paid, warn that removing participants is bad + if (scope.order.status == 'success') { + var removedEmails = editingParticipantsRemoved(scope.order); + var isOk = confirm("Вы удалили участников, которые получили приглашения на курс: " + removedEmails + ".\nПри продолжении их приглашения станут недействительными.\nПродолжить?"); + + if (!isOk) return; + } + + var formData = new FormData(); + + formData.append("orderNumber", scope.order.number); + var emails = scope.participants.map(function(participant) { + if (participant.inGroup) return; // cannot modify accepted, server will give error if I try + return participant.email; + }).filter(Boolean); + + formData.append("emails", emails); + + $http({ + method: 'PATCH', + url: '/payments/common/order', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + new notification.Success(response.data); + scope.order.participants = angular.copy(scope.participants); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profileAuthProviders.js b/handlers/profile/client/directive/profileAuthProviders.js new file mode 100644 index 000000000..49396095d --- /dev/null +++ b/handlers/profile/client/directive/profileAuthProviders.js @@ -0,0 +1,36 @@ +var notification = require('client/notification'); +var angular = require('angular'); +require('../service/authPopup'); + +angular.module('profile') + .directive('profileAuthProviders', function(promiseTracker, $http, authPopup, Me) { + return { + templateUrl: '/profile/templates/partials/profileAuthProviders', + replace: true, + + link: function(scope) { + + scope.connect = function(providerName) { + authPopup('/auth/connect/' + providerName, () => { + // refresh user + scope.me = Me.get(); + + }, () => { + console.error("fail", arguments); + }); + }; + + scope.connected = function(providerName) { + var connected = false; + + if (!scope.me.providers) return false; + scope.me.providers.forEach(function(provider) { + if (provider.name == providerName) connected = true; + }); + + return connected; + }; + } + }; + + }); diff --git a/handlers/profile/client/directive/profileField.js b/handlers/profile/client/directive/profileField.js new file mode 100644 index 000000000..103e1b52f --- /dev/null +++ b/handlers/profile/client/directive/profileField.js @@ -0,0 +1,108 @@ +var notification = require('client/notification'); +var angular = require('angular'); + + +angular.module('profile') + .directive('profileField', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/profileField', + scope: { + title: '@fieldTitle', + name: '@fieldName', + formatValue: '=?fieldFormatValue', + value: '=fieldValue' + }, + replace: true, + transclude: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + if (!scope.formatValue) { + scope.formatValue = function(value) { + return value; + }; + } + + + scope.loadingTracker = promiseTracker(); + + scope.edit = function() { + if (this.editing) return; + this.editing = true; + this.editingValue = this.value; + }; + + scope.submit = function() { + if (this.form.$invalid) return; + + if (this.value == this.editingValue) { + this.editing = false; + this.editingValue = ''; + return; + } + + var formData = new FormData(); + formData.append(this.name, this.editingValue); + + $http({ + method: 'PATCH', + url: '/users/me', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + if (this.name == 'displayName') { + new notification.Success("Изменение имени везде произойдёт после перезагрузки страницы.", 'slow'); + } else if (this.name == 'email') { + new notification.Warning("Требуется подтвердить смену email, проверьте почту.", 'slow'); + } else if (this.name == 'profileName') { + new notification.Success("Ваш профиль доступен по новому адресу, страница будет перезагружена"); + var newProfileName = this.editingValue; // remember now, (editing field will be reset) + setTimeout(function() { + window.location.href = '/profile/' + newProfileName + '/account'; + }, 2000); + } else { + new notification.Success("Информация обновлена."); + } + + this.editing = false; + this.value = this.editingValue; + this.editingValue = ''; + + }, (response) => { + //console.log(response); + if (response.status == 400) { + + new notification.Error(response.data.message); + } else if (response.status == 409) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + scope.cancel = function() { + if (!this.editing) return; + // if we turn editing off now, then click event may bubble up, reach the form and enable editing back + // so we wait until the event bubbles and ends, and *then* cancel + $timeout(() => { + this.editing = false; + this.editingValue = ""; + }); + }; + + transclude(scope, function(clone, scope) { + element[0].querySelector('[control-transclude]').append(clone[0]); + }); + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profilePassword.js b/handlers/profile/client/directive/profilePassword.js new file mode 100644 index 000000000..d1d6fb174 --- /dev/null +++ b/handlers/profile/client/directive/profilePassword.js @@ -0,0 +1,81 @@ +var notification = require('client/notification'); +var angular = require('angular'); + + +angular.module('profile') + .directive('profilePassword', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/profilePassword', + scope: { + hasPassword: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.password = ''; + scope.passwordOld = ''; + + scope.loadingTracker = promiseTracker(); + + scope.edit = function() { + if (this.editing) return; + this.editing = true; + + $timeout(function() { + var input = element[0].elements[scope.hasPassword ? 'passwordOld' : 'password']; + input.focus(); + }); + }; + + scope.submit = function() { + if (scope.form.$invalid) return; + + var formData = new FormData(); + formData.append("password", this.password); + formData.append("passwordOld", this.passwordOld); + + $http({ + method: 'PATCH', + url: '/users/me', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + new notification.Success("Пароль обновлён."); + scope.editing = false; + // now have password for sure + scope.hasPassword = true; + + // and clean password fields + scope.password = ''; + scope.passwordOld = ''; + scope.form.$setPristine(); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message || response.data.errors.password); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + scope.cancel = function() { + if (!this.editing) return; + // if we turn editing off now, then click event may bubble up, reach the form and enable editing back + // so we wait until the event bubbles and ends, and *then* cancel + $timeout(() => { + this.editing = false; + }); + }; + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profilePhoto.js b/handlers/profile/client/directive/profilePhoto.js new file mode 100644 index 000000000..cd93c5f9a --- /dev/null +++ b/handlers/profile/client/directive/profilePhoto.js @@ -0,0 +1,70 @@ +var notification = require('client/notification'); +var angular = require('angular'); +var thumb = require('client/image').thumb; +var promptSquarePhoto = require('photoCut').promptSquarePhoto; + +angular.module('profile') + .directive('profilePhoto', function(promiseTracker, $http) { + return { + templateUrl: '/profile/templates/partials/profilePhoto', + scope: { + photo: '=' + }, + replace: true, + + link: function(scope, element, attrs, noCtrl) { + scope.loadingTracker = promiseTracker(); + + scope.changePhoto = function() { + + promptSquarePhoto({ + minSize: 160, + onSuccess: uploadPhoto + }); + + }; + + function uploadPhoto(file) { + + var formData = new FormData(); + formData.append("photo", file); + + $http({ + method: 'POST', + url: '/imgur/upload', + headers: {'Content-Type': undefined }, + tracker: scope.loadingTracker, + transformRequest: angular.identity, + data: formData + }).then(function(response) { + + return $http({ + method: 'PATCH', + url: '/users/me', + tracker: scope.loadingTracker, + data: { + photoId: response.data.imgurId + } + }); + + }).then(function(response) { + scope.photo = response.data.photo; + new notification.Success("Изображение обновлено."); + }, function(response) { + if (response.status == 400) { + new notification.Error("Неверный тип файла или изображение повреждено."); + } else { + /* global403Interceptor will show the rest */ + } + }); + + + } + } + }; + + }) + .filter('thumb', () => thumb); + + + diff --git a/handlers/profile/client/factory/courseGroups.js b/handlers/profile/client/factory/courseGroups.js new file mode 100644 index 000000000..49cf08737 --- /dev/null +++ b/handlers/profile/client/factory/courseGroups.js @@ -0,0 +1,19 @@ +var angular = require('angular'); + +angular.module('profile').factory('CourseGroups', ($resource) => { + return $resource('/courses/profile/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.forEach(function(group) { + group.dateStart = new Date(group.dateStart); + group.dateEnd = new Date(group.dateEnd); + }); + + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/me.js b/handlers/profile/client/factory/me.js new file mode 100644 index 000000000..2b38985e6 --- /dev/null +++ b/handlers/profile/client/factory/me.js @@ -0,0 +1,15 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +angular.module('profile').factory('Me', ($resource) => { + return $resource('/users/me', {}, { + get: { + method: 'GET', + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.created = new Date(data.created); + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/orders.js b/handlers/profile/client/factory/orders.js new file mode 100644 index 000000000..283ce231a --- /dev/null +++ b/handlers/profile/client/factory/orders.js @@ -0,0 +1,27 @@ +var angular = require('angular'); + +angular.module('profile').factory('Orders', ($resource) => { + return $resource('/payments/common/orders/user/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.forEach(function(order) { + order.created = new Date(order.created); + + order.countDetails = { + free: order.count - order.participants.length, + busy: order.participants.length, + inGroup: order.participants.filter(function(participant) { + return participant.inGroup; + }).length + }; + + }); + + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/quizResults.js b/handlers/profile/client/factory/quizResults.js new file mode 100644 index 000000000..5c00a7c59 --- /dev/null +++ b/handlers/profile/client/factory/quizResults.js @@ -0,0 +1,18 @@ +var angular = require('angular'); + +angular.module('profile').factory('QuizResults', ($resource) => { + return $resource('/quiz/results/user/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + + data = JSON.parse(data); + data.forEach(function(result) { + result.created = new Date(result.created); + }); + return data; + } + } + }); +}); diff --git a/handlers/profile/client/index.js b/handlers/profile/client/index.js new file mode 100755 index 000000000..993d3db9f --- /dev/null +++ b/handlers/profile/client/index.js @@ -0,0 +1,71 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var pluralize = require('textUtil/pluralize'); + +var profile = angular.module('profile', [ + 'ui.router', 'ngResource', 'global403Interceptor', 'ajoslin.promise-tracker', 'progress', 'focusOn', 'ngMessages' +]); + +require('./directive/profileField'); +require('./directive/orderParticipants'); +require('./directive/orderContact'); +require('./directive/profilePhoto'); +require('./directive/profilePassword'); +require('./directive/profileAuthProviders'); +require('./directive/dateValidator'); +require('./directive/dateRangeValidator'); + +require('./factory/me'); + +require('./factory/quizResults'); + +require('./factory/orders'); +require('./factory/courseGroups'); + +require('./config'); + +require('./controller/root'); + +require('./controller/orders'); + +require('./controller/courseGroups'); + +require('./controller/aboutme'); + +require('./controller/quizResults'); + +require('./controller/account'); + + +profile + .filter('capitalize', () => function(str) { + return str[0].toUpperCase() + str.slice(1); + }) + .filter('longDate', () => function(date, offset) { + date = moment(date); + if (offset !== undefined) date = date.utcOffset(offset); + return date.format('D MMMM YYYY в LT'); + }) + .filter('shortDate', () => function(date, offset) { + date = moment(date); + if (offset !== undefined) date = date.utcOffset(offset); + return date.format('D MMM YYYY'); + }) + .filter('quizDuration', () => function(ms) { + var seconds = Math.round(ms / 1000); + return moment.duration(seconds, 'seconds').humanize(); + }) + .filter('pluralize', function() { + return pluralize; + }) + .filter('trust_html', function($sce){ + return function(text) { + text = $sce.trustAsHtml(text); + return text; + }; + }); + + + + diff --git a/handlers/profile/client/service/authPopup.js b/handlers/profile/client/service/authPopup.js new file mode 100644 index 000000000..ff77ca9f8 --- /dev/null +++ b/handlers/profile/client/service/authPopup.js @@ -0,0 +1,28 @@ +var angular = require('angular'); + +angular.module('profile') + .service('authPopup', function() { + + var authPopup; + + return function(url, onSuccess, onFail) { + + if (authPopup && !authPopup.closed) { + authPopup.close(); // close old popup if any + } + var width = 800, height = 600; + var top = (window.outerHeight - height) / 2; + var left = (window.outerWidth - width) / 2; + + window.authForm = { + onAuthSuccess: onSuccess, + onAuthFailure: onFail + }; + + authPopup = window.open(url, 'authForm', 'width=' + width + ',height=' + height + ',scrollbars=0,top=' + top + ',left=' + left); + }; + +}); + + + diff --git a/handlers/profile/controller/index.js b/handlers/profile/controller/index.js new file mode 100755 index 000000000..fa4452541 --- /dev/null +++ b/handlers/profile/controller/index.js @@ -0,0 +1,39 @@ +var config = require('config'); +var User = require('users').User; +var mongoose = require('mongoose'); +var QuizResult = require('quiz').QuizResult; +var Order = require('payments').Order; + +// skips the request unless it's the owner +exports.get = function* (next) { + + if (!this.user) { + yield* next; + return; + } + + // /profile -> /profile/iliakan + if (!this.params.profileName) { + this.status = 301; + this.redirect(`/profile/${this.user.profileName}`); + return; + } + + var user = yield User.findOne({profileName: this.params.profileName}).exec(); + + if (!user) { + this.throw(404); + } + + // if the visitor is the profile owner + if (String(this.user._id) == String(user._id)) { + + this.locals.title = this.user.displayName; + + this.body = this.render('index'); + } else { + yield* next; + } + +}; + diff --git a/handlers/profile/controller/partials.js b/handlers/profile/controller/partials.js new file mode 100755 index 000000000..055589900 --- /dev/null +++ b/handlers/profile/controller/partials.js @@ -0,0 +1,10 @@ +var config = require('config'); +var path = require('path'); + +exports.get = function* (next) { + // aboutme -> return rendererd partials/aboutme.jade + + var partialJade = path.join('partials', path.basename(this.params.partial.replace(/\./g, ''))); + this.body = this.render(partialJade); +}; + diff --git a/handlers/profile/index.js b/handlers/profile/index.js new file mode 100755 index 000000000..d3f3f9b61 --- /dev/null +++ b/handlers/profile/index.js @@ -0,0 +1,6 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/profile', __dirname)); +}; diff --git a/handlers/profile/router.js b/handlers/profile/router.js new file mode 100755 index 000000000..ab09d058f --- /dev/null +++ b/handlers/profile/router.js @@ -0,0 +1,13 @@ +var Router = require('koa-router'); + +var partials = require('./controller/partials'); +var index = require('./controller/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); + +router.get('/:profileName/:tab?', index.get); + +router.get('/templates/partials/:partial', partials.get); + diff --git a/handlers/profile/templates/blocks/profile-ok-cancel.jade b/handlers/profile/templates/blocks/profile-ok-cancel.jade new file mode 100755 index 000000000..09b6efc4f --- /dev/null +++ b/handlers/profile/templates/blocks/profile-ok-cancel.jade @@ -0,0 +1,4 @@ ++e.ok-cancel + +b('button')(type="submit").button._action.__item-save + +e('span').text Сохранить + +e('button').item-cancel Отмена diff --git a/handlers/profile/templates/index.jade b/handlers/profile/templates/index.jade new file mode 100755 index 000000000..dba273a71 --- /dev/null +++ b/handlers/profile/templates/index.jade @@ -0,0 +1,20 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + +block append head + base(href=user.getProfileUrl() + '/') + !=css("quiz") + +block content + + !=js("angular", {defer: true}) + !=js("profile", {defer: true}) + + +b(ng-app="profile" ng-strict-di) + + +e('ui-view').view(progress="loadingTracker.active()" progress-overlay progress-spinner="{class:'profile__spinner'}") Загрузка... + diff --git a/handlers/profile/templates/partials/aboutme.jade b/handlers/profile/templates/partials/aboutme.jade new file mode 100755 index 000000000..6bc4eaebf --- /dev/null +++ b/handlers/profile/templates/partials/aboutme.jade @@ -0,0 +1,33 @@ +include /bem + ++b.profile + + +e.title + +e.title-content + +e('p').note Информация о вас, которая будет видна другим посетителям. + + +e.fields._about + +e('profile-field')(field-name="realName" field-title="Имя" field-value="me.realName") + input.text-input__control(focus-on="editing" placeholder="Иван Иванович" ng-model="editingValue" type="text" name="input") + + +e('profile-field')(field-name="publicEmail" field-title="Публичный email" field-value="me.publicEmail") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="email" name='input') + + +e('profile-field')(field-name="country" field-title="Страна" field-value="me.country") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text" name='input') + + // +e('profile-field')(field-name="country" field-title="Страна" field-value="me.country") + select(ng-model="editingValue" name="input") + - var countries = {Russia: 'Россия', Belorussia: 'Белоруссия'} + for title, key in countries + option(value=key)= title + + + +e('profile-field')(field-name="town" field-title="Город" field-value="me.town") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text" name='input') + + +e('profile-field')(field-name="birthday" field-title="Дата рождения" field-value="me.birthday") + input.text-input__control(focus-on="editing" date-validator date-range-validator="01.01.1900-" placeholder="30.12.2000" ng-model="editingValue" type="text" name="input") + + +e('profile-field')(field-name="interests" field-title="Интересы" field-value="me.interests") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text") diff --git a/handlers/profile/templates/partials/account.jade b/handlers/profile/templates/partials/account.jade new file mode 100755 index 000000000..30d010b78 --- /dev/null +++ b/handlers/profile/templates/partials/account.jade @@ -0,0 +1,56 @@ +include /bem + ++b.profile + + +e.title + +e.title-content + +e('h2').inline-title Управление аккаунтом + + +e.fields._account + +e('profile-field')(field-name="displayName" field-title="Имя пользователя" field-value="me.displayName") + input.text-input__control(type="text" name="input" + focus-on="editing" + required + ng-minlength="2" + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + ) + + +e('profile-field')(field-name="profileName" field-title="Имя страницы профиля" field-value="me.profileName") + input.text-input__control(type="text" name="input" + focus-on="editing" + ng-pattern="/^[a-z0-9-]*$/" + ng-maxlength="64" + ng-minlength="2" + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 0, 'blur': 0} }" + ) + + +e('profile-field')(field-name="email" field-title="Email" field-value="me.email") + input.text-input__control(type="email" name="input" + focus-on="editing" + required + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + ) + + + +e('profile-password')(has-password="me.hasPassword") + + +e.title + +e.title-content + +e('h2').inline-title Привязанные внешние аккаунты + +e('p').note(ng-if="!me.providers.length") При привязке аккаунта можно будет заходить на сайт одним нажатием кнопки. + + +e.linked-account(ng-repeat="provider in me.providers") + +e.account-content + +e.linked-name + +e('img').linked-upic(ng-src="{{provider.photo}}") {{provider.displayName}} + +e.linked-provider {{provider.name | capitalize}} + +e('button').linked-provider-remove(ng-click="removeProvider(provider.name)", title="Удалить привязку") + + +e('profile-auth-providers') + + +e.action-item + +e.action-content + +e('button')(type="button" ng-click="remove()").action._remove-account Удалить аккаунт diff --git a/handlers/profile/templates/partials/courseGroups.jade b/handlers/profile/templates/partials/courseGroups.jade new file mode 100644 index 000000000..a50fb339b --- /dev/null +++ b/handlers/profile/templates/partials/courseGroups.jade @@ -0,0 +1,30 @@ +include /bem + ++b.profile + + +b.courses-table + +e('table').table + + +e('tr').line(ng-repeat="group in courseGroups") + +e('th').main + +e('h3').title {{group.title}} + +e('ul').info-links + +e('li').info-links-item(ng-repeat="link in group.links") + +e('a').info-link(ng-href="{{link.url}}") {{link.title}} + +e('td').info + //- group start date/time is always shown as in GMT+3 + +e('strong').start Начало {{group.dateStart | shortDate:3}} + +e.schedule Занятия {{ group.timeDesc }} + +e('td').verify + +e('span').status._verified(ng-if="group.status == 'accepted'") Участие подтверждено + +b('a').button._action(ng-href="{{group.inviteUrl}}" ng-if="group.status == 'invite'") + +e('span').text Подтвердить участие + +e('span').status._started(ng-if="group.status == 'started'") Занятия начались + +e('span')(ng-if="group.status == 'ended'") + +e('span').status._ended Курсы завершены + +e('ul').info-links + +e('li')(ng-if="group.feedbackLink").info-links-item + +e('a').feedback(ng-href="{{group.feedbackLink}}") Оставить отзыв + +e('li').info-links-item + +e('a').feedback(ng-href="{{group.certificateLink}}") Скачать сертификат + diff --git a/handlers/profile/templates/partials/orderContact.jade b/handlers/profile/templates/partials/orderContact.jade new file mode 100644 index 000000000..8e879c826 --- /dev/null +++ b/handlers/profile/templates/partials/orderContact.jade @@ -0,0 +1,42 @@ +include /bem + ++b.invoice-table + + +e('h4').settings-title Контактная информация + + +e('form').contact-form( + name="contactForm" + novalidate + progress="loadingTracker.active()" + progress-overlay + ng-submit="submit()" + ) + + +e.settings-line + +e("label").contact-form-label(for="contact-name{{order.number}}") Имя и фамилия: + +b("span").text-input + +e("input").control( + type="text", + ng-required, + name="contact-name", + id="contact-name{{order.number}}" + ng-model="contactName" + placeholder="Пушкин Александр Сергеевич" + ) + + +e.settings-line + +e('label').contact-form-label(for="contact-phone{{order.number}}") Телефон: + +b.full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control( + placeholder="+X XXX XXX-XX-XX", + type="tel", + id="contact-phone{{order.number}}" + ng-model="contactPhone" + ) + + +e.settings-line._submit + +b('button').button._common(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}") + +e('span').text Сохранить контакты + diff --git a/handlers/profile/templates/partials/orderParticipants.jade b/handlers/profile/templates/partials/orderParticipants.jade new file mode 100644 index 000000000..011227e6c --- /dev/null +++ b/handlers/profile/templates/partials/orderParticipants.jade @@ -0,0 +1,49 @@ +include /bem + ++b.invoice-table + +e('form').participants-form( + name="participantsForm" + novalidate + progress="loadingTracker.active()" + progress-overlay + ng-submit="submit()" + ) + +e('h4').settings-title Участники + + +e('ul').settings-participants + +e('li').settings-participant(ng-repeat="participant in participants") + +e('label').participant-label(for='participant_{{order.number}}_{{$index}}') Участник {{$index + 1}}: + +b('span').text-input.__input( + ng-form="participantForm" + ng-class="[participant.inGroup ? 'text-input_approved_yes' : 'text-input_approved_no', participantForm.$invalid && 'text-input_invalid']" + ) + +e('input').control( + placeholder="email", + name='participant_{{order.number}}_{{$index}}', + ng-type="email", + ng-pattern=validate.patterns.email, + ng-model="participant.email", + ng-keydown="onEmailKeyDown($event)" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + id='participant_{{order.number}}_{{$index}}', + ng-disabled="participant.inGroup" + ) + +e('span').status( + ng-if="participant.inGroup" + data-tooltip="Участие подтверждено." + ) + +e('span').status( + ng-if="!participant.inGroup && order.status != 'success'" + data-tooltip="Подтверждение участия станет возможным после оплаты." + ) + +e('span').status( + ng-if="!participant.inGroup && order.status == 'success'" + data-tooltip="Участнику требуется подтвердить участие." + ) + +e.err(ng-if="participantForm.$invalid") Некорректный email. + + + +e.settings-line_submit(ng-if="order.count > order.countDetails.inGroup") + +b('button').button._common(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}") + +e('span').text Сохранить участников + diff --git a/handlers/profile/templates/partials/orders.jade b/handlers/profile/templates/partials/orders.jade new file mode 100644 index 000000000..678450904 --- /dev/null +++ b/handlers/profile/templates/partials/orders.jade @@ -0,0 +1,72 @@ +include /bem + + ++b.invoice-table + + +e('p').empty-message(ng-if="!orders.length") Нет активных заказов. + + +e('table').table + + +e('tr').data(ng-repeat-start="order in orders" ng-class="{'invoice-table__data_show_settings': order.isEditing}") + +e('th').main + +e('span').number Заказ № {{ order.number }} + +e('time').time(datetime="{{order.created}}") {{order.created | longDate}} + +e('h3').title {{ order.title }} + + +e.slots + +e('strong').slots-total {{ order.count }} {{ order.count | pluralize:"место":"места":"мест" }} + + +e('strong').slots-free(ng-if="order.countDetails.free") + | {{ order.countDetails.free }} свободно + + +e('strong').slots-busy(ng-if="order.countDetails.busy") + | {{ order.countDetails.busy }} занято + + +e('span').slots-confirmed(ng-if="order.countDetails.inGroup") + |  ({{ order.countDetails.inGroup }} подтверждено) + + div + a(href='#' ng-click="order.isEditing = !order.isEditing") детали заказа + + +e('td').info + +e('a').info-link(ng-href='{{order.courseUrl}}') Описание курса + + +e('td').price + +b.price {{ order.amount }} RUB + +e(ng-class="['invoice-table__payment-status', 'invoice-table__payment-status_' + order.orderInfo.status]") + | {{ order.orderInfo.statusText }} + +e.payment-type(ng-if="order.paymentMethod") ({{ order.paymentMethod }}) + + +e('tr').settings(ng-repeat-end) + +e('td').settings-cell(colspan=3) + +e.settings-dropdown + +e('button').settings-dropdown-close.close-button(ng-click="order.isEditing = false") + + div(ng-if="order.orderInfo.status == 'pending'") + +b.notification.__state-notification._message._info + +e.content + | В данный момент мы ожидаем от вас оплату. После того, как мы получим подтверждение оплаты, + | указанным участникам курсов придёт письмо со всей необходимой информацией. + + div(ng-if="order.orderInfo.status == 'success'") + div(ng-if="order.count > order.countDetails.inGroup") + +b.notification.__state-notification._message._success + +e.content + | Каждому участнику отправляется письмо-приглашение. Участника можно изменить до тех пор, пока он не принял его. + + +e.settings-dropdown-cell._left + +e('order-participants')(order="order") + + +e.settings-dropdown-cell._right + +e('order-contact')(order="order") + + +e.settings-line._foot(ng-if="order.orderInfo.status != 'success'") + +e('h4').settings-title Оплата + +e('p').note(ng-bind-html="order.orderInfo.descriptionProfile | trust_html") + +b('button').button._common(type="button" ng-click="changePayment(order)") + +e('span').text Изменить метод оплаты + + +e('a').cancel-order(ng-click="cancelOrder(order)") Отменить заказ + + + diff --git a/handlers/profile/templates/partials/profileAuthProviders.jade b/handlers/profile/templates/partials/profileAuthProviders.jade new file mode 100755 index 000000000..01c1023c1 --- /dev/null +++ b/handlers/profile/templates/partials/profileAuthProviders.jade @@ -0,0 +1,11 @@ +include /bem + ++b.profile-providers + +e.content + +e.title Привязать: + +e.socials + +b('button').social-login._facebook.__social-login(ng-click="connect('facebook')" ng-if="!connected('facebook')") Facebook + +b('button').social-login._google.__social-login(ng-click="connect('google')" ng-if="!connected('google')") Google+ + +b('button').social-login._vkontakte.__social-login(ng-click="connect('vkontakte')" ng-if="!connected('vkontakte')") Вконтакте + +b('button').social-login._github.__social-login(ng-click="connect('github')" ng-if="!connected('github')") Github + +b('button').social-login._yandex.__social-login(ng-click="connect('yandex')" ng-if="!connected('yandex')") Яндекс \ No newline at end of file diff --git a/handlers/profile/templates/partials/profileField.jade b/handlers/profile/templates/partials/profileField.jade new file mode 100755 index 000000000..3bbd8ec52 --- /dev/null +++ b/handlers/profile/templates/partials/profileField.jade @@ -0,0 +1,34 @@ +include /bem + ++b('form').profile-field._editable( + novalidate + progress="loadingTracker.active()" + progress-overlay + name="form" + ng-submit="submit()" + ng-click="edit()" + ng-class="{'profile-field_editing': editing}" +) + + +e.lcell + +e.name {{title}}: + + +e.rcell + +e.value(ng-bind="formatValue(value)") + +e.change(ng-show="editing") + +e.change-content + +b.text-input._small.__control(ng-class="{'text-input_invalid':form.$invalid}") + div(control-transclude) + //- ng-transclude comes here + //- directive content will be appended here with current scope, not parent scope! + +e(ng-messages="form.input.$error").err + +e(ng-message="required") Значение не должно быть пустым. + +e(ng-message="minlength") Значение слишком короткое. + +e(ng-message="email") Некорректный email. + +e(ng-message="date") Дата неверна, формат: дд.мм.гггг. + +e(ng-message="dateRange") Такой даты здесь не может быть. + + +e.ok-cancel + +b('button')(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}").button._action.__save + +e('span').text Сохранить + +e('button').cancel(type="button" ng-click="cancel()") Отмена diff --git a/handlers/profile/templates/partials/profilePassword.jade b/handlers/profile/templates/partials/profilePassword.jade new file mode 100755 index 000000000..a8b324900 --- /dev/null +++ b/handlers/profile/templates/partials/profilePassword.jade @@ -0,0 +1,37 @@ +include /bem + ++b('form').profile-field._editable._password( + novalidate + progress="loadingTracker.active()" + progress-overlay + name="form" + ng-submit="submit()" + ng-click="edit()" + ng-class="{'profile-field_editing': editing}" +) + + +e('button').action._change-password(type="button" ng-hide="editing") Изменить пароль + + +e.change(ng-show="editing") + +e.change-content + + //- not ng-if, because ng-if creates a scope, so ng-model won't work + //- http://stackoverflow.com/questions/18342917/angularjs-ng-model-doesnt-work-inside-ng-if + +e.labeled.__pass-change(ng-show="hasPassword") + +e.labeled-label Старый пароль: + +b.text-input._small.__labeled-text.__pass(ng-class="{'text-input_invalid':form.passwordOld.$invalid}") + +e('input').control(type="password" name="passwordOld" ng-model="passwordOld") + + +e.labeled.__pass-change + +e.labeled-label(ng-if="hasPassword") Новый пароль: + +e.labeled-label(ng-if="!hasPassword") Укажите пароль + +b.text-input._small.__labeled-text.__pass(ng-class="{'text-input_invalid':(form.password.$dirty && form.password.$invalid)}") + +e('input').control(type="password" name="password" minlength="4" required ng-model="password") + +e(ng-messages="form.password.$error").err + +e(ng-message="required") Пароль не должен быть пустым. + +e(ng-message="minlength") Пароль слишком короткий. + + +e.ok-cancel + +b('button')(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}").button._action.__save + +e('span').text Сохранить + +e('button').cancel(type="button" ng-click="cancel()") Отмена diff --git a/handlers/profile/templates/partials/profilePhoto.jade b/handlers/profile/templates/partials/profilePhoto.jade new file mode 100755 index 000000000..dd057fd3c --- /dev/null +++ b/handlers/profile/templates/partials/profilePhoto.jade @@ -0,0 +1,11 @@ +include /bem + ++b.profile-photo + +e.upic( + style="background-image: url('{{photo | thumb:146:146 }}')" + progress="loadingTracker.active()" + progress-overlay + progress-spinner="{elemClass:'profile__upic_loading'}" + ) + +e.upic-edit(ng-click="changePhoto()") Загрузить
    фотографию + +e.content diff --git a/handlers/profile/templates/partials/quiz.jade b/handlers/profile/templates/partials/quiz.jade new file mode 100755 index 000000000..50cb4d70b --- /dev/null +++ b/handlers/profile/templates/partials/quiz.jade @@ -0,0 +1,34 @@ +include /bem + ++b.profile + + + +b.quiz-results-table + + +e('p').empty-message(ng-if="!quizResults.length") Нет пройденных тестов. + + + +e("table").results(ng-if="quizResults.length") + + //- [{"created":"2015-03-25T15:44:09.907Z","quizTitle":"Второй тест","score":0,"level":"junior","levelTitle":"новичок","time":2286}] + +e("tr").result(ng-repeat="result in quizResults") + + +e("th").test-info + +e("time").time {{result.created | longDate}} + +e("h1").name(ng-if="result.quizUrl") + a(href="{{result.quizUrl}}") {{result.quizTitle}} + +e("h1").name(ng-if="!result.quizUrl") {{result.quizTitle}} + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value {{result.score}}% + + +e("td").level + +e("h1").title Уровень: + +e("p").level-info {{result.levelTitle}} + + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info {{result.time | quizDuration}} diff --git a/handlers/profile/templates/partials/root.jade b/handlers/profile/templates/partials/root.jade new file mode 100755 index 000000000..8b00f415e --- /dev/null +++ b/handlers/profile/templates/partials/root.jade @@ -0,0 +1,13 @@ +include /bem + ++b.profile + + +e('profile-photo')(photo="me.photo") + + +e.content + +e.tabs + +e.tab(ng-repeat="tab in tabs" ui-sref-active="profile__tab_current") + +e.tab-content + +e('a').tab-link(ui-sref="{{tab.name}}") {{tab.title}} + + ui-view Loading... diff --git a/handlers/profileGuest/controller/index.js b/handlers/profileGuest/controller/index.js new file mode 100755 index 000000000..eb39f939c --- /dev/null +++ b/handlers/profileGuest/controller/index.js @@ -0,0 +1,68 @@ +var config = require('config'); +var User = require('users').User; +var mongoose = require('mongoose'); +var QuizResult = require('quiz').QuizResult; + +// skips the request if it's the owner +exports.get = function* (next) { + + var user = yield User.findOne({ + profileName: this.params.profileName + }).exec(); + + if (!user) { + this.throw(404); + } + + // the visitor is the owner => another middleware + if (this.user && String(this.user._id) == String(user._id)) { + yield* next; + return; + } + + var tabName = this.params.tab || 'aboutme'; + + this.locals.tabs = { + aboutme: { + url: user.getProfileUrl() + } + }; + + // public quiz tab + if (~user.profileTabsEnabled.indexOf('quiz')) { + + this.locals.tabs.quiz = { + url: user.getProfileUrl() + '/quiz' + }; + + var quizResults = yield* QuizResult.getLastAttemptsForUser(user._id); + + quizResults = quizResults.map(function(result) { + return { + created: result.created, + quizTitle: result.quizTitle, + score: result.score, + level: result.level, + levelTitle: result.levelTitle, + quizUrl: result.quiz && result.quiz.getUrl(), + time: result.time + }; + }); + + this.locals.quizResults = quizResults; + } + + if (!this.locals.tabs[tabName]) { + this.throw(404); + } + + this.locals.title = user.displayName; + + this.locals.tabs[tabName].active = true; + + this.body = this.render(tabName, { + profileUser: user + }); + +}; + diff --git a/handlers/profileGuest/index.js b/handlers/profileGuest/index.js new file mode 100755 index 000000000..d3f3f9b61 --- /dev/null +++ b/handlers/profileGuest/index.js @@ -0,0 +1,6 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/profile', __dirname)); +}; diff --git a/handlers/profileGuest/router.js b/handlers/profileGuest/router.js new file mode 100755 index 000000000..d21e13acb --- /dev/null +++ b/handlers/profileGuest/router.js @@ -0,0 +1,9 @@ +var Router = require('koa-router'); + +var index = require('./controller/index'); + +var router = module.exports = new Router(); + +router.get('/:profileName/:tab?', index.get); + + diff --git a/handlers/profileGuest/templates/aboutme.jade b/handlers/profileGuest/templates/aboutme.jade new file mode 100755 index 000000000..46f58e9d3 --- /dev/null +++ b/handlers/profileGuest/templates/aboutme.jade @@ -0,0 +1,61 @@ +extends root + +block profileContent + + +e.fields + + if profileUser.realName + +b.profile-field + +e.lcell + +e.name Имя + +e.rcell + +e.value= profileUser.realName + + if profileUser.country + +b.profile-field + +e.lcell + +e.name Страна + +e.rcell + +e.value= profileUser.country + + if profileUser.town + +b.profile-field + +e.lcell + +e.name Город + +e.rcell + +e.value= profileUser.town + + if profileUser.publicEmail + +b.profile-field + +e.lcell + +e.name E-Mail + +e.rcell + +e.value= profileUser.publicEmail + + + if profileUser.interests + +b.profile-field + +e.lcell + +e.name Интересы + +e.rcell + +e.value= profileUser.interests + + if profileUser.birthday + +b.profile-field + +e.lcell + +e.name Дата рождения + +e.rcell + +e.value= profileUser.birthday + + +b.profile-field + +e.lcell + +e.name Зарегистрирован + +e.rcell + +e.value= moment(profileUser.created).format('DD.MM.YY в LT') + + +b.profile-field + +e.lcell + +e.name Активность + +e.rcell + +e.value Последняя активность #{moment(profileUser.lastActivity || profileUser.created).format('DD.MM.YY в LT')} + diff --git a/handlers/profileGuest/templates/quiz.jade b/handlers/profileGuest/templates/quiz.jade new file mode 100755 index 000000000..f659fbc1d --- /dev/null +++ b/handlers/profileGuest/templates/quiz.jade @@ -0,0 +1,30 @@ +extends root + +block append head + !=css("quiz") + +block profileContent + + +b.quiz-results-table + +e("table").results + each result in quizResults + +e("tr").result(ng-repeat="result in quizResults") + +e("th").test-info + +e("time").time= moment(result.created).format('D MMMM YYYY в LT') + +e("h1").name + if result.quizUrl + a(href=result.quizUrl)= result.quizTitle + else + = result.quizTitle + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value #{result.score}% + +e("td").level + +e("h1").title Уровень: + +e("p").level-info #{result.levelTitle} + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info= moment.duration(Math.round(result.time/1000), 'seconds').humanize() diff --git a/handlers/profileGuest/templates/root.jade b/handlers/profileGuest/templates/root.jade new file mode 100755 index 000000000..f0ebd90b7 --- /dev/null +++ b/handlers/profileGuest/templates/root.jade @@ -0,0 +1,30 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + +block content + + +b.profile + + +b.profile-photo + +e.upic(style="background-image: url('" + profileUser.getPhotoUrl(146, 146) + "')") + + + +e.content + + if tabs.length == 1 + +e.single-tab-header + else + +e.tabs + each tab, name in tabs + +e(class=['tab', tab.active && '_current']) + +e.tab-content + +e('a').tab-link(href=tab.url)= profileTabNames[name] + + +e.fields + + block profileContent diff --git a/handlers/quiz/client/index.js b/handlers/quiz/client/index.js new file mode 100755 index 000000000..f9bf401aa --- /dev/null +++ b/handlers/quiz/client/index.js @@ -0,0 +1,187 @@ +require('./styles'); + +var Spinner = require('client/spinner'); +var xhr = require('client/xhr'); + +var prism = require('client/prism'); +var notification = require('client/notification'); + +function init() { + var quizQuestionForm = document.querySelector('[data-quiz-question-form]'); + + if (quizQuestionForm) { + initQuizForm(quizQuestionForm); + } + + var quizResultSaveForm = document.querySelector('[data-quiz-result-save-form]'); + + if (quizResultSaveForm) { + initQuizResultSaveForm(quizResultSaveForm); + } + + prism.init(); +} + +function initQuizResultSaveForm(form) { + form.onsubmit = function(e) { + e.preventDefault(); + + if (window.currentUser) { + saveResult(); + return; + } + + authAndSaveResult(); + }; + + function authAndSaveResult() { + + // let's authorize first + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + class: 'submit-button__spinner', + elemClass: 'submit-button_progress' + }); + spinner.start(); + + require.ensure('auth/client/authModal', function() { + spinner.stop(); + var AuthModal = require('auth/client/authModal'); + new AuthModal({ + callback: saveResult + }); + }, 'authClient'); + + } + + function saveResult() { + + var request = xhr({ + method: 'POST', + url: form.action + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + function onEnd() { + spinner.stop(); + submitButton.disabled = false; + } + + request.addEventListener('loadend', onEnd); + + request.addEventListener('success', (event) => { + new notification.Success(`Результат сохранён в профиле! Перейти в профиль.`, 'slow'); + }); + + } +} + + +function initQuizForm(form) { + + function getValue() { + var type = form.elements.type.value; + + var answerElems = form.elements.answer; + + var value = []; + + for (var i = 0; i < answerElems.length; i++) { + if (answerElems[i].checked) { + value.push(+answerElems[i].value); + } + } + + if (type == 'single') { + value = value[0]; + } + + return value; + } + + form.onchange = function() { + var value = getValue(); + + switch(form.elements.type.value) { + case 'single': + form.querySelector('[type="submit"]').disabled = (value === undefined); + break; + case 'multi': + form.querySelector('[type="submit"]').disabled = value.length ? false : true; + break; + default: + throw new Error("unknown type"); + } + }; + + form.onsubmit = function(event) { + event.preventDefault(); + var value = getValue(); + + var request = xhr({ + method: 'POST', + url: form.action, + body: { + answer: value + } + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + // stop spinned on success/fail, but not when window is going to be reloaded + function onEnd() { + spinner.stop(); + submitButton.disabled = false; + } + + request.addEventListener('fail', onEnd); + request.addEventListener('success', (event) => { + + if (event.result.reload) { + window.location.reload(); + } else if (event.result.html) { + onEnd(); + document.querySelector('.quiz-timeline .quiz-timeline__number_current') + .classList.remove('quiz-timeline__number_current'); + + document.querySelectorAll('.quiz-timeline span')[event.result.questionNumber] + .classList.add('quiz-timeline__number_current'); + + + document.querySelector('.quiz-tablet-timeline__num') + .innerHTML = ' ' + (event.result.questionNumber + 1) + ' '; + + form.innerHTML = event.result.html; + prism.highlight(form); + } else { + onEnd(); + console.error(`Bad response: ${event.result}`); + } + }); + + + }; + +} + +init(); diff --git a/handlers/quiz/client/styles/index.styl b/handlers/quiz/client/styles/index.styl new file mode 100644 index 000000000..7356e423a --- /dev/null +++ b/handlers/quiz/client/styles/index.styl @@ -0,0 +1,16 @@ + +@require "~styles/blocks/variables/variables" + + +@require "quiz-selector" +@require "quiz-start" +@require "quiz-explanations" +@require "quiz" +@require "quiz-question" +@require "quiz-timeline" +@require "quiz-result" +@require "quiz-results-indicator" +@require "quiz-percents" +@require "quiz-weak-list" +@require "quiz-results-table" +@require "quiz-tablet-timeline" diff --git a/handlers/quiz/client/styles/quiz-explanations/index.styl b/handlers/quiz/client/styles/quiz-explanations/index.styl new file mode 100644 index 000000000..d81633c0b --- /dev/null +++ b/handlers/quiz/client/styles/quiz-explanations/index.styl @@ -0,0 +1,10 @@ +.quiz-explanations + text-align left + + + @media phone + & + padding 0 10px + + &__title + text-align center diff --git a/handlers/quiz/client/styles/quiz-percents/index.styl b/handlers/quiz/client/styles/quiz-percents/index.styl new file mode 100755 index 000000000..20145d6ed --- /dev/null +++ b/handlers/quiz/client/styles/quiz-percents/index.styl @@ -0,0 +1,16 @@ +.quiz-percents + & dt + font-weight normal + + &__result &__percents + font-size 48px + font-weight bold + line-height initial + + &__position + margin-top 10px + + &__position &__percents + font-size 32px + font-weight bold + line-height initial diff --git a/handlers/quiz/client/styles/quiz-question/index.styl b/handlers/quiz/client/styles/quiz-question/index.styl new file mode 100644 index 000000000..adb8d3b86 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-question/index.styl @@ -0,0 +1,138 @@ +.quiz-question + min-width 600px + margin-top 25px + + & ul, + & li, + & ul li, + & h3 + padding 0 + margin 0 + line-height auto + + & p + padding 0 + margin-top 0 + margin-bottom 1em + line-height auto + + & ul li:before + display none + + &__body + padding 30px + + text-align left + + border 3px solid #eee + border-radius 10px + + &__title + font-size 16px + + & &__variants + margin-top 25px + + & &__variant + position relative + + line-height 16px + + margin-top 4px + padding-left 25px + + &__label + font-size 14px + + display inline-block + + position relative + + cursor pointer + + &__input + position absolute + top 50% + left -25px + + margin -7px 0 0 0 + width 16px + height 16px + + &__input:checked + &__input-text + font-weight bold + + &__description + margin-bottom 20px + margin-top 5px + + &__description .code-example, + &__description .codebox + margin 0 + + & &__note + font-size 12px + + margin 20px + margin-bottom 0 + + color #666 + + &__submit + margin-top 25px + + + &_correct_true &__body + border-color #bbd4a5 + + &_correct_false &__body + border-color #eaaaad + + + //- coloring logic is here: https://github.com/iliakan/javascript-nodejs/issues/293 + &__variant_correct_true &__input-text + font-weight bold + color #060 + + &__variant_correct_false&__variant_selected &__input-text + font-weight bold + color #d90000 + + @media tablet + & + min-width initial + + @media phone + & + min-width initial + margin-bottom 30px + + &__body + padding 20px + + border-width 0 + + border-radius initial + + &__body .code-example + margin 0 -20px + + &_correct_true, + &_correct_false + margin 15px -10px 0 + + &_correct_true:last-child, + &_correct_false:last-child + margin-bottom 30px + + + &_correct_true &__body, + &_correct_false &__body + border-width 3px + + & &__variant + margin-top 10px + + &__title + font-size 14px + font-weight normal diff --git a/handlers/quiz/client/styles/quiz-result/index.styl b/handlers/quiz/client/styles/quiz-result/index.styl new file mode 100644 index 000000000..81bdffda8 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-result/index.styl @@ -0,0 +1,128 @@ +.quiz-result + display inline-block + position relative + height 290px + + margin-top 10px + + .main & ul, + .main & li, + .main & dl, + .main & dt, + .main & dd, + .main & h1, + .main & p + margin 0 + padding 0 + + .main & li:before + display none + + &__save-form, + &__retry-form + display inline-block + + &__retry-form + margin-right 30px + + &__retry-button, + &__save-button + min-width 215px + + &__try + text-align center + + &__try-num + color #c60800 + + &__layout + display table + position relative + + padding 20px + margin-top 10px + + border-radius 10px 10px 0 0 + background #222 + + &__left, + &__center, + &__right + display table-cell + box-sizing: border-box + + width 33% + padding 10px 15px + + border-right: 2px solid #3c3c3c + + vertical-align top + text-align center + + &__right + width 33% + + border 0 + + &__save-result + background #4C4B4B + border-radius 0 0 10px 10px + + &__bottom + padding 20px 0 + + & .quiz-percents, + & .quiz-weak-list__title + color #7a7a7a + + & .quiz-percents__percents, + & .quiz-weak-list__list, + & .quiz-results-indicator + color: #fff + + @media tablet + &, + &__layout + display block + height auto + padding 0 + + &__left, + &__center, + &__right + display block + box-sizing: border-box + + width auto + padding 30px 25px + + border: 0 + + &__center, + &__right + border-top 2px solid rgba(213,214,214,0.15) + + + @media phone + + &__layout, + &__save-result + border-radius initial + + margin-left -10px + margin-right -10px + + &__bottom + padding 30px + + & .quiz-weak-list__list + display block + + text-align left + + &__retry-form, + &__save-form + display block + + &__retry-form + margin 0 0 20px 0 diff --git a/handlers/quiz/client/styles/quiz-results-indicator/chart.svg b/handlers/quiz/client/styles/quiz-results-indicator/chart.svg new file mode 100755 index 000000000..cf37b7656 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-indicator/chart.svg @@ -0,0 +1,44 @@ + + + + Chart + Created with Sketch. + + + + + + 0 + + + 100 + + + + + + + + + 20 + + + 80 + + + 40 + + + 60 + + + + + + + + + + + + \ No newline at end of file diff --git a/handlers/quiz/client/styles/quiz-results-indicator/index.styl b/handlers/quiz/client/styles/quiz-results-indicator/index.styl new file mode 100755 index 000000000..2be28d318 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-indicator/index.styl @@ -0,0 +1,55 @@ +.quiz-results-indicator + + &__indicator + position relative + + display inline-block + + width 203px + height 102px + + background no-repeat url('quiz-results-indicator/chart.svg'); + + &__indicator:after + position absolute + bottom 2px + left 51px + + + width 45px + height 4px + + transform-origin 100% 50% + + background #d8d8d8 + + content: "" + + &__text + display inline-block + + //- commented out by @iliakan + //- breaks text into 3 lines => breaks markup on extra-wide screens (where font-size is 16px) + //-width 197px + + margin-top 10px + + &__level + font-weight bold + + &__level_junior + color #FFC800 + + &__level_medium + color #ff7b00 + + &__level_senior + color #c20800 + + + @media tablet + &__text, + &__indicator + display block + margin-left auto + margin-right auto diff --git a/handlers/quiz/client/styles/quiz-results-table/index.styl b/handlers/quiz/client/styles/quiz-results-table/index.styl new file mode 100755 index 000000000..961974192 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-table/index.styl @@ -0,0 +1,83 @@ +.quiz-results-table + + .main &__empty-message + margin 12px + padding 10px + + .main &__results ul, + .main &__results li, + .main &__results dl, + .main &__results dt, + .main &__results dd, + .main &__results h1, + .main &__results p, + .main &__results th, + .main &__results td + .main &__results + font-size inherit + font-weight normal + + margin 0 + padding 0 + + .main &__results &__result:nth-child(even) + background none + + &__results + width 100% + white-space nowrap + + .main &__results tr:first-child th + vertical-align top + border-bottom-width: 1px + + .main &__results &__result:last-child, + .main &__results &__result:last-child th + border none + + .main &__results th, + .main &__results td + padding 25px + + vertical-align top + + .main &__results &__time, + .main &__results &__try, + .main &__results &__title, + .main &__results &__title-head + font-size 12px + line-height 12px + + display block + + margin-bottom 7px + + color #727272 + + .main &__results &__name + font: 17px/17px secondary_font + + white-space normal + + .main &__results &__try + margin-top 7px + + &__precents + text-align center + + .main &__results &__precents-value + font-size 28px + line-height 28px + + color #4C906B + + .main &__results &__level-info, + .main &__results &__time-spent-info + font-size 14px + line-height 14px + + @media (min-width: largescreen) + .main &__results &__level-info, + .main &__results &__time-spent-info + font-size 16px + line-height 16px diff --git a/handlers/quiz/client/styles/quiz-selector/index.styl b/handlers/quiz/client/styles/quiz-selector/index.styl new file mode 100644 index 000000000..dcab2c505 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-selector/index.styl @@ -0,0 +1,91 @@ +.quiz-selector + background #f7f6ea; + border-radius 3px + + & ul, + & li, + & ul li, + & h3, + & p + padding 0 + margin 0 + line-height auto + + & ul li:before + display none + + & &__item + font-size 13px + + padding 25px 30px + + white-space nowrap + + border-bottom 1px solid #eae5d9 + + & &__item:last-child + border none + + &__text, + &__start + display inline-block + + box-sizing border-box + + vertical-align middle + + &__text + width 60% + padding-right 20px + + white-space normal + + &__start + width 40% + min-width 230px + text-align right + + &__start-i + position relative + + display inline-block + + & &__title + font-family secondary_font + font-size 18px + + margin-bottom 10px + + color: #b20000 + + &__result + position absolute + + width 100% + + text-align center + color #508F6C + + @media tablet + &__text, + &__start + width auto + display block + + &__start + text-align left + margin-top 15px + + @media phone + & + margin 0 -10px + border-radius none + + text-align center + + & &__item + border-bottom 2px solid #fff + + &__text, + &__start + text-align center diff --git a/handlers/quiz/client/styles/quiz-start/index.styl b/handlers/quiz/client/styles/quiz-start/index.styl new file mode 100755 index 000000000..193044242 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-start/index.styl @@ -0,0 +1,29 @@ +.quiz-start + + &__pane + padding 40px + + border 3px solid #eee + border-radius 10px + + // tag to override .main p + p&__description + margin-top 0 + margin-bottom 32px + color light_gray_color + + &__pane p + margin 0 + margin-top 20px + + & .button_action + font-size 18px + + line-height 47px + + padding 1px 40px + + & .button_action:active, + & .button_action:focus + padding 0px 39px + diff --git a/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl b/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl new file mode 100644 index 000000000..6d704e6cd --- /dev/null +++ b/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl @@ -0,0 +1,9 @@ +.quiz-tablet-timeline + & &__title + margin 0 + + &__num + color #F8AB47 + + &__total + color #A9A9A9 diff --git a/handlers/quiz/client/styles/quiz-timeline/index.styl b/handlers/quiz/client/styles/quiz-timeline/index.styl new file mode 100644 index 000000000..6ff9a0147 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-timeline/index.styl @@ -0,0 +1,36 @@ +.quiz-timeline + font-size 12px + line-height 20px + + min-width 400px + height 20px + padding 5px + + white-space nowrap + + border 2px solid #EEE + border-radius 20px + + &__number + display inline-block + + width 20px + height 20px + margin 0 8px + + line-height inherit + + text-align center + + color #F8AB47 + + &__number_current + border-radius 10px + color #fff + background #f8ab47 + + &__number_current ~ &__number + color #333 + + @media tablet + display none diff --git a/handlers/quiz/client/styles/quiz-weak-list/index.styl b/handlers/quiz/client/styles/quiz-weak-list/index.styl new file mode 100755 index 000000000..a02ed81b8 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-weak-list/index.styl @@ -0,0 +1,15 @@ +.quiz-weak-list + + margin-top 10px + + &__title + font-size 14px + font-weight normal + + &__list + display inline-block + + margin-top 15px !important + + text-align left + list-style disc outside !important diff --git a/handlers/quiz/client/styles/quiz/index.styl b/handlers/quiz/client/styles/quiz/index.styl new file mode 100644 index 000000000..1d4153799 --- /dev/null +++ b/handlers/quiz/client/styles/quiz/index.styl @@ -0,0 +1,9 @@ +.quiz + display inline-block + margin-top 10px + + @media phone + & + display block + margin-left -10px + margin-right -10px diff --git a/handlers/quiz/controllers/answer.js b/handlers/quiz/controllers/answer.js new file mode 100755 index 000000000..a115b6a3e --- /dev/null +++ b/handlers/quiz/controllers/answer.js @@ -0,0 +1,114 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const QuizStat = require('../models/quizStat'); +const QuizQuestion = require('../models/quizQuestion'); +const _ = require('lodash'); + +exports.post = function*() { + var self = this; + if (!this.session.quizzes) { + this.log.debug("No session quizzes"); + this.throw(404); + } + + var sessionQuiz = this.session.quizzes[this.params.slug]; + + if (!sessionQuiz) { + this.log.debug("No session quiz with such slug"); + this.throw(404); + } + + var quiz = yield Quiz.findById(sessionQuiz.id).exec(); + + if (!quiz) { + this.log.debug("No quiz with id " + sessionQuiz.id); + this.throw(404); + } + + // save selected answers in the question and push to questionsTaken + var question = quiz.questions.id(sessionQuiz.questionCurrentId); + + sessionQuiz.questionsTakenIds.push(question._id); + + if (question.type == 'single') { + sessionQuiz.answers.push(+this.request.body.answer); + } else if (question.type == 'multi') { + if (!Array.isArray(this.request.body.answer)) { + this.throw(400); + } + sessionQuiz.answers.push(this.request.body.answer.map(Number)); + } else { + throw new Error("Unknown question type: " + question.type); + } + + if (sessionQuiz.questionsTakenIds.length == quiz.questionsToAskCount) { + + var totalScore = 0; + sessionQuiz.questionsTakenIds.forEach(function(id, i) { + totalScore += quiz.questions.id(id).checkAnswer(sessionQuiz.answers[i]); + }); + + // percentage of solved + totalScore = Math.round(totalScore / quiz.questionsToAskCount * 100); + + var quizResult = new QuizResult({ + user: this.user && this.user._id, + quizSlug: quiz.slug, + quizTitle: quiz.title, + score: totalScore, + level: totalScore <= 40 ? 'junior' : totalScore <= 80 ? 'medium' : 'senior', + time: Date.now() - sessionQuiz.started // in ms! + }); + + sessionQuiz.result = quizResult.toObject(); + + + yield QuizStat.update({ + slug: quiz.slug, + score: totalScore + }, { + $inc: { + count: 1 + } + }, { + upsert: true + }).exec(); + + + this.body = { + reload: true + }; + + } else { + + // select one more question among non-taken + var questionsAvailable = quiz.questions.filter(function(question) { + // if a quiz.question is taken, exclude it from the list + var found = false; + sessionQuiz.questionsTakenIds.forEach(function(id) { + + if (String(id) == String(question._id)) { + self.log.debug("Excluding " + id); + found = true; + } + }); + + return !found; + }); + + self.log.debug(questionsAvailable); + sessionQuiz.questionCurrentId = _.sample(questionsAvailable, 1)[0]._id; + + this.locals.question = quiz.questions.id(sessionQuiz.questionCurrentId); + + self.log.debug(this.locals.question, sessionQuiz.questionCurrentId); + + this.body = { + html: this.render('partials/_question'), + questionNumber: sessionQuiz.questionsTakenIds.length + }; + + + } + +}; diff --git a/handlers/quiz/controllers/index.js b/handlers/quiz/controllers/index.js new file mode 100755 index 000000000..b815d927e --- /dev/null +++ b/handlers/quiz/controllers/index.js @@ -0,0 +1,40 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); + +exports.get = function*() { + + this.nocache(); + + var quizzes = yield Quiz.find({ + archived: false + }).sort({weight: 1}).exec(); + + this.locals.quizzes = []; + + // FIXME: all quiz/* must have this + this.locals.siteToolbarCurrentSection = "quiz"; + this.locals.title = 'Тестирование знаний'; + + var quizResults = []; + if (this.user) { + quizResults = yield* QuizResult.getLastAttemptsForUser(this.user._id); + } + + for (var i = 0; i < quizzes.length; i++) { + var quiz = quizzes[i]; + var q = { + title: quiz.title, + description: quiz.description, + slug: quiz.slug + }; + quizResults.forEach(function(quizResult) { + if (quizResult.quizSlug == quiz.slug) { + q.quizResultScore = quizResult.score; + } + }); + + this.locals.quizzes.push(q); + } + + this.body = this.render('index'); +}; diff --git a/handlers/quiz/controllers/quiz.js b/handlers/quiz/controllers/quiz.js new file mode 100755 index 000000000..073434b67 --- /dev/null +++ b/handlers/quiz/controllers/quiz.js @@ -0,0 +1,78 @@ +const config = require('config'); +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const QuizStat = require('../models/quizStat'); +const formatTitle = require('simpledownParser').formatTitle; +const renderSimpledown = require('renderSimpledown'); + +exports.get = function*() { + + this.nocache(); + + // session may have many quiz at the same time + // take the current one + // it may be archived! + var sessionQuiz = this.session.quizzes && this.session.quizzes[this.params.slug]; + + if (!sessionQuiz) { + // let the user start a new quiz here + // not archived! + var quiz = yield Quiz.findOne({ + slug: this.params.slug, + archived: false + }).exec(); + + if (!quiz) { + this.log.debug("No quiz: " + this.params.slug); + this.throw(404); + } + + this.locals.quiz = quiz; + this.locals.title = formatTitle(quiz.title); + this.body = this.render('quiz-start'); + return; + } + + // we have a session quiz, but it may be archived! (user started it before the update) + // so let's look by id + var quiz = yield Quiz.findById(sessionQuiz.id).exec(); + + if (!quiz) { + // invalid id in sessionQuiz, probably db was cleared + this.log.debug("No quiz with id: " + sessionQuiz.id); + // invalid quiz in session, delete and go /quiz + delete this.session.quizzes[this.params.slug]; + this.redirect('/quiz'); + return; + } + + this.locals.quiz = quiz; + this.locals.title = formatTitle(quiz.title); + + this.log.debug("sessionQuiz", sessionQuiz); + + if (sessionQuiz.result) { + + var belowPercentage = yield QuizStat.getBelowScorePercentage(quiz.slug, sessionQuiz.result.score); + + this.locals.quizResult = new QuizResult(sessionQuiz.result); + this.locals.quizBelowPercentage = belowPercentage; + + this.locals.quizQuestions = sessionQuiz.questionsTakenIds.map(function(id, num) { + var question = quiz.questions.id(id).toObject(); + question.userAnswer = sessionQuiz.answers[num]; + question.correct = quiz.questions.id(id).checkAnswer(question.userAnswer); + return question; + }); + + this.body = this.render('results'); + } else { + // show current question + this.locals.question = quiz.questions.id(sessionQuiz.questionCurrentId); + + this.locals.progressNow = sessionQuiz.questionsTakenIds.length + 1; + this.locals.progressTotal = quiz.questionsToAskCount; + + this.body = this.render('quiz'); + } +}; diff --git a/handlers/quiz/controllers/resultsByUser.js b/handlers/quiz/controllers/resultsByUser.js new file mode 100644 index 000000000..a1459b0e2 --- /dev/null +++ b/handlers/quiz/controllers/resultsByUser.js @@ -0,0 +1,30 @@ +const config = require('config'); +const mongoose = require('mongoose'); +const QuizResult = require('../models/quizResult'); +const User = require('users').User; + +exports.get = function*() { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + var results = yield* QuizResult.getLastAttemptsForUser(user._id); + + results = results.map(function(result) { + return { + created: result.created, + quizTitle: result.quizTitle, + quizUrl: result.quiz && result.quiz.getUrl(), + score: result.score, + level: result.level, + levelTitle: result.levelTitle, + time: result.time + }; + }); + + this.body = results; + +}; diff --git a/handlers/quiz/controllers/save.js b/handlers/quiz/controllers/save.js new file mode 100755 index 000000000..848c26228 --- /dev/null +++ b/handlers/quiz/controllers/save.js @@ -0,0 +1,49 @@ +const config = require('config'); +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); + +exports.post = function*() { + + if (!this.session.quizzes) { + this.redirect('/quiz'); + return; + } + + // session may have many quiz at the same time + // take the current one + var sessionQuiz = this.session.quizzes[this.params.slug]; + + if (!sessionQuiz || !sessionQuiz.result) { + this.redirect('/quiz'); + return; + } + + // prevent double saving of the same result + if (!sessionQuiz.resultSaved) { + + var result = sessionQuiz.result; + + // only now we bind quizResult to user (!) + // because the user may be GUEST when finishing the test + // and authorize after it + + result.user = this.user._id; + + result = new QuizResult(result); + + yield result.persist(); + + if (!~this.user.profileTabsEnabled.indexOf('quiz')) { + this.user.profileTabsEnabled.addToSet('quiz'); + yield this.user.persist(); + } + + sessionQuiz.resultSaved = true; + } + + // done with that quiz + // delete this.session.quizzes[this.params.slug]; + + this.body = "DONE"; + +}; diff --git a/handlers/quiz/controllers/start.js b/handlers/quiz/controllers/start.js new file mode 100755 index 000000000..0f2d5f9c3 --- /dev/null +++ b/handlers/quiz/controllers/start.js @@ -0,0 +1,35 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const _ = require('lodash'); + +exports.post = function*() { + + var quiz = yield Quiz.findOne({ + slug: this.params.slug, + archived: false + }).exec(); + + if (!quiz) { + this.throw(404); + } + + this.log.debug("Starting quiz ", quiz.toObject()); + + if (!this.session.quizzes) { + this.session.quizzes = {}; + } + + var sessionQuiz = { + started: Date.now(), + id: quiz._id, + questionsTakenIds: [], + answers: [] + }; + + // previous attempt will be automatically removed from the session + this.session.quizzes[quiz.slug] = sessionQuiz; + + sessionQuiz.questionCurrentId = _.sample(quiz.questions, 1)[0]._id; + + this.redirect(quiz.getUrl()); +}; diff --git a/handlers/quiz/index.js b/handlers/quiz/index.js new file mode 100755 index 000000000..2c6517bff --- /dev/null +++ b/handlers/quiz/index.js @@ -0,0 +1,9 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/quiz', __dirname) ); +}; + +exports.QuizResult = require('./models/quizResult'); + diff --git a/handlers/quiz/models/quiz.js b/handlers/quiz/models/quiz.js new file mode 100755 index 000000000..3561f8b29 --- /dev/null +++ b/handlers/quiz/models/quiz.js @@ -0,0 +1,55 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const assert = require('assert'); +const _ = require('lodash'); +const QuizQuestion = require('./quizQuestion'); + + +const quizSchema = new Schema({ + // when a new quiz is imported, the current one gets archived: false, + // but still remains in db for some time, to those people who are passing it in the moment of update + archived: { + type: Boolean, + required: true + }, + title: { + type: String, + required: true + }, + description: { + type: String, + required: true + }, + weight: { + type: Number, + required: true + }, + slug: { + type: String, + required: true, + index: true + }, + questionsToAskCount: { + type: Number, + required: true + }, + created: { + type: Date, + required: true, + default: Date.now + }, + questions: [QuizQuestion.schema] +}); + +quizSchema.statics.getUrlBySlug = function(slug) { + return '/quiz/' + slug; +}; + +quizSchema.methods.getUrl = function() { + return quizSchema.statics.getUrlBySlug(this.get('slug')); +}; + + +module.exports = mongoose.model('Quiz', quizSchema); \ No newline at end of file diff --git a/handlers/quiz/models/quizQuestion.js b/handlers/quiz/models/quizQuestion.js new file mode 100755 index 000000000..050d90ce5 --- /dev/null +++ b/handlers/quiz/models/quizQuestion.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const assert = require('assert'); +const _ = require('lodash'); + + +const schema = new Schema({ + content: { + type: String, + required: true + }, + // question types, determines how to show/check answers + // single - a selection 1 from many, correctAnswer is the number + // multi - a selection of many from many, correctAnswer is a set + // for future possible: string - string match, eval - JS result eval match + type: { + type: String, + required: true, + default: 'single', + enum: ['single', 'multi'] + }, + answers: [{}], // array of generic answer variants, e.g. [{title: String, desc: String}] + correctAnswer: {}, // generic correct answer, e.g Number or [Number] for multi + correctAnswerComment: String // why is the answer correct, optional comment +}); + +schema.path('correctAnswer').validate(function (value) { + if (this.type == 'single') { + // 1 number + return typeof value == 'number'; + } + + if (this.type == 'multi') { + // array of numbers + return Array.isArray(value) && !value.filter(function(v) { + return typeof v != 'number'; + }).length; + } + +}, 'Invalid color'); + + +schema.methods.checkAnswer = function(answer) { + + switch (this.type) { + case 'single': + return this.correctAnswer == answer ? 1 : 0; + case 'multi': + assert(Array.isArray(answer)); + assert(Array.isArray(this.correctAnswer)); + + return _.isEqual( this.correctAnswer.sort(), answer.sort()) ? 1 : 0; + } + +}; + + +module.exports = mongoose.model('QuizQuestion', schema); diff --git a/handlers/quiz/models/quizResult.js b/handlers/quiz/models/quizResult.js new file mode 100755 index 000000000..b265416a2 --- /dev/null +++ b/handlers/quiz/models/quizResult.js @@ -0,0 +1,91 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const Quiz = require('./quiz'); + +// An attempt of quiz solving +const schema = new Schema({ + user: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true + }, + // we keep full information about the quiz, not linking by id, + // because the quiz may be replaced + // and even deleted + // but the information must stay + quizSlug: { + type: String, + required: true + }, + + quizTitle: { + type: String, + required: true + }, + + level: { + type: String, + enum: ['junior','medium', 'senior'], + required: true + }, + + score: { + type: Number, + required: true + }, + + time: { + type: Number, + required: true + }, + + // better than XX% participants is not stored here, + // because it is not persistent + + created: { + type: Date, + required: true, + default: Date.now + } +}); + +schema.virtual('levelTitle').get(function() { + return {junior: 'новичок', medium: 'средний', senior: 'профи'}[this.level]; +}); + +schema.statics.getLastAttemptsForUser = function*(user) { + + var allResults = yield QuizResult.find({user: user}).sort({created: -1}).exec(); + + var lastAttemptResults = {}; + + // get only first (by creation) result for each quizSlug + for (var i = 0; i < allResults.length; i++) { + var result = allResults[i]; + if (lastAttemptResults[result.quizSlug]) continue; + lastAttemptResults[result.quizSlug] = result; + } + + var quizzes = yield Quiz.find({ + archived: false, + slug: { + $in: Object.keys(lastAttemptResults) + } + }).exec(); + + var quizBySlug = {}; + for (var i = 0; i < quizzes.length; i++) { + var quiz = quizzes[i]; + quizBySlug[quiz.slug] = quiz; + } + + var results = []; + for(var key in lastAttemptResults) { + lastAttemptResults[key].quiz = quizBySlug[lastAttemptResults[key].quizSlug]; + results.push(lastAttemptResults[key]); + } + + return results; +}; + +var QuizResult = module.exports = mongoose.model('QuizResult', schema); \ No newline at end of file diff --git a/handlers/quiz/models/quizStat.js b/handlers/quiz/models/quizStat.js new file mode 100755 index 000000000..3de91d485 --- /dev/null +++ b/handlers/quiz/models/quizStat.js @@ -0,0 +1,67 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + slug: { + type: String, + required: true, + index: true + }, + score: { + type: Number, + required: true + }, + // count of tests with this score + count: { + type: Number, + required: true + } +}); + +schema.index({slug: 1, score: 1}, {unique: true}); + +// TODO: test me +// http://docs.mongodb.org/v2.6/MongoDB-aggregation-guide.pdf +schema.statics.getBelowScorePercentage = function*(slug, score) { + + var belowCount = yield QuizStat.aggregate( + { + $match: { + slug: slug, + score: { + $lt: score + } + } + }, { + $group: { + _id: null, + total: { + $sum: "$count" + } + } + } + ).exec(); + + var totalCount = yield QuizStat.aggregate( + { + $match: { + slug: slug + } + }, { + $group: { + _id: null, + total: { + $sum: "$count" + } + } + } + ).exec(); + + belowCount = belowCount.length ? belowCount[0].total : 0; + totalCount = totalCount.length ? totalCount[0].total : 1; + return Math.round(belowCount / totalCount * 100); + +}; + + +var QuizStat = module.exports = mongoose.model('QuizStat', schema); \ No newline at end of file diff --git a/handlers/quiz/quizImporter.js b/handlers/quiz/quizImporter.js new file mode 100755 index 000000000..823aa8f5b --- /dev/null +++ b/handlers/quiz/quizImporter.js @@ -0,0 +1,76 @@ +var yaml = require('js-yaml'); +var fs = require('fs'); +var Quiz = require('./models/quiz'); +var path = require('path'); +var log = require('log')(); + +function QuizImporter(options) { + this.fileContent = fs.realpathSync(options.yml); +} + + +QuizImporter.prototype.addDot = function(question) { + // numbers have no dot (looks better) + if (/^\d+$/.test(question)) return question; + + // do not wrap code + if (/^`[^`]+`$/.test(question)) return question; + + if (!/[.!?)]$/.test(question)) { + question += '.'; + } + return question; +}; + +QuizImporter.prototype.import = function*() { + + var quizObj = yaml.safeLoad(fs.readFileSync(this.fileContent, 'utf8')); + + + for (var i = 0; i < quizObj.questions.length; i++) { + var question = quizObj.questions[i]; + + for (var j = 0; j < question.answers.length; j++) { + var answer = question.answers[j]; + // all primitive values become titles w/o description + if (typeof answer != 'object') { + answer = question.answers[j] = { + title: answer + }; + } else { + if (!answer.title) { + log.error("No title for answer", question); + } + } + // convert title to string, cause string methods will be called on it + answer.title = this.addDot(String(answer.title).trim()); + } + + } + + + var quiz = new Quiz(quizObj); + + quiz.archived = false; + + yield Quiz.update({ + slug: quiz.slug + }, { + $set: { + archived: true + } + }, { + multi: true + }).exec(); + + try { + yield quiz.persist(); + } catch (e) { + if (e.errors) console.error(e.errors); + throw e; + } + +}; + + +module.exports = QuizImporter; \ No newline at end of file diff --git a/handlers/quiz/router.js b/handlers/quiz/router.js new file mode 100755 index 000000000..ea1d60234 --- /dev/null +++ b/handlers/quiz/router.js @@ -0,0 +1,20 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); +var start = require('./controllers/start'); +var save = require('./controllers/save'); +var answer = require('./controllers/answer'); +var quiz = require('./controllers/quiz'); +var resultsByUser = require('./controllers/resultsByUser'); + +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var router = module.exports = new Router(); +router.param('userById', require('users').routeUserById); + +router.get("/", index.get); +router.get("/results/user/:userById", mustBeAuthenticated, resultsByUser.get); +router.post("/start/:slug", start.post); +router.post("/save/:slug", mustBeAuthenticated, save.post); +router.post("/answer/:slug", answer.post); +router.get("/:slug", quiz.get); + diff --git a/handlers/quiz/tasks/quizImport.js b/handlers/quiz/tasks/quizImport.js new file mode 100755 index 000000000..813c3900d --- /dev/null +++ b/handlers/quiz/tasks/quizImport.js @@ -0,0 +1,52 @@ +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +var QuizImporter = require('../quizImporter'); +var Quiz = require('../models/quiz'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to quiz root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + return co(function* () { + + var files = glob.sync(path.join(root, '*.yml')); + + if (args.reset) { + yield Quiz.destroy({}); + } + + for (var i = 0; i < files.length; i++) { + var yml = files[i]; + if (path.basename(yml)[0] == '_') { + gutil.log("Skip unfinished " + yml); + continue; + } + + gutil.log("Importing " + yml); + + var importer = new QuizImporter({ + yml: yml + }); + + + yield* importer.import(); + } + + log.info("DONE"); + + }); + }; +}; + + diff --git a/handlers/quiz/templates/blocks/question.jade b/handlers/quiz/templates/blocks/question.jade new file mode 100755 index 000000000..d60529544 --- /dev/null +++ b/handlers/quiz/templates/blocks/question.jade @@ -0,0 +1,46 @@ ++b(class=["quiz-question", quizResult && (question.correct ? "_correct_true" : "_correct_false")]) + input(type="hidden" name="type" value=question.type) + +e.body + != renderSimpledown(question.content) + + if question.type == 'single' + +e("ul").variants + each answer, num in question.answers + - var variantCorrect = (num == question.correctAnswer); + - var variantSelected = question.userAnswer !== undefined && (question.userAnswer == num); + +e("li")(class=[ + "variant", + variantSelected && "_selected" || undefined, + quizResult && variantCorrect && "_correct_true" || undefined, + quizResult && variantSelected && !variantCorrect && "_correct_false" || undefined + ]) + +e("label").label + +e("input").input(type="radio" value=num name=(!quizResult && "answer") disabled=!!quizResult checked=(variantSelected ? "checked" : undefined)) + +e("span").input-text!= renderSimpledown(answer.title, {applyContextTypography: false}) + if answer.description + +e.description!= renderSimpledown(answer.description, {applyContextTypography: false}) + + + if question.type == 'multi' + +e("ul").variants + each answer, num in question.answers + - var variantCorrect = ~question.correctAnswer.indexOf(num); + - var variantSelected = question.userAnswer !== undefined && ~question.userAnswer.indexOf(num); + +e("li")(class=[ + "variant", + variantSelected && "_selected" || undefined, + quizResult && variantCorrect && "_correct_true" || undefined, + quizResult && variantSelected && !variantCorrect && "_correct_false" || undefined + ]) + +e("label").label + +e("input").input(type="checkbox" value=num name=(!quizResult && "answer") disabled=!!quizResult checked=(variantSelected ? "checked" : undefined)) + +e("span").input-text!= renderSimpledown(answer.title, {applyContextTypography: false}) + if answer.description + +e.description!= renderSimpledown(answer.description, {applyContextTypography: false}) + + + if !quizResult + +e.submit + +b("button").button._action(type="submit" disabled) + +e("span").text Продолжить + diff --git a/handlers/quiz/templates/blocks/quiz-explanations.jade b/handlers/quiz/templates/blocks/quiz-explanations.jade new file mode 100755 index 000000000..5ddabf913 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-explanations.jade @@ -0,0 +1,7 @@ ++b.quiz-explanations + +e("h4").title Пояснения: + +e("ul") + +e('li') Тесты предполагают современные браузеры. + +e('li') Все настройки браузера — по умолчанию. + +e('li') Версия Javascript — самая распространенная на текущий день, т.е ES5. + +e('li') Везде "use strict". \ No newline at end of file diff --git a/handlers/quiz/templates/blocks/quiz-selector.jade b/handlers/quiz/templates/blocks/quiz-selector.jade new file mode 100755 index 000000000..617edbf07 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-selector.jade @@ -0,0 +1,17 @@ ++b.quiz-selector + +e("ul").list + each quiz in quizzes + +e("li").item + +e.text + +e("h3").title!= quiz.title + != quiz.description + +e.start + +e.start-i + form(action="/quiz/start/#{quiz.slug}", method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b("button")(type="submit").button._common + +e("span").text Пройти тестирование + // the past score may be 0, so we check it's existance like this + if (quiz.quizResultScore !== undefined) + +e.result Предыдущий результат: #{quiz.quizResultScore}% + diff --git a/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade b/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade new file mode 100644 index 000000000..b9a408d41 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade @@ -0,0 +1,12 @@ +- var n = 0 + ++b.quiz-tablet-timeline.tablet-only + +e('h2').title Вопрос + +e('strong').num   + + while n < progressTotal + - n++ + if (n == progressNow) + | !{progressNow}  + | из  + +e('strong').total !{progressTotal} diff --git a/handlers/quiz/templates/blocks/quiz-timeline.jade b/handlers/quiz/templates/blocks/quiz-timeline.jade new file mode 100755 index 000000000..f0115c84e --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-timeline.jade @@ -0,0 +1,6 @@ +- var n = 0; + ++b.quiz-timeline + while n < progressTotal + - n++ + +e("span")(class="number" + (n == progressNow ? '_current' : ''))= n diff --git a/handlers/quiz/templates/blocks/quiz.jade b/handlers/quiz/templates/blocks/quiz.jade new file mode 100755 index 000000000..2e6962ddb --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz.jade @@ -0,0 +1,6 @@ ++b.quiz + include quiz-timeline + include quiz-tablet-timeline + + form(action="/quiz/answer/#{quiz.slug}" data-quiz-question-form method="POST") + include question diff --git a/handlers/quiz/templates/blocks/result.jade b/handlers/quiz/templates/blocks/result.jade new file mode 100755 index 000000000..33ec4ef9b --- /dev/null +++ b/handlers/quiz/templates/blocks/result.jade @@ -0,0 +1,43 @@ +block append variables + + - var rotate = parseInt((quizResult.score * 1.8), 10) + 'deg' + + ++b.quiz-result + + +e.layout + + +e.left + +b.quiz-percents + +e("dl").result + +e("dt").text Ваш результат: + +e("dd") + +e("p").percents #{quizResult.score}% + + style .quiz-results-indicator__indicator:after { -webkit-transform: rotate(!{ rotate }); transform: rotate(!{ rotate }); } + + +e.center + +b.quiz-results-indicator + +e.indicator + +e.text Ваш предположительный уровень —  + span(class='quiz-results-indicator__level quiz-results-indicator__level_' + quizResult.level)= quizResult.levelTitle + + +e.right + +b.quiz-percents + +e("dl").result + +e("dt").text Вы прошли тест лучше, чем + +e("dd") + +e("p").percents !{ quizBelowPercentage }% + +e("p").text респондентов + + +e.save-result + + +e.bottom + +e('form').retry-form(data-quiz-result-retry-form action="/quiz/start/#{quiz.slug}" method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b('button').button_common.__retry-button(type="submit") + +e('span').text Пройти тест заново + +e('form').save-form(data-quiz-result-save-form action="/quiz/save/#{quiz.slug}" method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b('button')(type="submit").button._action.__save-button + +e('span').text Сохранить результат diff --git a/handlers/quiz/templates/index.jade b/handlers/quiz/templates/index.jade new file mode 100755 index 000000000..4a45f602e --- /dev/null +++ b/handlers/quiz/templates/index.jade @@ -0,0 +1,22 @@ +extends /layouts/main + +block append head + !=css("quiz") + +block append variables + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var layout_header_class = "main__header_center" + +block content + //- + +b.notification._message._info + +e.content Раздел работает в тестовом режиме. О любых проблемах и странностях сообщайте, пожалуйста, на github. + + +b.intro На этой странице вы можете протестировать свои знания Javascript, выбрав один из тестов. + + include blocks/quiz-selector + + include blocks/quiz-explanations + + p Если у вас не получилось ответить на многие вопросы – не расстраивайтесь. Его цель – не только проверить знания, но и помочь заполнить пробелы в них. Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи! diff --git a/handlers/quiz/templates/partials/_question.jade b/handlers/quiz/templates/partials/_question.jade new file mode 100755 index 000000000..fc22ef6cb --- /dev/null +++ b/handlers/quiz/templates/partials/_question.jade @@ -0,0 +1,3 @@ +include /bem + +include ../blocks/question diff --git a/handlers/quiz/templates/quiz-start.jade b/handlers/quiz/templates/quiz-start.jade new file mode 100755 index 000000000..2e0b38322 --- /dev/null +++ b/handlers/quiz/templates/quiz-start.jade @@ -0,0 +1,35 @@ +extends /layouts/main + +block append head + !=css("quiz") + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'Учебник', url: '/' }, { title: 'Тесты', url: '/quiz' }] + - var content_class = 'content_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block append head + !=js("quiz", {defer: true}) + +block content + + +b.quiz-start + + +e("p").description= quiz.description + + +e('form')(action="/quiz/start/#{quiz.slug}", method="POST").pane + input(type="hidden", name="_csrf", value=csrf()) + + +b("button")(type="submit").button._action + +e("span").text Начать тестирование + + +e("p").info + | Нажмите на кнопку выше, чтобы начать тестирование. + br + | Сразу после этого начнется отчет времени. + + include blocks/quiz-explanations + diff --git a/handlers/quiz/templates/quiz.jade b/handlers/quiz/templates/quiz.jade new file mode 100755 index 000000000..4df77ce5d --- /dev/null +++ b/handlers/quiz/templates/quiz.jade @@ -0,0 +1,18 @@ +extends /layouts/main + +block append head + !=css("quiz") + +block append head + !=js("quiz", {defer: true}) + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'Учебник', url: '/' }, { title: 'Тесты', url: '/quiz' }] + - var content_class = 'content_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + include blocks/quiz diff --git a/handlers/quiz/templates/results.jade b/handlers/quiz/templates/results.jade new file mode 100755 index 000000000..e2f525a08 --- /dev/null +++ b/handlers/quiz/templates/results.jade @@ -0,0 +1,21 @@ +extends /layouts/main + +block append head + !=css("quiz") + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'Учебник', url: '/' }, { title: 'Тесты', url: '/quiz' }] + - var sitetoolbar = true + - var content_class = 'content_center' + - var layout_main_class = "main_width-limit" + +block append head + !=js("quiz", {defer: true}) + +block content + include blocks/result + + each question in quizQuestions + include blocks/question diff --git a/handlers/render.js b/handlers/render.js new file mode 100755 index 000000000..75362a1e7 --- /dev/null +++ b/handlers/render.js @@ -0,0 +1,261 @@ +'use strict'; + +const moment = require('momentWithLocale'); +const util = require('util'); +const path = require('path'); +const config = require('config'); +const fs = require('fs'); +const log = require('log')(); +const jade = require('lib/serverJade'); +const _ = require('lodash'); +const assert = require('assert'); +const i18n = require('i18next'); +const money = require('money'); +const url = require('url'); +const validate = require('validate'); + +// public.versions.json is regenerated and THEN node is restarted on redeploy +// so it loads a new version. +var publicVersions; + +function getPublicVersion(publicPath) { + if (!publicVersions) { + // don't include at module top, let the generating task to finish + publicVersions = require(path.join(config.projectRoot, 'public.versions.json')); + } + var busterPath = publicPath.slice(1); + return publicVersions[busterPath]; +} + +function addStandardHelpers(locals, ctx) { + // same locals may be rendered many times, let's not add helpers twice + if (locals._hasStandardHelpers) return; + + locals.moment = moment; + + locals._ = _; + + locals.lang = process.env.NODE_LANG; + + locals.url = url.parse(ctx.protocol + '://' + ctx.host + ctx.originalUrl); + locals.context = ctx; + + locals.analyticsEnabled = ctx.query.noa ? false : (ctx.host == 'learn.javascript.ru' && process.env.NODE_ENV == 'production'); + + locals.js = function(name, options) { + options = options || {}; + + let src = locals.pack(name, 'js'); + + let attrs = options.defer ? ' defer' : ''; + + return ` + `; + }; + + + locals.css = function(name) { + let src = locals.pack(name, 'css'); + + return ``; + }; + + // we don't use defer in sessions, so can assign it + // (simpler, need to call yield this.session) + locals.session = ctx.session; + + + locals.env = process.env; + + + // patterns to use in urls + // no need to escape / + // \s => \\s + locals.validate = { + patterns: {} + }; + for(var name in validate.patterns) { + locals.validate.patterns[name] = validate.patterns[name].source.replace(/\\\//g, '/'); + } + + // replace lone surrogates in json, -> <\/script> + locals.escapeJSON = function(s) { + var json = JSON.stringify(s); + return json.replace(/\//g, '\\/') + .replace(/[\u003c\u003e]/g, + function(c) { + return '\\u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4).toUpperCase(); + } + ).replace(/[\u007f-\uffff]/g, + function(c) { + return '\\u'+('0000'+c.charCodeAt(0).toString(16)).slice(-4); + } + ); + }; + + Object.defineProperty(locals, "user", { + get: function() { + return ctx.req.user; + } + }); + + locals.profileTabNames = { + quiz: 'Тесты', + orders: 'Заказы', + courses: 'Курсы', + aboutme: 'Публичный профиль', + account: 'Аккаунт' + }; + + // flash middleware may be attached later in the chain + Object.defineProperty(locals, "flashMessages", { + get: function() { + return ctx.flash && ctx.flash.messages; + } + }); + + var renderSimpledown; + Object.defineProperty(locals, "renderSimpledown", { + get: function() { + if (!renderSimpledown) { + renderSimpledown = require('renderSimpledown'); + } + return renderSimpledown; // attach at 1st use + } + }); + + locals.csrf = function() { + // function, not a property to prevent autogeneration + // jade touches all local properties + return ctx.user ? ctx.csrf : null; + }; + + // this.locals.debug causes jade to dump function + /* jshint -W087 */ + locals.deb = function() { + debugger; + }; + + locals.t = i18n.t; + locals.bem = require('bem-jade')(); + + locals.thumb = function(url, width, height) { + // return 2 times larger image for retina + var modifier = (width < 320 && height < 320) ? 't' : + (width < 640 && height < 640) ? 'm' : + (width < 1280 && height < 1280) ? 'l' : ''; + + return url.slice(0, url.lastIndexOf('.')) + modifier + url.slice(url.lastIndexOf('.')) + }; + + locals.currencyConvertRound = function(amount, from, to) { + return Math.round(money.convert(amount, {from: from, to: to})); + }; + + + locals.pack = function(name, ext) { + var versions = JSON.parse( + fs.readFileSync(path.join(config.manifestRoot, 'pack.versions.json'), {encoding: 'utf-8'}) + ); + var versionName = versions[name]; + // e.g style = [ style.js, style.js.map, style.css, style.css.map ] + + if (!Array.isArray(versionName)) return versionName; + + var extTestReg = new RegExp(`.${ext}\\b`); + + // select right .js\b extension from files + for (var i = 0; i < versionName.length; i++) { + var versionNameItem = versionName[i]; // e.g. style.css.map + if (/\.map/.test(versionNameItem)) continue; // we never need a map + if (extTestReg.test(versionNameItem)) return versionNameItem; + } + + throw new Error(`Not found pack name:${name} ext:${ext}`); + /* + if (process.env.NODE_ENV == 'development') { + // webpack-dev-server url + versionName = process.env.STATIC_HOST + ':' + config.webpack.devServer.port + versionName; + }*/ + + }; + + + + locals._hasStandardHelpers = true; +} + + +// (!) this.render does not assign this.body to the result +// that's because render can be used for different purposes, e.g to send emails +exports.init = function(app) { + app.use(function *(next) { + var ctx = this; + + this.locals = _.assign({}, config.jade); + + /** + * Render template + * Find the file: + * if locals.useAbsoluteTemplatePath => use templatePath + * else if templatePath starts with / => lookup in locals.basedir + * otherwise => lookup in this.templateDir (MW should set it) + * @param templatePath file to find (see the logic above) + * @param locals + * @returns {String} + */ + this.render = function(templatePath, locals) { + + // add helpers at render time, not when middleware is used time + // probably we will have more stuff initialized here + addStandardHelpers(this.locals, this); + + // warning! + // _.assign does NOT copy defineProperty + // so I use this.locals as a root and merge all props in it, instead of cloning this.locals + var loc = Object.create(this.locals); + + _.assign(loc, locals); + + if (!loc.schema) { + loc.schema = {}; + } + + + if (!loc.canonicalPath) { + // strip query + loc.canonicalPath = this.request.originalUrl.replace(/\?.*/, ''); + // /intro/ -> /intro + loc.canonicalPath = loc.canonicalPath.replace(/\/+$/, ''); + } + console.log(loc.canonicalPath); + loc.canonicalUrl = config.server.siteHost + loc.canonicalPath; + + if (!/\.jade$/.test(templatePath)) { + templatePath += '.jade'; + } + + var templatePathResolved; + if (loc.useAbsoluteTemplatePath) { + templatePathResolved = templatePath; + } else { + if (templatePath[0] == '/') { + this.log.debug("Lookup " + templatePath + " in " + loc.basedir); + templatePathResolved = path.join(loc.basedir, templatePath); + } else { + this.log.debug("Lookup " + templatePath + " in " + this.templateDir); + templatePathResolved = path.join(this.templateDir, templatePath); + } + } + + this.log.debug("render file " + templatePathResolved); + return jade.renderFile(templatePathResolved, loc); + }; + + yield* next; + }); + +}; diff --git a/handlers/requestId.js b/handlers/requestId.js new file mode 100755 index 000000000..6804fba5c --- /dev/null +++ b/handlers/requestId.js @@ -0,0 +1,11 @@ +var uuid = require('node-uuid').v4; + +// RequestCaptureStream wants "req_id" to identify the request +// we take it from upper chain (varnish? nginx on top?) OR generate +exports.init = function(app) { + app.use(function*(next) { + /* jshint -W106 */ + this.requestId = this.get('X-Request-Id') || uuid(); + yield next; + }); +}; diff --git a/handlers/requestLog.js b/handlers/requestLog.js new file mode 100755 index 000000000..9b9290261 --- /dev/null +++ b/handlers/requestLog.js @@ -0,0 +1,13 @@ + +exports.init = function(app) { + app.use(function*(next) { + + /* jshint -W106 */ + this.log = app.log.child({ + requestId: this.requestId + }); + + yield* next; + }); + +}; diff --git a/handlers/search/client/index.js b/handlers/search/client/index.js new file mode 100755 index 000000000..a6a4d2b04 --- /dev/null +++ b/handlers/search/client/index.js @@ -0,0 +1,26 @@ + +function init() { + var fixedForm = document.querySelector(".search-form_fixed"); + var fixedFormInput = fixedForm.querySelector(".search-form__query .text-input__control"); + var staticFormInput = document.querySelector(".search-form:not(.search-form_fixed) .search-form__query .text-input__control"); + var fixedInputOffset = parseInt(getComputedStyle(fixedForm, "").paddingTop); + + function updateFixedForm() { + if (staticFormInput.getBoundingClientRect().top <= fixedInputOffset) { + if (fixedForm.classList.contains("search-form_hidden")) { + fixedFormInput.value = staticFormInput.value; + } + fixedForm.classList.remove("search-form_hidden"); + } else { + if (!fixedForm.classList.contains("search-form_hidden")) { + staticFormInput.value = fixedFormInput.value; + } + fixedForm.classList.add("search-form_hidden"); + } + } + + window.addEventListener("scroll", updateFixedForm); + updateFixedForm(); // set initial state +} + +init(); diff --git a/handlers/search/controllers/index.js b/handlers/search/controllers/index.js new file mode 100755 index 000000000..871114ad2 --- /dev/null +++ b/handlers/search/controllers/index.js @@ -0,0 +1,204 @@ +"use strict"; + +var request = require('request'); +var Task = require('tutorial').Task; +var Article = require('tutorial').Article; +var config = require('config'); +var _ = require('lodash'); +var sanitizeHtml = require('sanitize-html'); + +const clsNamespace = require('continuation-local-storage').getNamespace('app'); + +// known types and methods to convert hits to showable results +// FIXME: many queries to MongoDB for parents (breadcrumbs) Cache them? +var searchTypes = { + articles: { + title: 'Статьи учебника', + hit2url: function(hit) { + return Article.getUrlBySlug(hit.fields.slug[0]); + }, + hit2breadcrumb: function*(hit) { + var article = yield Article.findById(hit._id).select('slug title isFolder parent').exec(); + if (!article) return null; + var parents = yield* article.findParents(); + parents.forEach(function(parent) { + parent.url = parent.getUrl(); + }); + return parents; + } + }, + + tasks: { + title: 'Задачи', + hit2url: function(hit) { + return Task.getUrlBySlug(hit.fields.slug[0]); + }, + hit2breadcrumb: function*(hit) { + var task = yield Task.findById(hit._id).select('slug title parent').exec(); + if (!task) return null; + var article = yield Article.findById(task.parent).select('slug title isFolder parent').exec(); + if (!article) return null; + var parents = (yield* article.findParents()).concat(article); + parents.forEach(function(parent) { + parent.url = parent.getUrl(); + }); + return parents; + } + } + +}; + +exports.get = function *get(next) { + + var locals = {}; + locals.sitetoolbar = true; + locals.sidebar = false; + + var searchQuery = locals.searchQuery = this.request.query.query || ''; + var searchType = locals.searchType = this.request.query.type || 'articles'; + + locals.title = searchQuery ? 'Результаты поиска' : 'Поиск'; + + if (!searchTypes[searchType]) { + this.throw(400); + } + + locals.searchTypes = searchTypes; + + locals.results = []; + + // for every type - total results# + locals.resultsCountPerType = {}; + + if (searchQuery) { + var test = Math.random(); + + //console.log("SEARCH CTRL in", test, process.namespaces.app.get('context').requestId); + + var result = yield* search(searchQuery); + + //console.log("SEARCH CTRL out", test, process.namespaces.app.get('context').requestId); + + + var hits = result[searchType].hits.hits; + + + // will show these results + for (var i = 0; i < hits.length; i++) { + var hit = hits[i]; + + // if no highlighted words in title, hit.highlight.title would be empty + var title = hit.highlight.title ? hit.highlight.title.join('… ') : hit.fields.title[0]; + + // header may have "" tags only, tags like "