From d19623b4d161f9761299770354fe8895091233f0 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Thu, 21 Jan 2016 18:35:04 +0100 Subject: [PATCH 1/4] US #2127: Discover section [1] --- .../discover-home-order-by.controller.coffee | 50 +++++ ...cover-home-order-by.controller.spec.coffee | 96 ++++++++ .../discover-home-order-by.directive.coffee | 37 ++++ .../discover-home-order-by.jade | 12 + .../discover-search-bar.controller.coffee | 36 +++ ...discover-search-bar.controller.spec.coffee | 72 ++++++ .../discover-search-bar.directive.coffee | 38 ++++ .../discover-search-bar.jade | 71 ++++++ .../discover-search-bar.scss | 54 +++++ ...cover-search-list-header.controller.coffee | 46 ++++ ...-search-list-header.controller.spec.coffee | 117 ++++++++++ ...scover-search-list-header.directive.coffee | 37 ++++ .../discover-search-list-header.jade | 89 ++++++++ .../discover-search-list-header.scss | 80 +++++++ .../featured-projects.controller.coffee | 30 +++ .../featured-projects.directive.coffee | 33 +++ .../featured-projects/featured-projects.jade | 52 +++++ .../featured-projects/featured-projects.scss | 29 +++ .../highlighted/highlighted.directive.coffee | 32 +++ .../components/highlighted/highlighted.jade | 57 +++++ .../components/highlighted/highlighted.scss | 205 ++++++++++++++++++ .../most-active/most-active.controller.coffee | 49 +++++ .../most-active.controller.spec.coffee | 79 +++++++ .../most-active/most-active.directive.coffee | 34 +++ .../components/most-active/most-active.jade | 18 ++ .../most-liked/most-liked.controller.coffee | 49 +++++ .../most-liked.controller.spec.coffee | 79 +++++++ .../most-liked/most-liked.directive.coffee | 34 +++ .../components/most-liked/most-liked.jade | 17 ++ .../discover-home.controller.coffee | 33 +++ .../discover-home.controller.spec.coffee | 71 ++++++ .../discover/discover-home/discover-home.jade | 12 + .../discover-search.controller.coffee | 114 ++++++++++ .../discover-search.controller.spec.coffee | 199 +++++++++++++++++ .../discover-search.directive.coffee | 32 +++ .../discover-search/discover-search.jade | 77 +++++++ .../discover-search/discover-search.scss | 131 +++++++++++ app/modules/discover/discover.module.coffee | 20 ++ .../services/discover-projects.service.coffee | 93 ++++++++ .../discover-projects.service.spec.coffee | 178 +++++++++++++++ app/modules/home/home-controller.spec.coffee | 78 +++++++ app/modules/home/home.controller.coffee | 32 +++ app/modules/home/home.scss | 2 +- .../home/projects/home-project-list.jade | 64 +++++- .../home/projects/home-project-list.scss | 59 +---- .../projects-resource.service.coffee | 14 ++ app/modules/resources/resources.coffee | 3 +- .../resources/stats-resource.service.coffee | 34 +++ conf.e2e.js | 3 +- e2e/helpers/discover-helper.js | 87 ++++++++ e2e/helpers/project-detail-helper.js | 27 +++ .../admin/project/project-detail.e2e.js | 37 ++++ e2e/suites/discover/discover-home.e2e.js | 63 ++++++ e2e/suites/discover/discover-search.e2e.js | 123 +++++++++++ e2e/utils/common.js | 2 +- run-e2e.js | 3 +- 56 files changed, 3160 insertions(+), 63 deletions(-) create mode 100644 app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.coffee create mode 100644 app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.spec.coffee create mode 100644 app/modules/discover/components/discover-home-order-by/discover-home-order-by.directive.coffee create mode 100644 app/modules/discover/components/discover-home-order-by/discover-home-order-by.jade create mode 100644 app/modules/discover/components/discover-search-bar/discover-search-bar.controller.coffee create mode 100644 app/modules/discover/components/discover-search-bar/discover-search-bar.controller.spec.coffee create mode 100644 app/modules/discover/components/discover-search-bar/discover-search-bar.directive.coffee create mode 100644 app/modules/discover/components/discover-search-bar/discover-search-bar.jade create mode 100644 app/modules/discover/components/discover-search-bar/discover-search-bar.scss create mode 100644 app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.coffee create mode 100644 app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.spec.coffee create mode 100644 app/modules/discover/components/discover-search-list-header/discover-search-list-header.directive.coffee create mode 100644 app/modules/discover/components/discover-search-list-header/discover-search-list-header.jade create mode 100644 app/modules/discover/components/discover-search-list-header/discover-search-list-header.scss create mode 100644 app/modules/discover/components/featured-projects/featured-projects.controller.coffee create mode 100644 app/modules/discover/components/featured-projects/featured-projects.directive.coffee create mode 100644 app/modules/discover/components/featured-projects/featured-projects.jade create mode 100644 app/modules/discover/components/featured-projects/featured-projects.scss create mode 100644 app/modules/discover/components/highlighted/highlighted.directive.coffee create mode 100644 app/modules/discover/components/highlighted/highlighted.jade create mode 100644 app/modules/discover/components/highlighted/highlighted.scss create mode 100644 app/modules/discover/components/most-active/most-active.controller.coffee create mode 100644 app/modules/discover/components/most-active/most-active.controller.spec.coffee create mode 100644 app/modules/discover/components/most-active/most-active.directive.coffee create mode 100644 app/modules/discover/components/most-active/most-active.jade create mode 100644 app/modules/discover/components/most-liked/most-liked.controller.coffee create mode 100644 app/modules/discover/components/most-liked/most-liked.controller.spec.coffee create mode 100644 app/modules/discover/components/most-liked/most-liked.directive.coffee create mode 100644 app/modules/discover/components/most-liked/most-liked.jade create mode 100644 app/modules/discover/discover-home/discover-home.controller.coffee create mode 100644 app/modules/discover/discover-home/discover-home.controller.spec.coffee create mode 100644 app/modules/discover/discover-home/discover-home.jade create mode 100644 app/modules/discover/discover-search/discover-search.controller.coffee create mode 100644 app/modules/discover/discover-search/discover-search.controller.spec.coffee create mode 100644 app/modules/discover/discover-search/discover-search.directive.coffee create mode 100644 app/modules/discover/discover-search/discover-search.jade create mode 100644 app/modules/discover/discover-search/discover-search.scss create mode 100644 app/modules/discover/discover.module.coffee create mode 100644 app/modules/discover/services/discover-projects.service.coffee create mode 100644 app/modules/discover/services/discover-projects.service.spec.coffee create mode 100644 app/modules/home/home-controller.spec.coffee create mode 100644 app/modules/home/home.controller.coffee create mode 100644 app/modules/resources/stats-resource.service.coffee create mode 100644 e2e/helpers/discover-helper.js create mode 100644 e2e/helpers/project-detail-helper.js create mode 100644 e2e/suites/discover/discover-home.e2e.js create mode 100644 e2e/suites/discover/discover-search.e2e.js diff --git a/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.coffee b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.coffee new file mode 100644 index 00000000..ebecd4ab --- /dev/null +++ b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.coffee @@ -0,0 +1,50 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-home-order-by.controller.coffee +### + +class DiscoverHomeOrderByController + @.$inject = [ + '$translate' + ] + + constructor: (@translate) -> + @.is_open = false + + @.texts = { + week: @translate.instant('DISCOVER.FILTERS.WEEK'), + month: @translate.instant('DISCOVER.FILTERS.MONTH'), + year: @translate.instant('DISCOVER.FILTERS.YEAR'), + all: @translate.instant('DISCOVER.FILTERS.ALL_TIME') + } + + currentText: () -> + return @.texts[@.currentOrderBy] + + open: () -> + @.is_open = true + + close: () -> + @.is_open = false + + orderBy: (type) -> + @.currentOrderBy = type + @.is_open = false + + @.onChange({orderBy: @.currentOrderBy}) + +angular.module("taigaDiscover").controller("DiscoverHomeOrderBy", DiscoverHomeOrderByController) diff --git a/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.spec.coffee b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.spec.coffee new file mode 100644 index 00000000..db71dbb9 --- /dev/null +++ b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.controller.spec.coffee @@ -0,0 +1,96 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-home-order-by.controller.spec.coffee +### + +describe "DiscoverHomeOrderBy", -> + $provide = null + $controller = null + mocks = {} + + _mockTranslate = -> + mocks.translate = { + instant: sinon.stub() + } + + $provide.value("$translate", mocks.translate) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockTranslate() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaDiscover" + + _setup() + + it "get current search text", () -> + mocks.translate.instant.withArgs('DISCOVER.FILTERS.WEEK').returns('week') + mocks.translate.instant.withArgs('DISCOVER.FILTERS.MONTH').returns('month') + + ctrl = $controller("DiscoverHomeOrderBy") + + ctrl.currentOrderBy = 'week' + text = ctrl.currentText() + + expect(text).to.be.equal('week') + + ctrl.currentOrderBy = 'month' + text = ctrl.currentText() + + expect(text).to.be.equal('month') + + it "open", () -> + ctrl = $controller("DiscoverHomeOrderBy") + + ctrl.is_open = false + + ctrl.open() + + expect(ctrl.is_open).to.be.true + + it "close", () -> + ctrl = $controller("DiscoverHomeOrderBy") + + ctrl.is_open = true + + ctrl.close() + + expect(ctrl.is_open).to.be.false + + it "order by", () -> + ctrl = $controller("DiscoverHomeOrderBy") + ctrl.onChange = sinon.spy() + + ctrl.orderBy('week') + + + expect(ctrl.currentOrderBy).to.be.equal('week') + expect(ctrl.is_open).to.be.false + expect(ctrl.onChange).to.have.been.calledWith({orderBy: 'week'}) diff --git a/app/modules/discover/components/discover-home-order-by/discover-home-order-by.directive.coffee b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.directive.coffee new file mode 100644 index 00000000..2def5e12 --- /dev/null +++ b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.directive.coffee @@ -0,0 +1,37 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-home-order-by.directive.coffee +### + +DiscoverHomeOrderByDirective = () -> + link = (scope, el, attrs) -> + + return { + controller: "DiscoverHomeOrderBy", + controllerAs: "vm", + bindToController: true, + templateUrl: "discover/components/discover-home-order-by/discover-home-order-by.html", + scope: { + currentOrderBy: "=orderBy", + onChange: "&" + }, + link: link + } + +DiscoverHomeOrderByDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgDiscoverHomeOrderBy", DiscoverHomeOrderByDirective) diff --git a/app/modules/discover/components/discover-home-order-by/discover-home-order-by.jade b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.jade new file mode 100644 index 00000000..fbaaa04c --- /dev/null +++ b/app/modules/discover/components/discover-home-order-by/discover-home-order-by.jade @@ -0,0 +1,12 @@ +.filter-highlighted(ng-mouseleave="vm.close()") + a.current-filter( + href="#" + ng-click="vm.open()" + ) {{vm.currentText()}} + span.icon-arrow-bottom + + ul.filter-list(ng-if="vm.is_open") + li(ng-click="vm.orderBy('week')") {{ 'DISCOVER.FILTERS.WEEK' | translate }} + li(ng-click="vm.orderBy('month')") {{ 'DISCOVER.FILTERS.MONTH' | translate }} + li(ng-click="vm.orderBy('year')") {{ 'DISCOVER.FILTERS.YEAR' | translate }} + li(ng-click="vm.orderBy('all')") {{ 'DISCOVER.FILTERS.ALL_TIME' | translate }} diff --git a/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.coffee b/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.coffee new file mode 100644 index 00000000..a7ca46c1 --- /dev/null +++ b/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.coffee @@ -0,0 +1,36 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search-bar.controller.coffee +### + +class DiscoverSearchBarController + @.$inject = [ + 'tgDiscoverProjectsService' + ] + + constructor: (@discoverProjectsService) -> + taiga.defineImmutableProperty @, 'projects', () => return @discoverProjectsService.projectsCount + + @discoverProjectsService.fetchStats() + + selectFilter: (filter) -> + @.onChange({filter: filter, q: @.q}) + + submitFilter: -> + @.onChange({filter: @.filter, q: @.q}) + +angular.module("taigaDiscover").controller("DiscoverSearchBar", DiscoverSearchBarController) diff --git a/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.spec.coffee b/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.spec.coffee new file mode 100644 index 00000000..36b5dd4e --- /dev/null +++ b/app/modules/discover/components/discover-search-bar/discover-search-bar.controller.spec.coffee @@ -0,0 +1,72 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: doscover-search-bar.controller.spec.coffee +### + +describe "DiscoverSearchBarController", -> + $provide = null + $controller = null + mocks = {} + + _mockDiscoverProjectsService = -> + mocks.discoverProjectsService = { + fetchStats: sinon.spy() + } + + $provide.value('tgDiscoverProjectsService', mocks.discoverProjectsService) + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockDiscoverProjectsService() + + return null + + _setup = -> + _inject() + + beforeEach -> + module "taigaDiscover" + + _mocks() + _setup() + + it "select filter", () -> + ctrl = $controller("DiscoverSearchBar") + ctrl.onChange = sinon.spy() + ctrl.q = 'query' + + ctrl.selectFilter('text') + + expect(mocks.discoverProjectsService.fetchStats).to.have.been.called; + expect(ctrl.onChange).to.have.been.calledWith(sinon.match({filter: 'text', q: 'query'})); + + it "submit filter", () -> + ctrl = $controller("DiscoverSearchBar") + ctrl.filter = 'all' + ctrl.q = 'query' + ctrl.onChange = sinon.spy() + + ctrl.submitFilter() + + expect(mocks.discoverProjectsService.fetchStats).to.have.been.called; + expect(ctrl.onChange).to.have.been.calledWith(sinon.match({filter: 'all', q: 'query'})); diff --git a/app/modules/discover/components/discover-search-bar/discover-search-bar.directive.coffee b/app/modules/discover/components/discover-search-bar/discover-search-bar.directive.coffee new file mode 100644 index 00000000..9dab0fbc --- /dev/null +++ b/app/modules/discover/components/discover-search-bar/discover-search-bar.directive.coffee @@ -0,0 +1,38 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search.directive.coffee +### + +DiscoverSearchBarDirective = () -> + link = (scope, el, attrs, ctrl) -> + + return { + controller: "DiscoverSearchBar", + controllerAs: "vm" + templateUrl: 'discover/components/discover-search-bar/discover-search-bar.html', + bindToController: true, + scope: { + q: "=" + filter: "=", + onChange: "&" + }, + link: link + } + +DiscoverSearchBarDirective.$inject = [] + +angular.module('taigaDiscover').directive('tgDiscoverSearchBar', DiscoverSearchBarDirective) diff --git a/app/modules/discover/components/discover-search-bar/discover-search-bar.jade b/app/modules/discover/components/discover-search-bar/discover-search-bar.jade new file mode 100644 index 00000000..201c6bca --- /dev/null +++ b/app/modules/discover/components/discover-search-bar/discover-search-bar.jade @@ -0,0 +1,71 @@ +div.discover-header + div.discover-header-inner + + h1.title {{ 'DISCOVER.DISCOVER_TITLE' | translate }} + + p.project-number( + ng-if="vm.projects", + translate="DISCOVER.DISCOVER_SUBTITLE", + translate-values="{ projects: '{{vm.projects}}'}" + translate-interpolation="messageformat" + ) + + form(ng-submit="vm.submitFilter()") + div.searchbox + input( + name="search" + type="text" + placeholder="{{ 'DISCOVER.SEARCH.INPUT_PLACEHOLDER' | translate }}" + ng-model="vm.q" + ) + a.search-button( + ng-click="vm.submitFilter()" + href="#" + title="{{ 'DISCOVER.SEARCH.ACTION_TITLE' | translate }}" + ) + include ../../../../svg/search.svg + + fieldset.searchbox-filters(ng-if="vm.filter") + input( + type='radio' + id="filter-all" + name="filter-search" + ) + label( + for="filter-all" + ng-click="vm.selectFilter('all')" + ng-class="{active: vm.filter == 'all'}", + ) {{ 'DISCOVER.FILTERS.ALL' | translate }} + + input( + type='radio' + id="filter-kanban" + name="filter-search" + ) + label( + for="filter-kanban" + ng-class="{active: vm.filter == 'kanban'}", + ng-click="vm.selectFilter('kanban')" + ) {{ 'DISCOVER.FILTERS.KANBAN' | translate }} + + input( + type='radio' + id="filter-scrum" + name="filter-search" + ) + label( + for="filter-scrum" + ng-class="{active: vm.filter == 'scrum'}", + ng-click="vm.selectFilter('scrum')" + ) {{ 'DISCOVER.FILTERS.SCRUM' | translate }} + + input( + type='radio' + id="filter-people" + name="filter-search" + ) + label( + for="filter-people" + ng-class="{active: vm.filter == 'people'}", + ng-click="vm.selectFilter('people')" + ) {{ 'DISCOVER.FILTERS.PEOPLE' | translate }} diff --git a/app/modules/discover/components/discover-search-bar/discover-search-bar.scss b/app/modules/discover/components/discover-search-bar/discover-search-bar.scss new file mode 100644 index 00000000..a45705b2 --- /dev/null +++ b/app/modules/discover/components/discover-search-bar/discover-search-bar.scss @@ -0,0 +1,54 @@ +.discover-header { + background: url('../images/discover.png') repeat-x bottom left $whitish; + margin-bottom: 2.5rem; + padding: 1rem 1rem 2rem; + text-align: center; + .discover-header-inner { + @include centered; + margin: 0 auto; + } + .title { + @extend %xxlarge; + margin-bottom: 0; + } + .project-number { + @extend %light; + @extend %large; + color: $primary; + } + form { + margin: 0 30%; + position: relative; + @include breakpoint(tablet) { + margin: 0 .5rem; + } + } + input[type="text"] { + background: $white; + border: 0; + padding: 1rem; + width: 100%; + &:focus { + outline-color: $primary-light; + } + &:-webkit-autofill { + background: rgba($primary-dark, .5); + } + } + .search-button { + position: absolute; + right: 1rem; + top: 1rem; + &:hover { + svg { + fill: $primary; + } + } + } + svg { + fill: $gray-light; + height: 1.5rem; + transition: all .2; + width: 1.5rem; + } +} diff --git a/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.coffee b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.coffee new file mode 100644 index 00000000..4c6e0c23 --- /dev/null +++ b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.coffee @@ -0,0 +1,46 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search-list-header.controller.coffee +### + +class DiscoverSearchListHeaderController + @.$inject = [] + + constructor: () -> + @.like_is_open = @.orderBy.indexOf('-total_fans') == 0 + @.activity_is_open = @.orderBy.indexOf('-total_activity') == 0 + + openLike: () -> + @.like_is_open = true + @.activity_is_open = false + + @.setOrderBy('-total_fans_last_week') + + openActivity: () -> + @.activity_is_open = true + @.like_is_open = false + + @.setOrderBy('-total_activity_last_week') + + setOrderBy: (type = '') -> + if !type + @.like_is_open = false + @.activity_is_open = false + + @.onChange({orderBy: type}) + +angular.module("taigaDiscover").controller("DiscoverSearchListHeader", DiscoverSearchListHeaderController) diff --git a/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.spec.coffee b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.spec.coffee new file mode 100644 index 00000000..bda659f9 --- /dev/null +++ b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.controller.spec.coffee @@ -0,0 +1,117 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search-list-header.controller.spec.coffee +### + +describe "DiscoverSearchListHeader", -> + $provide = null + $controller = null + scope = null + + _inject = -> + inject (_$controller_, $rootScope) -> + $controller = _$controller_ + scope = $rootScope.$new() + + _setup = -> + _inject() + + beforeEach -> + module "taigaDiscover" + + _setup() + + it "openLike", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '' + }) + + ctrl.like_is_open = false + ctrl.activity_is_open = true + ctrl.setOrderBy = sinon.spy() + + ctrl.openLike() + + expect(ctrl.like_is_open).to.be.true + expect(ctrl.activity_is_open).to.be.false + expect(ctrl.setOrderBy).have.been.calledWith('-total_fans_last_week') + + it "openActivity", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '' + }) + + ctrl.activity_is_open = false + ctrl.like_is_open = true + ctrl.setOrderBy = sinon.spy() + + ctrl.openActivity() + + expect(ctrl.activity_is_open).to.be.true + expect(ctrl.like_is_open).to.be.false + expect(ctrl.setOrderBy).have.been.calledWith('-total_activity_last_week') + + it "setOrderBy", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '' + }) + + ctrl.onChange = sinon.spy() + + ctrl.setOrderBy("type1") + + expect(ctrl.onChange).to.have.been.calledWith(sinon.match({orderBy: "type1"})) + + it "setOrderBy falsy close the like or activity layer", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '' + }) + + ctrl.like_is_open = true + ctrl.activity_is_open = true + + ctrl.onChange = sinon.spy() + + ctrl.setOrderBy() + + expect(ctrl.onChange).to.have.been.calledWith(sinon.match({orderBy: ''})) + expect(ctrl.like_is_open).to.be.false + expect(ctrl.activity_is_open).to.be.false + + it "closed like & activity", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '' + }) + + expect(ctrl.like_is_open).to.be.false + expect(ctrl.activity_is_open).to.be.false + + it "open like", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '-total_fans' + }) + + expect(ctrl.like_is_open).to.be.true + expect(ctrl.activity_is_open).to.be.false + + it "open activity", () -> + ctrl = $controller("DiscoverSearchListHeader", scope, { + orderBy: '-total_activity' + }) + + expect(ctrl.like_is_open).to.be.false + expect(ctrl.activity_is_open).to.be.true diff --git a/app/modules/discover/components/discover-search-list-header/discover-search-list-header.directive.coffee b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.directive.coffee new file mode 100644 index 00000000..ce344946 --- /dev/null +++ b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.directive.coffee @@ -0,0 +1,37 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search-list-header.directive.coffee +### + +DiscoverSearchListHeaderDirective = () -> + link = (scope, el, attrs) -> + + return { + controller: "DiscoverSearchListHeader", + controllerAs: "vm", + bindToController: true, + templateUrl: "discover/components/discover-search-list-header/discover-search-list-header.html", + scope: { + onChange: "&", + orderBy: "=" + }, + link: link + } + +DiscoverSearchListHeaderDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgDiscoverSearchListHeader", DiscoverSearchListHeaderDirective) diff --git a/app/modules/discover/components/discover-search-list-header/discover-search-list-header.jade b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.jade new file mode 100644 index 00000000..b480a619 --- /dev/null +++ b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.jade @@ -0,0 +1,89 @@ +.discover-results-header + .discover-results-header-inner + .title + include ../../../../svg/search.svg + h2 {{ 'DISCOVER.SEARCH.RESULTS' | translate }} + + .filter-discover-search(ng-mouseleave="vm.toggleClose()") + a.discover-search-filter( + href="#" + ng-click="vm.openLike()" + ng-class="{active: vm.like_is_open}" + ) + include ../../../../svg/like.svg + span {{ 'DISCOVER.MOST_LIKED' | translate }} + a.discover-search-filter( + href="#" + ng-click="vm.openActivity()" + ng-class="{active: vm.activity_is_open}" + ) + include ../../../../svg/activity.svg + span {{ 'DISCOVER.MOST_ACTIVE' | translate }} + + .discover-search-subfilter.most-liked-subfilter(ng-if="vm.like_is_open") + a.results( + ng-if="vm.orderBy" + title="" + href="#", + ng-click="vm.setOrderBy()" + ) {{ 'DISCOVER.FILTERS.CLEAR' | translate }} + + ul.filter-list + li + a( + ng-class="{active: vm.orderBy == '-total_fans_last_week'}", + href="#", + ng-click="vm.setOrderBy('-total_fans_last_week')" + ) {{ 'DISCOVER.FILTERS.WEEK' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_fans_last_month'}", + href="#", + ng-click="vm.setOrderBy('-total_fans_last_month')" + ) {{ 'DISCOVER.FILTERS.MONTH' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_fans_last_year'}", + href="#", + ng-click="vm.setOrderBy('-total_fans_last_year')" + ) {{ 'DISCOVER.FILTERS.YEAR' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_fans'}", + href="#", + ng-click="vm.setOrderBy('-total_fans')" + ) {{ 'DISCOVER.FILTERS.ALL_TIME' | translate }} + + .discover-search-subfilter.most-active-subfilter(ng-if="vm.activity_is_open") + a.results( + ng-if="vm.orderBy" + title="" + href="#", + ng-click="vm.setOrderBy()" + ) {{ 'DISCOVER.FILTERS.CLEAR' | translate }} + + ul.filter-list + li + a( + ng-class="{active: vm.orderBy == '-total_activity_last_week'}", + href="#", + ng-click="vm.setOrderBy('-total_activity_last_week')" + ) {{ 'DISCOVER.FILTERS.WEEK' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_activity_last_month'}", + href="#", + ng-click="vm.setOrderBy('-total_activity_last_month')" + ) {{ 'DISCOVER.FILTERS.MONTH' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_activity_last_year'}", + href="#", + ng-click="vm.setOrderBy('-total_activity_last_year')" + ) {{ 'DISCOVER.FILTERS.YEAR' | translate }} + li + a( + ng-class="{active: vm.orderBy == '-total_activity'}", + href="#", + ng-click="vm.setOrderBy('-total_activity')" + ) {{ 'DISCOVER.FILTERS.ALL_TIME' | translate }} diff --git a/app/modules/discover/components/discover-search-list-header/discover-search-list-header.scss b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.scss new file mode 100644 index 00000000..d3112c54 --- /dev/null +++ b/app/modules/discover/components/discover-search-list-header/discover-search-list-header.scss @@ -0,0 +1,80 @@ +.discover-results-header { + .discover-results-header-inner { + align-items: center; + display: flex; + justify-content: space-between; + } + svg { + @include svg-size(1.1rem); + fill: $gray-light; + } + .title { + @extend %bold; + @extend %larger; + text-transform: uppercase; + } + h2 { + display: inline-block; + } +} + +.filter-discover-search { + .discover-search-filter { + margin-right: 1rem; + &.active { + color: $primary; + } + } +} + +.discover-search-subfilter { + @include arrow('bottom', $whitish, $whitish, 1, 8); + align-items: center; + background: $whitish; + display: flex; + justify-content: space-between; + position: relative; + &.most-liked-subfilter { + &::after, + &::before { + left: 85%; + } + } + &.most-active-subfilter { + &::after, + &::before { + left: 95%; + } + } + &.ng-enter { + animation: dropdownFade .2s; + } + .results { + @extend %small; + color: $red-light; + display: block; + padding: .5rem 1rem; + transition: all .2s; + &:hover { + color: $red; + } + } + .filter-list { + display: flex; + margin: 0; + margin-left: auto; + a { + display: block; + padding: .5rem 1rem; + transition: all .2s; + &:hover { + background: $gray-light; + color: currentColor; + } + &.active { + background: $primary-light; + color: $whitish; + } + } + } +} diff --git a/app/modules/discover/components/featured-projects/featured-projects.controller.coffee b/app/modules/discover/components/featured-projects/featured-projects.controller.coffee new file mode 100644 index 00000000..9175a524 --- /dev/null +++ b/app/modules/discover/components/featured-projects/featured-projects.controller.coffee @@ -0,0 +1,30 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: featured-projects.controller.coffee +### + +class FeaturedProjectsController + @.$inject = [ + "tgDiscoverProjectsService" + ] + + constructor: (@discoverProjectsService) -> + taiga.defineImmutableProperty @, "featured", () => return @discoverProjectsService.featured + + @discoverProjectsService.fetchFeatured() + +angular.module("taigaDiscover").controller("FeaturedProjects", FeaturedProjectsController) diff --git a/app/modules/discover/components/featured-projects/featured-projects.directive.coffee b/app/modules/discover/components/featured-projects/featured-projects.directive.coffee new file mode 100644 index 00000000..0ec079b5 --- /dev/null +++ b/app/modules/discover/components/featured-projects/featured-projects.directive.coffee @@ -0,0 +1,33 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: featured-projects.directive.coffee +### + +FeaturedProjectsDirective = () -> + link = (scope, el, attrs) -> + + return { + controller: "FeaturedProjects" + controllerAs: "vm", + templateUrl: "discover/components/featured-projects/featured-projects.html", + scope: {}, + link: link + } + +FeaturedProjectsDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgFeaturedProjects", FeaturedProjectsDirective) diff --git a/app/modules/discover/components/featured-projects/featured-projects.jade b/app/modules/discover/components/featured-projects/featured-projects.jade new file mode 100644 index 00000000..ec1de366 --- /dev/null +++ b/app/modules/discover/components/featured-projects/featured-projects.jade @@ -0,0 +1,52 @@ +.featured-projects(ng-if="vm.featured.size") + h1.title {{ 'DISCOVER.FEATURED' | translate }} + + .featured-projects-inner + .featured-project(tg-repeat="project in vm.featured track by project.get('id')") + .tags-container + .project-tag( + style="background: {{tag.get('color')}}" + title="{{tag.get('name')}}" + tg-repeat="tag in project.get('colorized_tags') track by tag.get('name')" + ) + .project-card-inner + .project-card-header + a.project-card-logo( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) + img( + tg-project-logo-src="::project" + alt="{{::project.get('name')}}" + ) + h2.project-card-name + a( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) {{::project.get('name')}} + span.look-for-people( + ng-if="project.get('is_looking_for_people')" + title="{{ ::project.get('looking_for_people_note') }}" + ) + include ../../../../svg/recruit.svg + p.project-card-description {{ ::project.get('description') | limitTo:100 }}{{ ::project.get('description').length < 100 ? '' : '...'}} + .project-card-statistics + span.statistic( + ng-class="{'active': project.get('is_fan')}" + title="{{ 'PROJECT.FANS_COUNTER_TITLE'|translate:{total:project.get('total_fans')||0}:'messageformat' }}" + ) + include ../../../../svg/like.svg + span {{::project.get('total_fans')}} + span.statistic( + ng-class="{'active': project.get('is_watcher')}" + title="{{ 'PROJECT.WATCHERS_COUNTER_TITLE'|translate:{total:project.get('total_watchers')||0}:'messageformat' }}" + ) + include ../../../../svg/eye.svg + span {{::project.get('total_watchers')}} + span.statistic( + title="{{ 'PROJECT.MEMBERS_COUNTER_TITLE'|translate:{total:project.get('members').size||0}:'messageformat' }}" + ) + include ../../../../svg/team.svg + span.statistics-num {{ ::project.get('members').size }} diff --git a/app/modules/discover/components/featured-projects/featured-projects.scss b/app/modules/discover/components/featured-projects/featured-projects.scss new file mode 100644 index 00000000..17e4c703 --- /dev/null +++ b/app/modules/discover/components/featured-projects/featured-projects.scss @@ -0,0 +1,29 @@ +@import '../../../../styles/dependencies/mixins/project-card'; + +.featured-projects { + @include centered; + .title { + @extend %bold; + @extend %larger; + color: $grayer; + text-align: center; + } +} +.featured-projects-inner { + align-items: stretch; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.featured-project { + @include project-card; + display: flex; + flex-basis: 23%; + @include breakpoint(tablet) { + flex-basis: 45%; + } + @include breakpoint(mobile) { + flex-basis: 100%; + } +} diff --git a/app/modules/discover/components/highlighted/highlighted.directive.coffee b/app/modules/discover/components/highlighted/highlighted.directive.coffee new file mode 100644 index 00000000..3fee80b4 --- /dev/null +++ b/app/modules/discover/components/highlighted/highlighted.directive.coffee @@ -0,0 +1,32 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: highlighted.directive.coffee +### + +HighlightedDirective = () -> + return { + templateUrl: "discover/components/highlighted/highlighted.html", + scope: { + loading: "=", + highlighted: "=", + orderBy: "=" + } + } + +HighlightedDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgHighlighted", HighlightedDirective) diff --git a/app/modules/discover/components/highlighted/highlighted.jade b/app/modules/discover/components/highlighted/highlighted.jade new file mode 100644 index 00000000..a7606e77 --- /dev/null +++ b/app/modules/discover/components/highlighted/highlighted.jade @@ -0,0 +1,57 @@ +.highlighted-projects-container + .loading-container( + tg-loading="loading" + ng-show="loading" + ) + .highlighted-project( + tg-repeat="project in highlighted track by project.get('id')" + ng-if="!loading" + ) + a.project-logo( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) + img( + tg-project-logo-src="::project" + alt="{{::project.get('name')}}" + ) + .project-data-container + .single-project-header + h2.project-title + a( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) {{::project.get('name')}} + span.look-for-people( + ng-if="project.get('is_looking_for_people')" + title="{{ ::project.get('looking_for_people_note') }}" + ) + include ../../../../svg/recruit.svg + .project-statistics + span.statistic( + ng-class="{'active': project.get('is_fan')}" + title="{{ 'PROJECT.FANS_COUNTER_TITLE'|translate:{total:project.get('total_fans')||0}:'messageformat' }}" + ) + include ../../../../svg/like.svg + span {{::project.get('total_fans')}} + span.statistic( + ng-class="{'active': project.get('is_watcher')}" + title="{{ 'PROJECT.WATCHERS_COUNTER_TITLE'|translate:{total:project.get('total_watchers')||0}:'messageformat' }}" + ) + include ../../../../svg/eye.svg + span {{::project.get('total_watchers')}} + span.statistic( + title="{{ 'PROJECT.MEMBERS_COUNTER_TITLE'|translate:{total:project.get('members').size||0}:'messageformat' }}" + ) + include ../../../../svg/team.svg + span.statistics-num {{ ::project.get('members').size }} + p.project-description {{ ::project.get('description') | limitTo:150 }}{{ ::project.get('description').length < 150 ? '' : '...'}} + + a.view-more-projects.button-green( + ng-if="highlighted" + tg-nav="discover-search" + tg-nav-get-params="{\"order_by\": \"{{orderBy}}\"}" + href="#" + ) {{ 'DISCOVER.VIEW_MORE' | translate }} diff --git a/app/modules/discover/components/highlighted/highlighted.scss b/app/modules/discover/components/highlighted/highlighted.scss new file mode 100644 index 00000000..e1064406 --- /dev/null +++ b/app/modules/discover/components/highlighted/highlighted.scss @@ -0,0 +1,205 @@ +.highlighted { + @include centered; + display: flex; + justify-content: space-around; + margin-bottom: 4rem; + @include breakpoint(tablet) { + flex-direction: column; + tg-most-active { + margin-top: 4rem; + } + } + tg-most-liked, + tg-most-active { + align-content: stretch; + display: flex; + flex: 1; + } + tg-most-liked { + margin-right: 8%; + @include breakpoint(tablet) { + margin-right: 0; + } + } + .most-active, + .most-liked { + align-content: stretch; + display: flex; + flex: 1; + flex-direction: column; + } + .header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 1rem; + svg { + @include svg-size(1.5rem); + fill: $gray-light; + margin: .5rem; + } + } + .title-wrapper { + align-items: center; + display: flex; + } + .title { + @extend %bold; + @extend %larger; + color: $grayer; + display: inline-block; + margin: 0; + } + tg-highlighted { + display: flex; + flex: 1; + } + .highlighted-projects-container { + display: flex; + flex: 1; + flex-direction: column; + justify-content: flex-start; + } + .loading-container { + margin-top: calc(50% - 1rem); + } + .loading-spinner { + display: block; + margin: 2rem auto; + max-height: 3rem; + max-width: 3rem; + } + .view-more-projects { + margin-top: auto; + width: 100%; + } + .empty-highlighted-project { + border: 2px dashed $whitish; + padding: 2rem; + text-align: center; + svg { + @include svg-size(2rem); + display: block; + fill: $gray-light; + margin: 1rem auto; + } + span { + @extend %light; + color: $gray; + display: block; + } + } +} + + +.filter-highlighted { + position: relative; + .current-filter { + padding: 1rem; + span { + margin-left: .2rem; + position: relative; + top: .2rem; + } + } + .filter-list { + background: $black; + position: absolute; + right: 0; + top: 1.5rem; + &.ng-enter { + animation: dropdownFade .2s ease-in; + } + &.ng-leave { + animation: dropdownFade .2s ease-in; + animation-direction: reverse; + } + } + li { + @extend %small; + color: $white; + cursor: pointer; + min-width: 8rem; + padding: .25rem .5rem; + &:hover { + background: rgba($primary-light, .4); + } + } +} + +.highlighted-project { + align-items: flex-start; + border-bottom: 1px solid $whitish; + display: flex; + flex-basis: 9rem; + min-height: 9rem; + padding: 1.5rem 0; + &:nth-last-child(-n+2) { + border-bottom: 0; + } + .project-logo { + flex-basis: 3rem; + height: auto; + margin-right: 1rem; + width: 3rem; + img { + width: 100%; + } + } + .project-data-container { + flex: 1; + } + .single-project-header { + align-content: center; + display: flex; + justify-content: space-between; + } + .project-title { + @extend %large; + @extend %text; + display: inline-block; + margin-bottom: .5rem; + a { + color: $primary; + &:hover { + color: $primary-light; + } + } + } + .look-for-people { + svg { + @include svg-size(); + fill: $gray-light; + margin-left: .5rem; + } + } + .project-description { + @extend %small; + color: $gray; + margin-bottom: 0; + } + .project-statistics { + display: flex; + flex-basis: 140px; + justify-content: flex-end; + svg { + @include svg-size(.8rem); + fill: $gray-light; + } + .svg-eye-closed { + display: none; + } + } + .statistic { + @extend %small; + color: $gray-light; + display: inline-block; + margin-right: .5rem; + &.active { + color: $primary; + svg { + fill: $primary; + } + } + } +} diff --git a/app/modules/discover/components/most-active/most-active.controller.coffee b/app/modules/discover/components/most-active/most-active.controller.coffee new file mode 100644 index 00000000..6c8676b7 --- /dev/null +++ b/app/modules/discover/components/most-active/most-active.controller.coffee @@ -0,0 +1,49 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: most-active.controller.coffee +### + +class MostActiveController + @.$inject = [ + "tgDiscoverProjectsService" + ] + + constructor: (@discoverProjectsService) -> + taiga.defineImmutableProperty @, "highlighted", () => return @discoverProjectsService.mostActive + + @.currentOrderBy = 'week' + @.order_by = @.getOrderBy() + + fetch: () -> + @.loading = true + @.order_by = @.getOrderBy() + + return @discoverProjectsService.fetchMostActive({order_by: @.order_by}).then () => + @.loading = false + + orderBy: (type) -> + @.currentOrderBy = type + + @.fetch() + + getOrderBy: (type) -> + if @.currentOrderBy == 'all' + return '-total_activity' + else + return '-total_activity_last_' + @.currentOrderBy + +angular.module("taigaDiscover").controller("MostActive", MostActiveController) diff --git a/app/modules/discover/components/most-active/most-active.controller.spec.coffee b/app/modules/discover/components/most-active/most-active.controller.spec.coffee new file mode 100644 index 00000000..da7258bc --- /dev/null +++ b/app/modules/discover/components/most-active/most-active.controller.spec.coffee @@ -0,0 +1,79 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: most-active.controller.spec.coffee +### + +describe "MostActive", -> + $provide = null + $controller = null + mocks = {} + + _mockDiscoverProjectsService = -> + mocks.discoverProjectsService = { + fetchMostActive: sinon.stub() + } + + $provide.value("tgDiscoverProjectsService", mocks.discoverProjectsService) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockDiscoverProjectsService() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaDiscover" + + _setup() + + it "fetch", (done) -> + ctrl = $controller("MostActive") + + ctrl.getOrderBy = sinon.stub().returns('week') + + mockPromise = mocks.discoverProjectsService.fetchMostActive.withArgs(sinon.match({order_by: 'week'})).promise() + + promise = ctrl.fetch() + + expect(ctrl.loading).to.be.true + + mockPromise.resolve() + + promise.finally () -> + expect(ctrl.loading).to.be.false + done() + + + it "order by", () -> + ctrl = $controller("MostActive") + + ctrl.fetch = sinon.spy() + + ctrl.orderBy('month') + + expect(ctrl.fetch).to.have.been.called + expect(ctrl.currentOrderBy).to.be.equal('month') diff --git a/app/modules/discover/components/most-active/most-active.directive.coffee b/app/modules/discover/components/most-active/most-active.directive.coffee new file mode 100644 index 00000000..c3cf6b84 --- /dev/null +++ b/app/modules/discover/components/most-active/most-active.directive.coffee @@ -0,0 +1,34 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: most-active.directive.coffee +### + +MostActiveDirective = () -> + link = (scope, el, attrs, ctrl) -> + ctrl.fetch() + + return { + controller: "MostActive" + controllerAs: "vm", + templateUrl: "discover/components/most-active/most-active.html", + scope: {}, + link: link + } + +MostActiveDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgMostActive", MostActiveDirective) diff --git a/app/modules/discover/components/most-active/most-active.jade b/app/modules/discover/components/most-active/most-active.jade new file mode 100644 index 00000000..37e58cdf --- /dev/null +++ b/app/modules/discover/components/most-active/most-active.jade @@ -0,0 +1,18 @@ +.most-active(ng-if="vm.highlighted.size") + .header + .title-wrapper + include ../../../../svg/activity.svg + h1.title {{ 'DISCOVER.MOST_ACTIVE' | translate }} + tg-discover-home-order-by(on-change="vm.orderBy(orderBy)", order-by="vm.currentOrderBy") + + tg-highlighted( + loading="vm.loading", + highlighted="vm.highlighted" + order-by="vm.order_by" + ) + +.empty-highlighted-project( + ng-if="!vm.highlighted.size" +) + include ../../../../svg/activity.svg + span {{ 'DISCOVER.MOST_ACTIVE_EMPTY' | translate }} diff --git a/app/modules/discover/components/most-liked/most-liked.controller.coffee b/app/modules/discover/components/most-liked/most-liked.controller.coffee new file mode 100644 index 00000000..b75e2c9a --- /dev/null +++ b/app/modules/discover/components/most-liked/most-liked.controller.coffee @@ -0,0 +1,49 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: msot-liked.controller.coffee +### + +class MostLikedController + @.$inject = [ + "tgDiscoverProjectsService" + ] + + constructor: (@discoverProjectsService) -> + taiga.defineImmutableProperty @, "highlighted", () => return @discoverProjectsService.mostLiked + + @.currentOrderBy = 'week' + @.order_by = @.getOrderBy() + + fetch: () -> + @.loading = true + @.order_by = @.getOrderBy() + + @discoverProjectsService.fetchMostLiked({order_by: @.order_by}).then () => + @.loading = false + + orderBy: (type) -> + @.currentOrderBy = type + + @.fetch() + + getOrderBy: () -> + if @.currentOrderBy == 'all' + return '-total_fans' + else + return '-total_fans_last_' + @.currentOrderBy + +angular.module("taigaDiscover").controller("MostLiked", MostLikedController) diff --git a/app/modules/discover/components/most-liked/most-liked.controller.spec.coffee b/app/modules/discover/components/most-liked/most-liked.controller.spec.coffee new file mode 100644 index 00000000..f00ee3ad --- /dev/null +++ b/app/modules/discover/components/most-liked/most-liked.controller.spec.coffee @@ -0,0 +1,79 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: most-liked.controller.spec.coffee +### + +describe "MostLiked", -> + $provide = null + $controller = null + mocks = {} + + _mockDiscoverProjectsService = -> + mocks.discoverProjectsService = { + fetchMostLiked: sinon.stub() + } + + $provide.value("tgDiscoverProjectsService", mocks.discoverProjectsService) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockDiscoverProjectsService() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaDiscover" + + _setup() + + it "fetch", (done) -> + ctrl = $controller("MostLiked") + + ctrl.getOrderBy = sinon.stub().returns('week') + + mockPromise = mocks.discoverProjectsService.fetchMostLiked.withArgs(sinon.match({order_by: 'week'})).promise() + + promise = ctrl.fetch() + + expect(ctrl.loading).to.be.true + + mockPromise.resolve() + + promise.finally () -> + expect(ctrl.loading).to.be.false + done() + + + it "order by", () -> + ctrl = $controller("MostLiked") + + ctrl.fetch = sinon.spy() + + ctrl.orderBy('month') + + expect(ctrl.fetch).to.have.been.called + expect(ctrl.currentOrderBy).to.be.equal('month') diff --git a/app/modules/discover/components/most-liked/most-liked.directive.coffee b/app/modules/discover/components/most-liked/most-liked.directive.coffee new file mode 100644 index 00000000..06813cb9 --- /dev/null +++ b/app/modules/discover/components/most-liked/most-liked.directive.coffee @@ -0,0 +1,34 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: most-liked.directive.coffee +### + +MostLikedDirective = () -> + link = (scope, el, attrs, ctrl) -> + ctrl.fetch() + + return { + controller: "MostLiked" + controllerAs: "vm", + templateUrl: "discover/components/most-liked/most-liked.html", + scope: {}, + link: link + } + +MostLikedDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgMostLiked", MostLikedDirective) diff --git a/app/modules/discover/components/most-liked/most-liked.jade b/app/modules/discover/components/most-liked/most-liked.jade new file mode 100644 index 00000000..e3967aa0 --- /dev/null +++ b/app/modules/discover/components/most-liked/most-liked.jade @@ -0,0 +1,17 @@ +.most-liked(ng-if="vm.highlighted.size") + .header + .title-wrapper + include ../../../../svg/like.svg + h1.title {{ 'DISCOVER.MOST_LIKED' | translate }} + tg-discover-home-order-by(on-change="vm.orderBy(orderBy)", order-by="vm.currentOrderBy") + tg-highlighted( + loading="vm.loading", + highlighted="vm.highlighted" + order-by="vm.order_by" + ) + +.empty-highlighted-project( + ng-if="!vm.highlighted.size" +) + include ../../../../svg/like.svg + span {{ 'DISCOVER.MOST_LIKED_EMPTY' | translate }} diff --git a/app/modules/discover/discover-home/discover-home.controller.coffee b/app/modules/discover/discover-home/discover-home.controller.coffee new file mode 100644 index 00000000..d9d249d6 --- /dev/null +++ b/app/modules/discover/discover-home/discover-home.controller.coffee @@ -0,0 +1,33 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-home.controller.coffee +### + +class DiscoverHomeController + @.$inject = [ + '$tgLocation', + '$tgNavUrls' + ] + + constructor: (@location, @navUrls) -> + + onSubmit: (q) -> + url = @navUrls.resolve('discover-search') + + @location.search('text', q).path(url) + +angular.module("taigaDiscover").controller("DiscoverHome", DiscoverHomeController) diff --git a/app/modules/discover/discover-home/discover-home.controller.spec.coffee b/app/modules/discover/discover-home/discover-home.controller.spec.coffee new file mode 100644 index 00000000..0318c6ba --- /dev/null +++ b/app/modules/discover/discover-home/discover-home.controller.spec.coffee @@ -0,0 +1,71 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: doscover-home.controller.spec.coffee +### + +describe "DiscoverHomeController", -> + $provide = null + $controller = null + mocks = {} + + _mockLocation = -> + mocks.location = {} + + $provide.value('$tgLocation', mocks.location) + + _mockNavUrls = -> + mocks.navUrls = {} + + $provide.value('$tgNavUrls', mocks.navUrls) + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockLocation() + _mockNavUrls() + + return null + + _setup = -> + _inject() + + beforeEach -> + module "taigaDiscover" + + _mocks() + _setup() + + it "onSubmit redirect to discover search", () -> + mocks.navUrls.resolve = sinon.stub().withArgs('discover-search').returns('url') + + pathSpy = sinon.spy() + searchStub = { + path: pathSpy + } + + mocks.location.search = sinon.stub().withArgs('text', 'query').returns(searchStub) + + ctrl = $controller("DiscoverHome") + + ctrl.onSubmit('query') + + expect(pathSpy).to.have.been.calledWith('url'); diff --git a/app/modules/discover/discover-home/discover-home.jade b/app/modules/discover/discover-home/discover-home.jade new file mode 100644 index 00000000..56096cb8 --- /dev/null +++ b/app/modules/discover/discover-home/discover-home.jade @@ -0,0 +1,12 @@ +doctype html + +section.discover + header + tg-discover-search-bar(on-change="vm.onSubmit(q)") + + section.highlighted + tg-most-liked + tg-most-active + + section.featured-projects + tg-featured-projects diff --git a/app/modules/discover/discover-search/discover-search.controller.coffee b/app/modules/discover/discover-search/discover-search.controller.coffee new file mode 100644 index 00000000..0744a9ad --- /dev/null +++ b/app/modules/discover/discover-search/discover-search.controller.coffee @@ -0,0 +1,114 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search.controller.coffee +### + +class DiscoverSearchController + @.$inject = [ + '$routeParams', + 'tgDiscoverProjectsService', + '$route' + ] + + constructor: (@routeParams, @discoverProjectsService, @route) -> + @.page = 1 + + taiga.defineImmutableProperty @, "searchResult", () => return @discoverProjectsService.searchResult + taiga.defineImmutableProperty @, "nextSearchPage", () => return @discoverProjectsService.nextSearchPage + + @.q = @routeParams.text + @.filter = @routeParams.filter || 'all' + @.orderBy = @routeParams['order_by'] || '' + + @.loadingGlobal = false + @.loadingList = false + @.loadingPagination = false + + fetch: () -> + @.page = 1 + + @discoverProjectsService.resetSearchList() + + return @.search() + + fetchByGlobalSearch: () -> + return if @.loadingGlobal + + @.loadingGlobal = true + + @.fetch().then () => @.loadingGlobal = false + + fetchByOrderBy: () -> + return if @.loadingList + + @.loadingList = true + + @.fetch().then () => @.loadingList = false + + showMore: () -> + return if @.loadingPagination + + @.loadingPagination = true + + @.page++ + + return @.search().then () => @.loadingPagination = false + + search: () -> + filter = @.getFilter() + + params = { + page: @.page, + q: @.q, + order_by: @.orderBy + } + + _.assign(params, filter) + + return @discoverProjectsService.fetchSearch(params) + + getFilter: () -> + if @.filter == 'people' + return {is_looking_for_people: true} + else if @.filter == 'scrum' + return {is_backlog_activated: true} + else if @.filter == 'kanban' + return {is_kanban_activated: true} + + return {} + + onChangeFilter: (filter, q) -> + @.filter = filter + @.q = q + + @route.updateParams({ + filter: @.filter, + text: @.q + }) + + @.fetchByGlobalSearch() + + onChangeOrder: (orderBy) -> + @.orderBy = orderBy + + @route.updateParams({ + order_by: orderBy + }) + + @.fetchByOrderBy() + +angular.module("taigaDiscover").controller("DiscoverSearch", DiscoverSearchController) diff --git a/app/modules/discover/discover-search/discover-search.controller.spec.coffee b/app/modules/discover/discover-search/discover-search.controller.spec.coffee new file mode 100644 index 00000000..6c05d661 --- /dev/null +++ b/app/modules/discover/discover-search/discover-search.controller.spec.coffee @@ -0,0 +1,199 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search.controller.spec.coffee +### + +describe "DiscoverSearch", -> + $provide = null + $controller = null + mocks = {} + + _mockRouteParams = -> + mocks.routeParams = {} + + $provide.value("$routeParams", mocks.routeParams) + + _mockRoute = -> + mocks.route = {} + + $provide.value("$route", mocks.route) + + _mockDiscoverProjects = -> + mocks.discoverProjects = { + resetSearchList: sinon.spy(), + fetchSearch: sinon.stub() + } + + mocks.discoverProjects.fetchSearch.promise().resolve() + + $provide.value("tgDiscoverProjectsService", mocks.discoverProjects) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockRoute() + _mockRouteParams() + _mockDiscoverProjects() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaDiscover" + + _setup() + + it "initialize search params", () -> + mocks.routeParams.text = 'text' + mocks.routeParams.filter = 'filter' + mocks.routeParams.order_by = 'order' + + ctrl = $controller('DiscoverSearch') + + expect(ctrl.q).to.be.equal('text') + expect(ctrl.filter).to.be.equal('filter') + expect(ctrl.orderBy).to.be.equal('order') + + it "fetch", () -> + ctrl = $controller('DiscoverSearch') + + ctrl.search = sinon.spy() + + ctrl.fetch() + + expect(mocks.discoverProjects.resetSearchList).to.have.been.called + expect(ctrl.search).to.have.been.called + expect(ctrl.page).to.be.equal(1) + + it "showMore", (done) -> + ctrl = $controller('DiscoverSearch') + + ctrl.search = sinon.stub().promise() + + ctrl.showMore().then () -> + expect(ctrl.loadingPagination).to.be.false + + done() + + expect(ctrl.loadingPagination).to.be.true + expect(ctrl.search).to.have.been.called + expect(ctrl.page).to.be.equal(2) + + ctrl.search.resolve() + + it "search", () -> + mocks.discoverProjects.fetchSearch = sinon.stub() + + filter = { + filter: '123' + } + + ctrl = $controller('DiscoverSearch') + + ctrl.page = 1 + ctrl.q = 'text' + ctrl.orderBy = 1 + + ctrl.getFilter = () -> return filter + + params = { + filter: '123', + page: 1, + q: 'text', + order_by: 1 + } + + ctrl.search() + + expect(mocks.discoverProjects.fetchSearch).have.been.calledWith(sinon.match(params)) + + it "get filter", () -> + ctrl = $controller('DiscoverSearch') + + ctrl.filter = 'people' + expect(ctrl.getFilter()).to.be.eql({is_looking_for_people: true}) + + ctrl.filter = 'scrum' + expect(ctrl.getFilter()).to.be.eql({is_backlog_activated: true}) + + ctrl.filter = 'kanban' + expect(ctrl.getFilter()).to.be.eql({is_kanban_activated: true}) + + it "onChangeFilter", () -> + ctrl = $controller('DiscoverSearch') + + mocks.route.updateParams = sinon.stub() + + ctrl.fetchByGlobalSearch = sinon.spy() + + ctrl.onChangeFilter('filter', 'query') + + expect(ctrl.filter).to.be.equal('filter') + expect(ctrl.q).to.be.equal('query') + expect(ctrl.fetchByGlobalSearch).to.have.been.called + expect(mocks.route.updateParams).to.have.been.calledWith(sinon.match({filter: 'filter', text: 'query'})) + + it "onChangeOrder", () -> + ctrl = $controller('DiscoverSearch') + + mocks.route.updateParams = sinon.stub() + + ctrl.fetchByOrderBy = sinon.spy() + + ctrl.onChangeOrder('order-by') + + expect(ctrl.orderBy).to.be.equal('order-by') + expect(ctrl.fetchByOrderBy).to.have.been.called + expect(mocks.route.updateParams).to.have.been.calledWith(sinon.match({order_by: 'order-by'})) + + it "fetchByGlobalSearch", (done) -> + ctrl = $controller('DiscoverSearch') + + ctrl.fetch = sinon.stub().promise() + + ctrl.fetchByGlobalSearch().then () -> + expect(ctrl.loadingGlobal).to.be.false + + done() + + expect(ctrl.loadingGlobal).to.be.true + expect(ctrl.fetch).to.have.been.called + + ctrl.fetch.resolve() + + it "fetchByOrderBy", (done) -> + ctrl = $controller('DiscoverSearch') + + ctrl.fetch = sinon.stub().promise() + + ctrl.fetchByOrderBy().then () -> + expect(ctrl.loadingList).to.be.false + + done() + + expect(ctrl.loadingList).to.be.true + expect(ctrl.fetch).to.have.been.called + + ctrl.fetch.resolve() diff --git a/app/modules/discover/discover-search/discover-search.directive.coffee b/app/modules/discover/discover-search/discover-search.directive.coffee new file mode 100644 index 00000000..5f66cbea --- /dev/null +++ b/app/modules/discover/discover-search/discover-search.directive.coffee @@ -0,0 +1,32 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-search.directive.coffee +### + +DiscoverSearchDirective = () -> + link = (scope, element, attrs, ctrl) -> + ctrl.fetch() + + return { + controller: "DiscoverSearch", + controllerAs: "vm" + link: link + } + +DiscoverSearchDirective.$inject = [] + +angular.module("taigaDiscover").directive("tgDiscoverSearch", DiscoverSearchDirective) diff --git a/app/modules/discover/discover-search/discover-search.jade b/app/modules/discover/discover-search/discover-search.jade new file mode 100644 index 00000000..e7629e0b --- /dev/null +++ b/app/modules/discover/discover-search/discover-search.jade @@ -0,0 +1,77 @@ +div(tg-discover-search) + .discover-search + tg-discover-search-bar( + filter="vm.filter", + q="vm.q", + on-change="vm.onChangeFilter(filter, q)" + ) + + .empty-discover-results(ng-if="!vm.searchResult.size && !vm.loadingGlobal && !vm.loadingList") + img( + src="/#{v}/images/issues-empty.png", + alt="{{ DISCOVER.EMPTY | translate }}" + ) + p.title(translate="DISCOVER.EMPTY") + + .discover-results(ng-if="vm.searchResult.size || vm.loadingGlobal || vm.loadingList") + .spin(tg-loading="vm.loadingGlobal") + + .discover-results-inner(ng-if="!vm.loadingGlobal") + tg-discover-search-list-header( + on-change="vm.onChangeOrder(orderBy)", + order-by="vm.orderBy" + ) + + .spin(ng-show="vm.loadingList", tg-loading="vm.loadingList") + + ul.project-list(ng-if="!vm.loadingList && vm.searchResult.size") + li.list-itemtype-project(tg-repeat="project in vm.searchResult track by project.get('id')") + .list-itemtype-project-left + a.list-itemtype-project-image( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{ ::project.get('name') }}" + ) + img( + tg-project-logo-src="::project" + alt="{{::project.get('name')}}" + ) + .list-itemtype-project-data + h2 + a( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{ ::project.get('name') }}" + ) {{project.get('name')}} + span.look-for-people( + ng-if="project.get('is_looking_for_people')" + title="{{ ::project.get('looking_for_people_note') }}" + ) + include ../../../svg/recruit.svg + p {{ ::project.get('description') | limitTo:300 }} + span(ng-if="::project.get('description').length > 300") ... + .list-itemtype-project-right.project-statistics + span.statistic( + ng-class="{'active': project.get('is_fan')}" + title="{{ 'PROJECT.FANS_COUNTER_TITLE'|translate:{total:project.get('total_fans')||0}:'messageformat' }}" + ) + include ../../../svg/like.svg + span {{::project.get('total_fans')}} + span.statistic( + ng-class="{'active': project.get('is_watcher')}" + title="{{ 'PROJECT.WATCHERS_COUNTER_TITLE'|translate:{total:project.get('total_watchers')||0}:'messageformat' }}" + ) + include ../../../svg/eye.svg + span {{::project.get('total_watchers')}} + span.statistic( + title="{{ 'PROJECT.MEMBERS_COUNTER_TITLE'|translate:{total:project.get('members').size||0}:'messageformat' }}" + ) + include ../../../svg/team.svg + span.statistics-num {{ ::project.get('members').size }} + + a.button-green.more-results( + tg-loading="vm.loadingPagination" + href="#" + ng-click="vm.showMore()" + ng-if="vm.nextSearchPage" + ) {{ 'DISCOVER.VIEW_MORE' | translate }} diff --git a/app/modules/discover/discover-search/discover-search.scss b/app/modules/discover/discover-search/discover-search.scss new file mode 100644 index 00000000..f31bcdbd --- /dev/null +++ b/app/modules/discover/discover-search/discover-search.scss @@ -0,0 +1,131 @@ +.discover-search { + .discover-header { + form { + margin: 0 8rem; + position: relative; + } + + .search-button { + left: 1rem; + right: auto; + } + .searchbox { + input { + padding-left: 3.5rem; + padding-right: 23rem; + } + } + } + .searchbox-filters { + position: absolute; + right: 1rem; + top: .7rem; + width: auto; + input { + display: none; + } + label { + border-radius: 4px; + color: $gray-light; + cursor: pointer; + display: inline-block; + padding: .4rem .75rem; + transition: all .2s; + transition-delay: .2s; + &.active { + background: $primary-light; + color: $white; + } + &:hover { + background: $whitish; + color: $gray; + } + } + } +} + +.discover-results { + @include centered; + .discover-results-inner { + .spin { + margin-top: 4rem; + } + } + .list-itemtype-project { + border-bottom: 1px solid $gray-light; + display: flex; + padding: 1rem 0; + &:last-child { + border-bottom: 0; + } + } + .list-itemtype-project-left { + align-items: flex-start; + display: flex; + } + .list-itemtype-project-image { + flex-shrink: 0; + margin-right: 1rem; + } + .list-itemtype-project-data { + flex: 1; + vertical-align: middle; + } + .look-for-people { + margin-left: .5rem; + svg { + @include svg-size(1rem); + fill: $gray-light; + } + } + .project-statistics { + display: flex; + flex-basis: 140px; + justify-content: flex-end; + svg { + @include svg-size(.8rem); + fill: $gray-light; + } + .svg-eye-closed { + display: none; + } + } + .statistic { + @extend %small; + color: $gray-light; + display: inline-block; + margin-right: .5rem; + &.active { + color: $primary; + svg { + fill: $primary; + } + } + } + .more-results { + display: block; + margin: 0 20rem; + transition: inherit; + } + div[tg-loading] { + img { + display: block; + margin: 0 auto; + } + } +} + +.empty-discover-results { + @include centered; + margin-top: 4rem; + text-align: center; + img { + margin-bottom: 1rem; + } + .title { + @extend %large; + @extend %light; + margin: 0; + text-transform: uppercase; + } +} diff --git a/app/modules/discover/discover.module.coffee b/app/modules/discover/discover.module.coffee new file mode 100644 index 00000000..2c2bfb7f --- /dev/null +++ b/app/modules/discover/discover.module.coffee @@ -0,0 +1,20 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover.module.coffee +### + +module = angular.module("taigaDiscover", []) diff --git a/app/modules/discover/services/discover-projects.service.coffee b/app/modules/discover/services/discover-projects.service.coffee new file mode 100644 index 00000000..f53808e3 --- /dev/null +++ b/app/modules/discover/services/discover-projects.service.coffee @@ -0,0 +1,93 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-projects.service.coffee +### + +taiga = @.taiga + +class DiscoverProjectsService extends taiga.Service + @.$inject = [ + "tgResources", + "tgProjectsService" + ] + + constructor: (@rs, @projectsService) -> + @._mostLiked = Immutable.List() + @._mostActive = Immutable.List() + @._featured = Immutable.List() + @._searchResult = Immutable.List() + @._projectsCount = 0 + + @.decorate = @projectsService._decorate.bind(@projectsService) + + taiga.defineImmutableProperty @, "mostLiked", () => return @._mostLiked + taiga.defineImmutableProperty @, "mostActive", () => return @._mostActive + taiga.defineImmutableProperty @, "featured", () => return @._featured + taiga.defineImmutableProperty @, "searchResult", () => return @._searchResult + taiga.defineImmutableProperty @, "nextSearchPage", () => return @._nextSearchPage + taiga.defineImmutableProperty @, "projectsCount", () => return @._projectsCount + + fetchMostLiked: (params) -> + return @rs.projects.getProjects(params, false) + .then (result) => + data = result.data.slice(0, 5) + + projects = Immutable.fromJS(data) + projects = projects.map(@.decorate) + + @._mostLiked = projects + + fetchMostActive: (params) -> + return @rs.projects.getProjects(params, false) + .then (result) => + data = result.data.slice(0, 5) + + projects = Immutable.fromJS(data) + projects = projects.map(@.decorate) + + @._mostActive = projects + + fetchFeatured: () -> + params = {is_featured: true} + + return @rs.projects.getProjects(params, false) + .then (result) => + data = result.data.slice(0, 4) + + projects = Immutable.fromJS(data) + projects = projects.map(@.decorate) + + @._featured = projects + + resetSearchList: () -> + @._searchResult = Immutable.List() + + fetchStats: () -> + return @rs.stats.discover().then (discover) => + @._projectsCount = discover.getIn(['projects', 'total']) + + fetchSearch: (params) -> + return @rs.projects.getProjects(params) + .then (result) => + @._nextSearchPage = !!result.headers('X-Pagination-Next') + + projects = Immutable.fromJS(result.data) + projects = projects.map(@.decorate) + + @._searchResult = @._searchResult.concat(projects) + +angular.module("taigaDiscover").service("tgDiscoverProjectsService", DiscoverProjectsService) diff --git a/app/modules/discover/services/discover-projects.service.spec.coffee b/app/modules/discover/services/discover-projects.service.spec.coffee new file mode 100644 index 00000000..97adbd0c --- /dev/null +++ b/app/modules/discover/services/discover-projects.service.spec.coffee @@ -0,0 +1,178 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: discover-projects.service.spec.coffee +### + +describe "tgDiscoverProjectsService", -> + discoverProjectsService = provide = null + mocks = {} + + _mockResources = () -> + mocks.resources = { + projects: { + getProjects: sinon.stub() + }, + stats: { + discover: sinon.stub() + } + } + + provide.value "tgResources", mocks.resources + + _mockProjectsService = () -> + mocks.projectsService = { + _decorate: (content) -> + return content.set('decorate', true) + } + + provide.value "tgProjectsService", mocks.projectsService + + _inject = (callback) -> + inject (_tgDiscoverProjectsService_) -> + discoverProjectsService = _tgDiscoverProjectsService_ + callback() if callback + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockResources() + _mockProjectsService() + return null + + _setup = -> + _mocks() + + beforeEach -> + module "taigaDiscover" + _setup() + _inject() + + it "fetch most liked", (done) -> + params = {test: 1} + + mocks.resources.projects.getProjects.withArgs(sinon.match(params), false).promise().resolve({ + data: [ + {id: 1}, + {id: 2}, + {id: 3}, + {id: 4}, + {id: 5}, + {id: 6}, + {id: 7} + ] + }) + + discoverProjectsService.fetchMostLiked(params).then () -> + result = discoverProjectsService._mostLiked.toJS() + + expect(result).to.have.length(5) + expect(result[0].decorate).to.be.ok; + + done() + + it "fetch most active", (done) -> + params = {test: 1} + + mocks.resources.projects.getProjects.withArgs(sinon.match(params), false).promise().resolve({ + data: [ + {id: 1}, + {id: 2}, + {id: 3}, + {id: 4}, + {id: 5}, + {id: 6}, + {id: 7} + ] + }) + + discoverProjectsService.fetchMostActive(params).then () -> + result = discoverProjectsService._mostActive.toJS() + + expect(result).to.have.length(5) + expect(result[0].decorate).to.be.ok; + + done() + + it "fetch featured", (done) -> + mocks.resources.projects.getProjects.withArgs(sinon.match({is_featured: true}), false).promise().resolve({ + data: [ + {id: 1}, + {id: 2}, + {id: 3}, + {id: 4}, + {id: 5}, + {id: 6}, + {id: 7} + ] + }) + + discoverProjectsService.fetchFeatured().then () -> + result = discoverProjectsService._featured.toJS() + + expect(result).to.have.length(4) + expect(result[0].decorate).to.be.ok; + + done() + + it "reset search list", () -> + discoverProjectsService._searchResult = 'xxx' + + discoverProjectsService.resetSearchList() + + expect(discoverProjectsService._searchResult.size).to.be.equal(0) + + it "fetch stats", (done) -> + mocks.resources.stats.discover.promise().resolve(Immutable.fromJS({ + projects: { + total: 3 + } + })) + + discoverProjectsService.fetchStats().then () -> + expect(discoverProjectsService._projectsCount).to.be.equal(3) + + done() + + it "fetch search", (done) -> + params = {test: 1} + + result = { + headers: sinon.stub(), + data: [ + {id: 1}, + {id: 2}, + {id: 3} + ] + } + + result.headers.withArgs('X-Pagination-Next').returns('next') + + mocks.resources.projects.getProjects.withArgs(sinon.match(params)).promise().resolve(result) + + discoverProjectsService._searchResult = Immutable.fromJS([ + {id: 4}, + {id: 5} + ]) + + discoverProjectsService.fetchSearch(params).then () -> + result = discoverProjectsService._searchResult.toJS() + + expect(result).to.have.length(5) + + expect(result[4].decorate).to.be.ok; + + done() diff --git a/app/modules/home/home-controller.spec.coffee b/app/modules/home/home-controller.spec.coffee new file mode 100644 index 00000000..2713181f --- /dev/null +++ b/app/modules/home/home-controller.spec.coffee @@ -0,0 +1,78 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: home.controller.spec.coffee +### + +describe "HomeController", -> + homeCtrl = null + provide = null + controller = null + mocks = {} + + _mockCurrentUserService = () -> + mocks.currentUserService = { + getUser: sinon.stub() + } + + provide.value "tgCurrentUserService", mocks.currentUserService + + _mockLocation = () -> + mocks.location = { + path: sinon.stub() + } + provide.value "$location", mocks.location + + _mockTgNavUrls = () -> + mocks.tgNavUrls = { + resolve: sinon.stub() + } + + provide.value "$tgNavUrls", mocks.tgNavUrls + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockCurrentUserService() + _mockLocation() + _mockTgNavUrls() + + return null + + beforeEach -> + module "taigaHome" + + _mocks() + + inject ($controller) -> + controller = $controller + + it "anonymous home", () -> + homeCtrl = controller "Home", + $scope: {} + + expect(mocks.tgNavUrls.resolve).to.be.calledWith("discover") + expect(mocks.location.path).to.be.calledOnce + + it "non anonymous home", () -> + mocks.currentUserService = { + getUser: Immutable.fromJS({ + id: 1 + }) + } + + expect(mocks.tgNavUrls.resolve).to.be.notCalled + expect(mocks.location.path).to.be.notCalled diff --git a/app/modules/home/home.controller.coffee b/app/modules/home/home.controller.coffee new file mode 100644 index 00000000..a3d9121e --- /dev/null +++ b/app/modules/home/home.controller.coffee @@ -0,0 +1,32 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: home.controller.coffee +### + +class HomeController + @.$inject = [ + "tgCurrentUserService", + "$location", + "$tgNavUrls" + ] + + constructor: (@currentUserService, @location, @navUrls) -> + if not @currentUserService.getUser() + @location.path(@navUrls.resolve("discover")) + + +angular.module("taigaHome").controller("Home", HomeController) diff --git a/app/modules/home/home.scss b/app/modules/home/home.scss index e605acea..9f488e10 100644 --- a/app/modules/home/home.scss +++ b/app/modules/home/home.scss @@ -11,7 +11,7 @@ display: block; } .title-bar { - @extend %title; + @extend %light; @extend %larger; align-content: center; background: $whitish; diff --git a/app/modules/home/projects/home-project-list.jade b/app/modules/home/projects/home-project-list.jade index 0841af36..53336204 100644 --- a/app/modules/home/projects/home-project-list.jade +++ b/app/modules/home/projects/home-project-list.jade @@ -1,13 +1,59 @@ section.home-project-list(ng-if="vm.projects.size") - ul - li.home-project-list-single(tg-bind-scope, tg-repeat="project in vm.projects") - a(href="#", tg-nav="project:project=project.get('slug')") - h2.home-project-list-single-title - span.project-name(title="{{ ::project.get('name') }}") {{::project.get('name')}} - span.private(ng-if="project.get('is_private')", title="{{'PROJECT.PRIVATE' | translate}}") - include ../../../svg/lock.svg - p {{ ::project.get('description') | limitTo:150 }} - span(ng-if="::project.get('description').size > 150") ... + + .home-project(tg-bind-scope, tg-repeat="project in vm.projects") + .tags-container + .project-tag( + style="background: {{tag.get('color')}}" + title="{{tag.get('name')}}" + tg-repeat="tag in project.get('colorized_tags') track by tag.get('name')" + ) + .project-card-inner(href="#", tg-nav="project:project=project.get('slug')") + .project-card-header + a.project-card-logo( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) + img( + tg-project-logo-src="::project" + alt="{{::project.get('name')}}" + ) + h2.project-card-name + a( + href="#" + tg-nav="project:project=project.get('slug')" + title="{{::project.get('name')}}" + ) {{::project.get('name')}} + span.look-for-people( + ng-if="project.get('is_looking_for_people')" + title="{{ ::project.get('looking_for_people_note') }}" + ) + include ../../../svg/recruit.svg + p.project-card-description {{::project.get('description')| limitTo:100 }} + span(ng-if="::project.get('description').length > 100") ... + .project-card-statistics + span.statistic( + ng-class="{'active': project.get('is_fan')}" + title="{{ 'PROJECT.FANS_COUNTER_TITLE'|translate:{total:project.get('total_fans')||0}:'messageformat' }}" + ) + include ../../../svg/like.svg + span {{::project.get('total_fans')}} + span.statistic( + ng-class="{'active': project.get('is_watcher')}" + title="{{ 'PROJECT.WATCHERS_COUNTER_TITLE'|translate:{total:project.get('total_watchers')||0}:'messageformat' }}" + ) + include ../../../svg/eye.svg + span {{::project.get('total_watchers')}} + span.statistic( + title="{{ 'PROJECT.MEMBERS_COUNTER_TITLE'|translate:{total:project.get('members').size||0}:'messageformat' }}" + ) + include ../../../svg/team.svg + span.statistics-num {{ ::project.get('members').size }} + span.statistic( + ng-if="::project.get('is_private')" + title="{{ 'PROJECT.PRIVATE' | translate }}" + ) + include ../../../svg/lock.svg a.see-more-projects-btn.button-gray( href="#", diff --git a/app/modules/home/projects/home-project-list.scss b/app/modules/home/projects/home-project-list.scss index 21d67b31..c73826b0 100644 --- a/app/modules/home/projects/home-project-list.scss +++ b/app/modules/home/projects/home-project-list.scss @@ -1,52 +1,13 @@ -.home-project-list { - li { - border: 1px solid lighten($gray-light, 15%); - border-radius: 3px; - cursor: pointer; - margin-bottom: .75rem; - padding: 1rem; - text-overflow: ellipsis; - &:hover { - border-color: $primary-light; - transition: all .3s linear; - p { - color: $gray; - transition: color .3s linear; - } - .private path { - fill: $gray; - transition: fill .3s linear; - } - } - a { - display: flex; - flex-direction: column; - min-height: 5rem; - } - } - h2 { - @extend %text; - color: $gray; - font-size: 1.5rem; - line-height: 1.3; - margin-bottom: .5rem; - text-transform: none; - .project-name { - display: inline-block; - max-width: 90%; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: middle; - white-space: nowrap; - } - } - p { - @extend %text; - @extend %xsmall; - color: $gray-light; - line-height: 125%; - margin: 0; - word-wrap: break-word; +@import '../../../styles/dependencies/mixins/project-card'; + +.home-project { + @include project-card; + cursor: pointer; + margin-bottom: 1rem; + transition: .2s; + transition-delay: .1s; + &:hover { + border: 1px solid $primary-light; } } diff --git a/app/modules/resources/projects-resource.service.coffee b/app/modules/resources/projects-resource.service.coffee index 35de7d51..4385b90c 100644 --- a/app/modules/resources/projects-resource.service.coffee +++ b/app/modules/resources/projects-resource.service.coffee @@ -22,6 +22,20 @@ pagination = () -> Resource = (urlsService, http, paginateResponseService) -> service = {} + service.getProjects = (params = {}, pagination = true) -> + url = urlsService.resolve("projects") + + httpOptions = {} + + if !pagination + httpOptions = { + headers: { + "x-disable-pagination": "1" + } + } + + return http.get(url, params, httpOptions) + service.getProjectBySlug = (projectSlug) -> url = urlsService.resolve("projects") diff --git a/app/modules/resources/resources.coffee b/app/modules/resources/resources.coffee index b6e8a17f..e8a4c33a 100644 --- a/app/modules/resources/resources.coffee +++ b/app/modules/resources/resources.coffee @@ -25,7 +25,8 @@ services = [ "tgTasksResource", "tgIssuesResource", "tgExternalAppsResource", - "tgAttachmentsResource" + "tgAttachmentsResource", + "tgStatsResource" ] Resources = ($injector) -> diff --git a/app/modules/resources/stats-resource.service.coffee b/app/modules/resources/stats-resource.service.coffee new file mode 100644 index 00000000..2376ae4c --- /dev/null +++ b/app/modules/resources/stats-resource.service.coffee @@ -0,0 +1,34 @@ +### +# Copyright (C) 2014-2015 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: stats-resource.service.coffee +### + +Resource = (urlsService, http) -> + service = {} + + service.discover = (applicationId, state) -> + url = urlsService.resolve("stats-discover") + return http.get(url).then (result) -> + Immutable.fromJS(result.data) + + return () -> + return {"stats": service} + +Resource.$inject = ["$tgUrls", "$tgHttp"] + +module = angular.module("taigaResources2") +module.factory("tgStatsResource", Resource) diff --git a/conf.e2e.js b/conf.e2e.js index e0b11b37..bca55590 100644 --- a/conf.e2e.js +++ b/conf.e2e.js @@ -40,7 +40,8 @@ exports.config = { kanban: "e2e/suites/kanban.e2e.js", projectHome: "e2e/suites/project-home.e2e.js", search: "e2e/suites/search.e2e.js", - team: "e2e/suites/team.e2e.js" + team: "e2e/suites/team.e2e.js", + discover: "e2e/suites/discover/*.e2e.js" }, onPrepare: function() { // track mouse movements diff --git a/e2e/helpers/discover-helper.js b/e2e/helpers/discover-helper.js new file mode 100644 index 00000000..2e6d8cfe --- /dev/null +++ b/e2e/helpers/discover-helper.js @@ -0,0 +1,87 @@ +var utils = require('../utils'); + +var helper = module.exports; + +helper.liked = function() { + return $('tg-most-liked'); +}; + +helper.active = function() { + return $('tg-most-active'); +}; + +helper.featured = function() { + return $('tg-featured-projects'); +}; + +helper.likedProjects = function() { + return helper.liked().$$('.highlighted-project'); +}; + +helper.activeProjects = function() { + return helper.active().$$('.highlighted-project'); +}; + +helper.featuredProjects = function() { + return helper.featured().$$('.featured-project'); +}; + +helper.rearrangeLike = function(index) { + helper.liked().$('.current-filter').click(); + + helper.liked().$$('.filter-list li').get(index).click(); +}; + +helper.getLikeFilterText = function(index) { + return helper.liked().$('.current-filter').getText(); +}; + +helper.rearrangeActive = function(index) { + helper.active().$('.current-filter').click(); + + helper.active().$$('.filter-list li').get(index).click(); +}; + +helper.getActiveFilterText = function(index) { + return helper.active().$('.current-filter').getText(); +}; + +helper.searchFilter = function(index) { + return $$('.searchbox-filters label').get(index).click(); +}; + +helper.searchProjectsList = function() { + return $('.project-list'); +}; + +helper.searchProjects = function() { + return helper.searchProjectsList().$$('li'); +}; + +helper.searchInput = function() { + return $('.searchbox input'); +}; + +helper.sendSearch = function() { + return $('.search-button').click(); +}; + +helper.mostLiked = function() { + $$('.discover-search-filter').get(0).click(); +}; + +helper.mostActived = function() { + $$('.discover-search-filter').get(1).click(); +}; + +helper.searchOrder = function(index) { + $$('.filter-list a').get(index).click(); +}; + +helper.orderSelectorWrapper = function() { + return $('.discover-search-subfilter'); +}; + +helper.clearOrder = function() { + helper.orderSelectorWrapper().$('.results a').click(); +}; diff --git a/e2e/helpers/project-detail-helper.js b/e2e/helpers/project-detail-helper.js new file mode 100644 index 00000000..096dab63 --- /dev/null +++ b/e2e/helpers/project-detail-helper.js @@ -0,0 +1,27 @@ +var utils = require('../utils'); + +var helper = module.exports; + +helper.lookingForPeople = function() { + return $$('.looking-for-people input').get(0); +}; + +helper.lookingForPeopleReason = function() { + return $$('.looking-for-people-reason input').get(0); +}; + +helper.toggleIsLookingForPeople = function() { + helper.lookingForPeople().click(); +}; + +helper.editLogo = function() { + let inputFile = $('#logo-field'); + + var fileToUpload = utils.common.uploadImagePath(); + + return utils.common.uploadFile(inputFile, fileToUpload); +}; + +helper.getLogoSrc = function() { + return $('.image-container .image'); +}; diff --git a/e2e/suites/admin/project/project-detail.e2e.js b/e2e/suites/admin/project/project-detail.e2e.js index 827b562c..2ebb94a1 100644 --- a/e2e/suites/admin/project/project-detail.e2e.js +++ b/e2e/suites/admin/project/project-detail.e2e.js @@ -6,6 +6,8 @@ var chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); var expect = chai.expect; +var adminHelper = require('../../../helpers/project-detail-helper'); + describe('project detail', function() { before(async function(){ browser.get(browser.params.glob.host + 'project/project-0/admin/project-profile/details'); @@ -40,5 +42,40 @@ describe('project detail', function() { $('button[type="submit"]').click(); expect(utils.notifications.success.open()).to.be.eventually.equal(true); + + await utils.notifications.success.close(); + }); + + it('looking for people', async function() { + let checked = !! await adminHelper.lookingForPeople().getAttribute('checked'); + + if(checked) { + adminHelper.toggleIsLookingForPeople(); + } + + adminHelper.toggleIsLookingForPeople(); + + adminHelper.lookingForPeopleReason().sendKeys('looking for people reason'); + + $('button[type="submit"]').click(); + + checked = !! await adminHelper.lookingForPeople().getAttribute('checked'); + + expect(checked).to.be.true; + expect(utils.notifications.success.open()).to.be.eventually.equal(true); + }); + + it('edit logo', async function() { + let imageContainer = $('.image-container'); + + let htmlChanges = await utils.common.outerHtmlChanges(imageContainer); + + adminHelper.editLogo(); + + await htmlChanges(); + + let src = await adminHelper.getLogoSrc().getAttribute('src'); + + expect(src).to.contains('upload-image-test.png'); }); }); diff --git a/e2e/suites/discover/discover-home.e2e.js b/e2e/suites/discover/discover-home.e2e.js new file mode 100644 index 00000000..96f1900b --- /dev/null +++ b/e2e/suites/discover/discover-home.e2e.js @@ -0,0 +1,63 @@ +var utils = require('../../utils'); +var discoverHelper = require('../../helpers/discover-helper'); + +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); +var expect = chai.expect; + + +describe('discover', () => { + before(async () => { + browser.get(browser.params.glob.host + 'discover'); + await utils.common.waitLoader(); + }); + + it('screenshot', async () => { + await utils.common.takeScreenshot("discover", "discover-home"); + }); + + describe('most liked', () => { + it('has projects', () => { + let projects = discoverHelper.likedProjects(); + + expect(projects.count()).to.be.eventually.above(0); + }); + + it('rearrange', () => { + discoverHelper.rearrangeLike(3); + + let filterText = discoverHelper.getLikeFilterText(); + let projects = discoverHelper.likedProjects(); + + expect(filterText).to.be.eventually.equal('All time'); + expect(projects.count()).to.be.eventually.equal(5); + + }); + }); + + describe('most active', () => { + it('has projects', () => { + let projects = discoverHelper.activeProjects(); + + expect(projects.count()).to.be.eventually.above(0); + }); + + it('rearrange', () => { + discoverHelper.rearrangeActive(3); + + let filterText = discoverHelper.getActiveFilterText(); + let projects = discoverHelper.activeProjects(); + + expect(filterText).to.be.eventually.equal('All time'); + expect(projects.count()).to.be.eventually.equal(5); + }); + }); + + it('featured projects', () => { + let projects = discoverHelper.featuredProjects(); + + expect(projects.count()).to.be.eventually.above(0); + }); +}); diff --git a/e2e/suites/discover/discover-search.e2e.js b/e2e/suites/discover/discover-search.e2e.js new file mode 100644 index 00000000..b51f3a45 --- /dev/null +++ b/e2e/suites/discover/discover-search.e2e.js @@ -0,0 +1,123 @@ +var utils = require('../../utils'); +var discoverHelper = require('../../helpers/discover-helper'); + +var chai = require('chai'); +var chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); +var expect = chai.expect; + + +describe('discover search', () => { + before(async () => { + browser.get(browser.params.glob.host + 'discover/search'); + await utils.common.waitLoader(); + }); + + it('screenshot', async () => { + await utils.common.takeScreenshot("discover", "discover-search"); + }); + + describe('top bar', async () => { + after(async () => { + browser.get(browser.params.glob.host + 'discover/search'); + await utils.common.waitLoader(); + }); + + it('filters', async () => { + let htmlChanges = await utils.common.outerHtmlChanges(discoverHelper.searchProjectsList()); + + discoverHelper.searchFilter(1); + + await htmlChanges(); + + let url = await browser.getCurrentUrl(); + + let projects = discoverHelper.searchProjects(); + + expect(projects.count()).to.be.eventually.above(0); + expect(url).to.be.equal(browser.params.glob.host + 'discover/search?filter=kanban'); + }); + + it('search by text', () => { + discoverHelper.searchInput().sendKeys('Project Example 0'); + + discoverHelper.sendSearch(); + + let projects = discoverHelper.searchProjects(); + expect(projects.count()).to.be.eventually.equal(1); + }); + }); + + describe('most liked', async () => { + after(async () => { + browser.get(browser.params.glob.host + 'discover/search'); + await utils.common.waitLoader(); + }); + + it('default', async () => { + discoverHelper.mostLiked(); + + utils.common.takeScreenshot("discover", "discover-search-filter"); + + let url = await browser.getCurrentUrl(); + + expect(url).to.be.equal(browser.params.glob.host + 'discover/search?order_by=-total_fans_last_week'); + }); + + it('filter', async () => { + discoverHelper.searchOrder(3); + + let projects = discoverHelper.searchProjects(); + + let url = await browser.getCurrentUrl(); + + expect(projects.count()).to.be.eventually.above(0); + expect(url).to.be.equal(browser.params.glob.host + 'discover/search?order_by=-total_fans'); + }); + + it('clear', () => { + discoverHelper.clearOrder(); + + let orderSelector = discoverHelper.orderSelectorWrapper(); + + expect(orderSelector.isPresent()).to.be.eventually.equal(false); + }); + }); + + describe('most active', async () => { + after(async () => { + browser.get(browser.params.glob.host + 'discover/search'); + await utils.common.waitLoader(); + }); + + it('default', async () => { + discoverHelper.mostActived(); + + utils.common.takeScreenshot("discover", "discover-search-filter"); + + let url = await browser.getCurrentUrl(); + + expect(url).to.be.equal(browser.params.glob.host + 'discover/search?order_by=-total_activity_last_week'); + }); + + it('filter', async () => { + discoverHelper.searchOrder(3); + + let projects = discoverHelper.searchProjects(); + + let url = await browser.getCurrentUrl(); + + expect(projects.count()).to.be.eventually.above(0); + expect(url).to.be.equal(browser.params.glob.host + 'discover/search?order_by=-total_activity'); + }); + + it('clear', () => { + discoverHelper.clearOrder(); + + let orderSelector = discoverHelper.orderSelectorWrapper(); + + expect(orderSelector.isPresent()).to.be.eventually.equal(false); + }); + }); +}); diff --git a/e2e/utils/common.js b/e2e/utils/common.js index a0f9511d..c1c4e468 100644 --- a/e2e/utils/common.js +++ b/e2e/utils/common.js @@ -163,7 +163,7 @@ common.prepare = function() { browser.get(browser.params.glob.host); return common.closeCookies(); -} +}; common.dragEnd = function(elm) { return browser.wait(async function() { diff --git a/run-e2e.js b/run-e2e.js index 0c2ae066..33af48d8 100644 --- a/run-e2e.js +++ b/run-e2e.js @@ -17,7 +17,8 @@ var suites = [ 'kanban', 'projectHome', 'search', - 'team' + 'team', + 'discover' ]; var lunchSuites = []; From 40418839b2daabf47b170165e0c9f824d393df0b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 21 Jan 2016 18:38:58 +0100 Subject: [PATCH 2/4] US #2127: Discover section [2] --- app/coffee/app.coffee | 29 +++++++-- .../modules/admin/project-profile.coffee | 61 +++++++++++++++++++ app/coffee/modules/base.coffee | 3 + app/coffee/modules/base/navurls.coffee | 4 +- app/coffee/modules/common/loading.coffee | 2 +- app/coffee/modules/resources.coffee | 3 + app/coffee/modules/resources/projects.coffee | 25 ++++++++ app/coffee/modules/user-settings/main.coffee | 10 +-- app/coffee/utils.coffee | 8 +++ .../dropdown-user.directive.coffee | 3 +- .../dropdown-user.directive.spec.coffee | 15 +++-- .../navigation-bar.directive.coffee | 20 ++++-- .../navigation-bar.directive.spec.coffee | 45 ++++++++++++++ .../navigation-bar/navigation-bar.jade | 21 ++++--- 14 files changed, 221 insertions(+), 28 deletions(-) diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 24f955e1..54ff9b5d 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -66,9 +66,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven $routeProvider.when("/", { templateUrl: "home/home.html", - access: { - requiresLogin: true - }, + controller: "Home", + controllerAs: "vm" loader: true, title: "HOME.PAGE_TITLE", loader: true, @@ -77,6 +76,27 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven } ) + $routeProvider.when("/discover", + { + templateUrl: "discover/discover-home/discover-home.html", + controller: "DiscoverHome", + controllerAs: "vm", + title: "PROJECT.NAVIGATION.DISCOVER", + loader: true + } + ) + + $routeProvider.when("/discover/search", + { + templateUrl: "discover/discover-search/discover-search.html", + title: "PROJECT.NAVIGATION.DISCOVER", + loader: true, + controller: "DiscoverSearch", + controllerAs: "vm", + reloadOnSearch: false + } + ) + $routeProvider.when("/projects/", { templateUrl: "projects/listing/projects-listing.html", @@ -577,7 +597,7 @@ init = ($log, $rootscope, $auth, $events, $analytics, $translate, $location, $na $rootscope.$evalAsync(cb) $events.setupConnection() - + # Load user if $auth.isAuthenticated() user = $auth.getUser() @@ -664,6 +684,7 @@ modules = [ "taigaHome", "taigaUserTimeline", "taigaExternalApps", + "taigaDiscover", # template cache "templates", diff --git a/app/coffee/modules/admin/project-profile.coffee b/app/coffee/modules/admin/project-profile.coffee index 74055a96..4a6bee65 100644 --- a/app/coffee/modules/admin/project-profile.coffee +++ b/app/coffee/modules/admin/project-profile.coffee @@ -449,3 +449,64 @@ CsvIssueDirective = ($translate) -> } module.directive("tgCsvIssue", ["$translate", CsvIssueDirective]) + + +############################################################################# +## Project Logo Directive +############################################################################# + +ProjectLogoDirective = ($auth, $model, $rs, $confirm) -> + link = ($scope, $el, $attrs) -> + showSizeInfo = -> + $el.find(".size-info").addClass("active") + + onSuccess = (response) -> + project = $model.make_model("projects", response.data) + $scope.project = project + + $el.find('.loading-overlay').removeClass('active') + $confirm.notify('success') + + onError = (response) -> + showSizeInfo() if response.status == 413 + $el.find('.loading-overlay').removeClass('active') + $confirm.notify('error', response.data._error_message) + + # Change photo + $el.on "click", ".js-change-logo", -> + $el.find("#logo-field").click() + + $el.on "change", "#logo-field", (event) -> + if $scope.logoAttachment + $el.find('.loading-overlay').addClass("active") + $rs.projects.changeLogo($scope.project.id, $scope.logoAttachment).then(onSuccess, onError) + + # Use default photo + $el.on "click", "a.js-use-default-logo", (event) -> + $el.find('.loading-overlay').addClass("active") + $rs.projects.removeLogo($scope.project.id).then(onSuccess, onError) + + $scope.$on "$destroy", -> + $el.off() + + return {link:link} + +module.directive("tgProjectLogo", ["$tgAuth", "$tgModel", "$tgResources", "$tgConfirm", ProjectLogoDirective]) + + +############################################################################# +## Project Logo Model Directive +############################################################################# + +ProjectLogoModelDirective = ($parse) -> + link = ($scope, $el, $attrs) -> + model = $parse($attrs.tgProjectLogoModel) + modelSetter = model.assign + + $el.bind 'change', -> + $scope.$apply -> + modelSetter($scope, $el[0].files[0]) + + return {link:link} + +module.directive('tgProjectLogoModel', ['$parse', ProjectLogoModelDirective]) diff --git a/app/coffee/modules/base.coffee b/app/coffee/modules/base.coffee index d45b879f..feeb02f8 100644 --- a/app/coffee/modules/base.coffee +++ b/app/coffee/modules/base.coffee @@ -52,6 +52,9 @@ urls = { "not-found": "/not-found" "permission-denied": "/permission-denied" + "discover": "/discover" + "discover-search": "/discover/search" + "login": "/login" "forgot-password": "/forgot-password" "change-password": "/change-password/:token" diff --git a/app/coffee/modules/base/navurls.coffee b/app/coffee/modules/base/navurls.coffee index 3885b013..85fee430 100644 --- a/app/coffee/modules/base/navurls.coffee +++ b/app/coffee/modules/base/navurls.coffee @@ -117,7 +117,7 @@ NavigationUrlsDirective = ($navurls, $auth, $q, $location) -> $el.on "mouseenter", (event) -> target = $(event.currentTarget) - if !target.data("fullUrl") + if !target.data("fullUrl") || $attrs.tgNavGetParams != target.data("params") parseNav($attrs.tgNav, $scope).then (result) -> [name, options] = result user = $auth.getUser() @@ -131,6 +131,8 @@ NavigationUrlsDirective = ($navurls, $auth, $q, $location) -> getURLParamsStr = $.param(getURLParams) fullUrl = "#{fullUrl}?#{getURLParamsStr}" + target.data("params", $attrs.tgNavGetParams) + target.data("fullUrl", fullUrl) if target.is("a") diff --git a/app/coffee/modules/common/loading.coffee b/app/coffee/modules/common/loading.coffee index 4d58b052..2c128ba9 100644 --- a/app/coffee/modules/common/loading.coffee +++ b/app/coffee/modules/common/loading.coffee @@ -112,7 +112,7 @@ LoadingDirective = ($loading) -> if showLoading currentLoading = $loading() .target($el) - .timeout(50) + .timeout(100) .template(template) .scope($scope) .start() diff --git a/app/coffee/modules/resources.coffee b/app/coffee/modules/resources.coffee index 77677ca7..fda8262b 100644 --- a/app/coffee/modules/resources.coffee +++ b/app/coffee/modules/resources.coffee @@ -175,6 +175,9 @@ urls = { # Application tokens "applications": "/applications" "application-tokens": "/application-tokens" + + # Stats + "stats-discover": "/stats/discover" } # Initialize api urls service diff --git a/app/coffee/modules/resources/projects.coffee b/app/coffee/modules/resources/projects.coffee index cfb758d1..a1745420 100644 --- a/app/coffee/modules/resources/projects.coffee +++ b/app/coffee/modules/resources/projects.coffee @@ -153,6 +153,31 @@ resourceProvider = ($config, $repo, $http, $urls, $auth, $q, $translate) -> return defered.promise + service.changeLogo = (projectId, file) -> + maxFileSize = $config.get("maxUploadFileSize", null) + if maxFileSize and file.size > maxFileSize + response = { + status: 413, + data: _error_message: "'#{file.name}' (#{sizeFormat(file.size)}) is too heavy for our oompa + loompas, try it with a smaller than (#{sizeFormat(maxFileSize)})" + } + defered = $q.defer() + defered.reject(response) + return defered.promise + + data = new FormData() + data.append('logo', file) + options = { + transformRequest: angular.identity, + headers: {'Content-Type': undefined} + } + url = "#{$urls.resolve("projects")}/#{projectId}/change_logo" + return $http.post(url, data, {}, options) + + service.removeLogo = (projectId) -> + url = "#{$urls.resolve("projects")}/#{projectId}/remove_logo" + return $http.post(url) + return (instance) -> instance.projects = service diff --git a/app/coffee/modules/user-settings/main.coffee b/app/coffee/modules/user-settings/main.coffee index 8b203aa5..98348150 100644 --- a/app/coffee/modules/user-settings/main.coffee +++ b/app/coffee/modules/user-settings/main.coffee @@ -148,12 +148,12 @@ UserAvatarDirective = ($auth, $model, $rs, $confirm) -> $auth.setUser(user) $scope.user = user - $el.find('.overlay').addClass('hidden') + $el.find('.loading-overlay').removeClass('active') $confirm.notify('success') onError = (response) -> showSizeInfo() if response.status == 413 - $el.find('.overlay').addClass('hidden') + $el.find('.loading-overlay').removeClass('active') $confirm.notify('error', response.data._error_message) # Change photo @@ -162,12 +162,12 @@ UserAvatarDirective = ($auth, $model, $rs, $confirm) -> $el.on "change", "#avatar-field", (event) -> if $scope.avatarAttachment - $el.find('.overlay').removeClass('hidden') + $el.find('.loading-overlay').addClass("active") $rs.userSettings.changeAvatar($scope.avatarAttachment).then(onSuccess, onError) # Use gravatar photo - $el.on "click", "a.use-gravatar", (event) -> - $el.find('.overlay').removeClass('hidden') + $el.on "click", "a.js-use-gravatar", (event) -> + $el.find('.loading-overlay').addClass("active") $rs.userSettings.removeAvatar().then(onSuccess, onError) $scope.$on "$destroy", -> diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee index 81276e6d..6ced2cc5 100644 --- a/app/coffee/utils.coffee +++ b/app/coffee/utils.coffee @@ -195,6 +195,14 @@ _.mixin delete obj[key]; obj , obj).value() + cartesianProduct: -> + _.reduceRight( + arguments, (a,b) -> + _.flatten(_.map(a, (x) -> _.map b, (y) -> [y].concat(x)), true) + , [ [] ]) + + + isImage = (name) -> return name.match(/\.(jpe?g|png|gif|gifv|webm)/i) != null diff --git a/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.coffee b/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.coffee index aeac042e..ac09365c 100644 --- a/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.coffee +++ b/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.coffee @@ -27,7 +27,8 @@ DropdownUserDirective = (authService, configService, locationService, scope.vm.logout = -> authService.logout() - locationService.path(navUrlsService.resolve("login")) + locationService.url(navUrlsService.resolve("discover")) + locationService.search({}) scope.vm.sendFeedback = -> feedbackService.sendFeedback() diff --git a/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.spec.coffee b/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.spec.coffee index d85e69fd..87287d6a 100644 --- a/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.spec.coffee +++ b/app/modules/navigation-bar/dropdown-user/dropdown-user.directive.spec.coffee @@ -50,8 +50,10 @@ describe "dropdownUserDirective", () -> _mockTgLocation = () -> mockTgLocation = { - path: sinon.stub() + url: sinon.stub() + search: sinon.stub() } + provide.value "$tgLocation", mockTgLocation _mockTgNavUrls = () -> @@ -97,16 +99,19 @@ describe "dropdownUserDirective", () -> expect(vm.isFeedbackEnabled).to.be.equal(true) it "dropdown user log out", () -> - mockTgNavUrls.resolve.withArgs("login").returns("/login") + mockTgNavUrls.resolve.withArgs("discover").returns("/discover") elm = createDirective() scope.$apply() vm = elm.isolateScope().vm expect(mockTgAuth.logout.callCount).to.be.equal(0) - expect(mockTgLocation.path.callCount).to.be.equal(0) + expect(mockTgLocation.url.callCount).to.be.equal(0) + expect(mockTgLocation.search.callCount).to.be.equal(0) vm.logout() expect(mockTgAuth.logout.callCount).to.be.equal(1) - expect(mockTgLocation.path.callCount).to.be.equal(1) - expect(mockTgLocation.path.calledWith("/login")).to.be.true + expect(mockTgLocation.url.callCount).to.be.equal(1) + expect(mockTgLocation.search.callCount).to.be.equal(1) + expect(mockTgLocation.url.calledWith("/discover")).to.be.true + expect(mockTgLocation.search.calledWith({})).to.be.true it "dropdown user send feedback", () -> elm = createDirective() diff --git a/app/modules/navigation-bar/navigation-bar.directive.coffee b/app/modules/navigation-bar/navigation-bar.directive.coffee index 67d4589d..c32a792f 100644 --- a/app/modules/navigation-bar/navigation-bar.directive.coffee +++ b/app/modules/navigation-bar/navigation-bar.directive.coffee @@ -17,12 +17,14 @@ # File: navigation-bar.directive.coffee ### -NavigationBarDirective = (currentUserService, navigationBarService, $location) -> +NavigationBarDirective = (currentUserService, navigationBarService, + locationService, navUrlsService) -> + link = (scope, el, attrs, ctrl) -> scope.vm = {} scope.$on "$routeChangeSuccess", () -> - if $location.path() == "/" + if locationService.path() == "/" scope.vm.active = true else scope.vm.active = false @@ -31,6 +33,15 @@ NavigationBarDirective = (currentUserService, navigationBarService, $location) - taiga.defineImmutableProperty(scope.vm, "isAuthenticated", () -> currentUserService.isAuthenticated()) taiga.defineImmutableProperty(scope.vm, "isEnabledHeader", () -> navigationBarService.isEnabledHeader()) + scope.vm.login = -> + nextUrl = encodeURIComponent(locationService.url()) + locationService.url(navUrlsService.resolve("login")) + locationService.search({next: nextUrl}) + + scope.vm.register = -> + nextUrl = encodeURIComponent(locationService.url()) + locationService.url(navUrlsService.resolve("register")) + locationService.search({next: nextUrl}) directive = { templateUrl: "navigation-bar/navigation-bar.html" @@ -42,8 +53,9 @@ NavigationBarDirective = (currentUserService, navigationBarService, $location) - NavigationBarDirective.$inject = [ "tgCurrentUserService", - "tgNavigationBarService" - "$location" + "tgNavigationBarService", + "$tgLocation", + "$tgNavUrls" ] angular.module("taigaNavigationBar").directive("tgNavigationBar", NavigationBarDirective) diff --git a/app/modules/navigation-bar/navigation-bar.directive.spec.coffee b/app/modules/navigation-bar/navigation-bar.directive.spec.coffee index 7c3251bc..36041e2f 100644 --- a/app/modules/navigation-bar/navigation-bar.directive.spec.coffee +++ b/app/modules/navigation-bar/navigation-bar.directive.spec.coffee @@ -41,6 +41,19 @@ describe "navigationBarDirective", () -> provide.value "tgCurrentUserService", mocks.currentUserService + _mocksLocationService = () -> + mocks.locationService = { + url: sinon.stub() + search: sinon.stub() + } + + provide.value "$tgLocation", mocks.locationService + + _mockTgNavUrls = () -> + mocks.navUrls = { + resolve: sinon.stub() + } + provide.value "$tgNavUrls", mocks.navUrls _mockTranslateFilter = () -> mockTranslateFilter = (value) -> @@ -58,6 +71,8 @@ describe "navigationBarDirective", () -> provide = $provide _mocksCurrentUserService() + _mocksLocationService() + _mockTgNavUrls( ) _mockTranslateFilter() _mockTgDropdownProjectListDirective() _mockTgDropdownUserDirective() @@ -90,3 +105,33 @@ describe "navigationBarDirective", () -> mocks.currentUserService.isAuthenticated.returns(true) expect(elm.isolateScope().vm.isAuthenticated).to.be.true + + it "navigation bar login", () -> + mocks.navUrls.resolve.withArgs("login").returns("/login") + nextUrl = "/discover/search?order_by=-total_activity_last_month" + mocks.locationService.url.returns(nextUrl) + elm = createDirective() + scope.$apply() + vm = elm.isolateScope().vm + expect(mocks.locationService.url.callCount).to.be.equal(0) + expect(mocks.locationService.search.callCount).to.be.equal(0) + vm.login() + expect(mocks.locationService.url.callCount).to.be.equal(2) + expect(mocks.locationService.search.callCount).to.be.equal(1) + expect(mocks.locationService.url.calledWith("/login")).to.be.true + expect(mocks.locationService.search.calledWith({next: encodeURIComponent(nextUrl)})).to.be.true + + it "navigation bar register", () -> + mocks.navUrls.resolve.withArgs("register").returns("/register") + nextUrl = "/discover/search?order_by=-total_activity_last_month" + mocks.locationService.url.returns(nextUrl) + elm = createDirective() + scope.$apply() + vm = elm.isolateScope().vm + expect(mocks.locationService.url.callCount).to.be.equal(0) + expect(mocks.locationService.search.callCount).to.be.equal(0) + vm.register() + expect(mocks.locationService.url.callCount).to.be.equal(2) + expect(mocks.locationService.search.callCount).to.be.equal(1) + expect(mocks.locationService.url.calledWith("/register")).to.be.true + expect(mocks.locationService.search.calledWith({next: encodeURIComponent(nextUrl)})).to.be.true diff --git a/app/modules/navigation-bar/navigation-bar.jade b/app/modules/navigation-bar/navigation-bar.jade index 54aa167e..83f49a36 100644 --- a/app/modules/navigation-bar/navigation-bar.jade +++ b/app/modules/navigation-bar/navigation-bar.jade @@ -3,7 +3,7 @@ nav.navbar(ng-if="vm.isEnabledHeader") a.logo( href="#", tg-nav="home", - title="{{'PROJECT.NAVIGATION.DASHBOARD_TITLE' | translate}}") + title="{{'PROJECT.NAVIGATION.HOMEPAGE' | translate}}") include ../../svg/logo.svg @@ -15,16 +15,16 @@ nav.navbar(ng-if="vm.isEnabledHeader") div.nav-right(ng-if="!vm.isAuthenticated") a.login( - tg-nav="login", + ng-click="vm.login()" href="#", - title="{{ 'LOGIN_COMMON.ACTION_SIGN_IN' | translate }}" + title="{{ 'LOGIN_COMMON.ACTION_SIGN_IN' | translate }}" ) {{ 'LOGIN_COMMON.ACTION_SIGN_IN' | translate }} a.register( - tg-nav="register", + ng-click="vm.register()" href="#", - title="{{ 'REGISTER_FORM.ACTION_SIGN_UP' | translate }}" + title="{{ 'REGISTER_FORM.ACTION_SIGN_UP' | translate }}" ) {{ 'REGISTER_FORM.ACTION_SIGN_UP' | translate }} - + div.nav-right(ng-if="vm.isAuthenticated") a(tg-nav="home", ng-class="{active: vm.active}", @@ -32,6 +32,13 @@ nav.navbar(ng-if="vm.isEnabledHeader") include ../../svg/dashboard.svg + a( + href="#", + tg-nav="discover", + title="{{'PROJECT.NAVIGATION.DISCOVER_TITLE' | translate}}", + ) + include ../../svg/discover.svg + div.topnav-dropdown-wrapper(ng-show="vm.projects.size", tg-dropdown-project-list) - //div.topnav-dropdown-wrapper(tg-dropdown-organization-list) + //- div.topnav-dropdown-wrapper(tg-dropdown-organization-list) div.topnav-dropdown-wrapper(tg-dropdown-user) From 555a011cb559bce8916bb8da5df3f39337c03ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 21 Jan 2016 18:40:17 +0100 Subject: [PATCH 3/4] US #2127: Discover section [3] --- app/js/murmurhash3_gc.js | 89 +++++++++++++++++++ .../project-logo-src.directive.coffee | 77 ++++++++++++++++ gulpfile.js | 3 +- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 app/js/murmurhash3_gc.js create mode 100644 app/modules/components/project-logo-src/project-logo-src.directive.coffee diff --git a/app/js/murmurhash3_gc.js b/app/js/murmurhash3_gc.js new file mode 100644 index 00000000..57e0dfb6 --- /dev/null +++ b/app/js/murmurhash3_gc.js @@ -0,0 +1,89 @@ +/** + * JS Implementation of MurmurHash3 (r136) (as of May 20, 2011) + * + * Copyright (c) 2011 Gary Court + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * @author Gary Court + * @see http://github.com/garycourt/murmurhash-js + * @author Austin Appleby + * @see http://sites.google.com/site/murmurhash/ + * + * @param {string} key ASCII only + * @param {number} seed Positive integer only + * @return {number} 32-bit positive integer hash + */ + +function murmurhash3_32_gc(key, seed) { + var remainder, bytes, h1, h1b, c1, c1b, c2, c2b, k1, i; + + remainder = key.length & 3; // key.length % 4 + bytes = key.length - remainder; + h1 = seed; + c1 = 0xcc9e2d51; + c2 = 0x1b873593; + i = 0; + + while (i < bytes) { + k1 = + ((key.charCodeAt(i) & 0xff)) | + ((key.charCodeAt(++i) & 0xff) << 8) | + ((key.charCodeAt(++i) & 0xff) << 16) | + ((key.charCodeAt(++i) & 0xff) << 24); + ++i; + + k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff; + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); + h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff; + h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16)); + } + + k1 = 0; + + switch (remainder) { + case 3: k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; + case 2: k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; + case 1: k1 ^= (key.charCodeAt(i) & 0xff); + + k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + h1 ^= k1; + } + + h1 ^= key.length; + + h1 ^= h1 >>> 16; + h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff; + h1 ^= h1 >>> 13; + h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff; + h1 ^= h1 >>> 16; + + return h1 >>> 0; +} + + diff --git a/app/modules/components/project-logo-src/project-logo-src.directive.coffee b/app/modules/components/project-logo-src/project-logo-src.directive.coffee new file mode 100644 index 00000000..2b7b8804 --- /dev/null +++ b/app/modules/components/project-logo-src/project-logo-src.directive.coffee @@ -0,0 +1,77 @@ +### +# Copyright (C) 2014-2016 Taiga Agile LLC +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +# File: project-logo.directive.coffee +### + + +IMAGES = [ + "/#{window._version}/images/project-logos/project-logo-01.png" + "/#{window._version}/images/project-logos/project-logo-02.png" + "/#{window._version}/images/project-logos/project-logo-03.png" + "/#{window._version}/images/project-logos/project-logo-04.png" + "/#{window._version}/images/project-logos/project-logo-05.png" +] + +COLORS = [ + "rgba( 153, 214, 220, 1 )" + "rgba( 213, 156, 156, 1 )" + "rgba( 214, 161, 212, 1 )" + "rgba( 164, 162, 219, 1 )" + "rgba( 152, 224, 168, 1 )" +] + +LOGOS = _.cartesianProduct(IMAGES, COLORS) + + +ProjectLogoSrcDirective = ($parse) -> + _getDefaultProjectLogo = (project) -> + key = "#{project.get("slug")}-#{project.get("id")}" + idx = murmurhash3_32_gc(key, 42) %% LOGOS.length + logo = LOGOS[idx] + + return { src: logo[0], color: logo[1] } + + link = (scope, el, attrs) -> + scope.$watch "project", (project) -> + project = Immutable.fromJS(project) # Necesary for old code + + return if not project + + projectLogo = project.get('logo_small_url') + + if projectLogo + el.attr("src", projectLogo) + el.css('background', "") + else + logo = _getDefaultProjectLogo(project) + el.attr("src", logo.src) + el.css('background', logo.color) + + scope.$on "$destroy", -> el.off() + + return { + link: link + scope: { + project: "=tgProjectLogoSrc" + } + } + +ProjectLogoSrcDirective.$inject = [ + "$parse" +] + +angular.module("taigaComponents").directive("tgProjectLogoSrc", ProjectLogoSrcDirective) diff --git a/gulpfile.js b/gulpfile.js index 61db5d31..bb3d9a71 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -173,7 +173,8 @@ paths.libs = [ paths.app + "js/jquery-ui.drag-multiple-custom.js", paths.app + "js/jquery.ui.touch-punch.min.js", paths.app + "js/tg-repeat.js", - paths.app + "js/sha1-custom.js" + paths.app + "js/sha1-custom.js", + paths.app + "js/murmurhash3_gc.js" ]; var isDeploy = argv["_"].indexOf("deploy") !== -1; From 5b2f5233c4da1286720fabca218068ae2d38b270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Xavier=20Juli=C3=A1n?= Date: Thu, 21 Jan 2016 18:42:12 +0100 Subject: [PATCH 4/4] US #2127: Discover section [4] --- CHANGELOG.md | 21 +- app/images/discover.png | Bin 0 -> 19838 bytes app/images/looking-for-people.png | Bin 0 -> 18714 bytes app/images/project-logos/project-logo-01.png | Bin 0 -> 1167 bytes app/images/project-logos/project-logo-02.png | Bin 0 -> 1297 bytes app/images/project-logos/project-logo-03.png | Bin 0 -> 1030 bytes app/images/project-logos/project-logo-04.png | Bin 0 -> 1467 bytes app/images/project-logos/project-logo-05.png | Bin 0 -> 1687 bytes app/locales/taiga/locale-en.json | 48 +++- .../components/vote-button/vote-button.jade | 1 - .../profile/profile-favs/items/project.jade | 25 ++- .../profile-projects/profile-projects.jade | 27 ++- .../profile/styles/profile-content-tabs.scss | 2 +- .../projects/listing/projects-listing.jade | 68 ++++-- .../listing/styles/profile-projects.scss | 6 + .../projects/listing/styles/project-list.scss | 7 + app/modules/projects/project/project.jade | 100 +++++---- app/partials/admin/admin-project-profile.jade | 164 +++++++++++--- .../includes/components/mainTitle.jade | 6 +- app/partials/user/mail-notifications.jade | 8 +- app/partials/user/user-change-password.jade | 2 - app/partials/user/user-profile.jade | 211 +++++++++--------- app/styles/components/buttons.scss | 1 + app/styles/components/list-items.scss | 11 + app/styles/components/private.scss | 3 +- app/styles/components/tag.scss | 5 +- app/styles/core/elements.scss | 8 + app/styles/core/typography.scss | 10 +- app/styles/dependencies/mixins.scss | 23 +- .../dependencies/mixins/profile-form.scss | 73 ++++++ .../dependencies/mixins/project-card.scss | 75 +++++++ app/styles/layout/auth.scss | 6 +- app/styles/layout/ticket-detail.scss | 15 +- .../modules/admin/admin-project-profile.scss | 148 +++++++----- .../modules/backlog/taskboard-table.scss | 4 +- app/styles/modules/home-project.scss | 36 ++- app/styles/modules/kanban/kanban-table.scss | 4 +- .../modules/user-settings/user-profile.scss | 81 +------ app/svg/activity.svg | 5 + app/svg/discover.svg | 3 + app/svg/help.svg | 4 + app/svg/recruit.svg | 4 + app/svg/search.svg | 5 + app/svg/team.svg | 4 + app/themes/taiga/variables.scss | 1 - 45 files changed, 829 insertions(+), 396 deletions(-) create mode 100644 app/images/discover.png create mode 100644 app/images/looking-for-people.png create mode 100644 app/images/project-logos/project-logo-01.png create mode 100644 app/images/project-logos/project-logo-02.png create mode 100644 app/images/project-logos/project-logo-03.png create mode 100644 app/images/project-logos/project-logo-04.png create mode 100644 app/images/project-logos/project-logo-05.png create mode 100644 app/styles/dependencies/mixins/profile-form.scss create mode 100644 app/styles/dependencies/mixins/project-card.scss create mode 100644 app/svg/activity.svg create mode 100644 app/svg/discover.svg create mode 100644 app/svg/help.svg create mode 100644 app/svg/recruit.svg create mode 100644 app/svg/search.svg create mode 100644 app/svg/team.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8d388f..6280b4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,26 @@ ## 1.10.0 ??? (unreleased) ### Features -- Upload attachments on US/issue/task lightbox. -- Attachments image gallery view mode in detail pages. -- Drag files from desktop to attachments section. -- Drag files from desktop in wysiwyg textareas. - New design for the detail pages slidebar. -- Sticky project navigation bar. - Added 'Assign to me' button in User Stories, Tasks and Issues detail pages. (thanks to [@allistera](https://github.com/allistera)). +- Attachments: + - Upload attachments on US/issue/task lightbox. + - Attachments image gallery view mode in detail pages. + - Drag files from desktop to attachments section. + - Drag files from desktop in wysiwyg textareas. +- Project: + - Add a logo to your project. + - Denotes that your project is looking for people and add an explanation. +- Discover section: + - List most liked and most active project (last week/month/year or all time). + - List featured project. + - Search projects: + - Full text search with priorities over title, tags and description fields. + - Order results alphabeticaly, by most liked or more actived. + - Filter by 'use kanban', 'use scrum' or 'looking for people'. ### Misc +- Sticky project navigation bar. - Lots of small and not so small bugfixes. diff --git a/app/images/discover.png b/app/images/discover.png new file mode 100644 index 0000000000000000000000000000000000000000..04568fc67ba387709dfd8951307615c90e4e3e1e GIT binary patch literal 19838 zcmX`TcT^MI_dPrm0Y$n9QUoH!0MbHlQi4j97NmvVLhrpJ2nH!Cgc6V<8mXa3??nND z0D=%iqzIBwq-a1oyo=}a`@U<<{BtwsW%10f+H5^mle0gm>*&JsTUE_qw3TmZli=t4Cghra$j7iMQ=vPZkK-6E+w zi*BqRX;-*>$ME{4VUWt_&ZN(v&!8))lgBT4sV$r`b*QPSsb@Io=@PVNz=r>LHI3q7 znYZ0swmKwrIf}kG5epXh$f5!xGQ*b0^y9-9nEfracZ2u{(p&_iokf=O#bF|mF3|YZY}zAH;s5L_5zcH+{`W_wQ-7n*m4VrRWP)7hwhv#3>i^ms@WPaL z{&#Wty9V_FOpI*z>|TFP1c$h}lpLYxzg~QB&(WhsTo}lCJ^=4$!ENRU1bO8(^U5Ko zx*6V_!@mNI;c#y<^RU|GcKL6`#%R2th~Iu6~D-bO@Qn^`JYh%$(k?Zk!culBu~Np zN3&l5s`RxmzY-7;UZ5cIyZL`ZOr&oEeYr5N%$>RzT;bT=*t3jSz_)=A-lpjIVdx`% zZ+YNFR})^4-_V>PJVrimP8|P##%y_X^%s6BkaF_!HcWP8O~x`8A|^jS-+1ZypeQe7 z+6}i&vxB?x453T+CujTE_R1}Z|4sYP{{J^k<+|`8Hh{U;h&@zD*(0&>*(ut2JdMV9lPHmoIqpqwAbljWTG|Jy6gcn04G9R*!3@tpbgEmQrp6L++b5yR<_0h&^}cSOqM1dddjP){qn=vw6?YiKLC&Yjp`CwGZ!Czo(*?51BJiB z*ur69x&w|Drhk6-)%+K0a((-!lJo+|B{E}A-**FWkjXV^3v33w6|c(CJ$)+|bmwWP zjz>oXboqrVVk6VP<;PcO;1g-0GkB%`o#O{_v74|m20#@kqUc5dH$A<)ydKDl4u5$5 zf5AC*bz$E!McRPURg@G7Qa1I7vj1#vA@n!ud>FdRMN_@anRpq0fIEt-1AcRI%*~V<4L_!6 z=Iwsfxo!K(SJ5=J7UMWvP6;bB&QZ$HFWJ^tzCQe+(j7g5G}c170G`Z*9DHOgDa?hH z<(b@jIYsPbB49%;rT&Ily|kiYHu~?cK{8uz7ZHqYq})-xVo7-%fFp$s^mDv&w(v88 zol?} z`GLw;!z*bIe9v-)v4Ir$Pu;p2fZ{!H(Bnq}7Fq0U;1wHSlxwLb6Xlw#D8mrKjDxb9UnG&J<=pGu1G4)|t|AVXHC_9pC!H#)a{(p?R+ zoHO-2qIhEqDZX9lHii$?UViqNTu+!)Od|`&Rzs!LA?Kg>PiOuuh~BK)@FpsbUUS5! z-JZ;OehIRjF-GbFJ$3z~>Qgf%P5^RR(okD8Qdrc->LFyE3X~_^*+TmAdh4hL%OMpA zF~Av+hqL79BoGKRpxjG#Q_=HPw~8^avgRRgb2+>)C5~Mxz}ndOt6G1KSzMZ-DRtz6 z&vmU63fte|O5ZslVi|*I6Y7NzmA@W<4XAfAY$UxjpbCQQ1~__Cvw~jy7^Uy60uKXV z%meq;Xw;DciuQ4LM|}D5<3yFEuQBKmopdc0yRu#OSd@GE^~Y5BIt`%05f<9PyrhoX z`)<_lWMR6itfDeNA|K8G!f4;`(Vg42%qEX+UBq_`tzBLZBgM{3>sM|JeHVSbjrFFk zk-WkuXb>D2Sefqo;3Fes`iA4yr7sJ5B=@xClOAuAy2|fw8s`n;?107FlVNQ<~oM3{QJ;z zvn4k@yz0RIY1V_d58`6}c&q8JUjr0aaK`;DE}~dZaN^6`Sp7SH147jt@mGxCBV4oC-LhC@Mc@v>smcbq#m z171K~^h09yYl?Kh_t{yIgo_1poZghU@BkX|SPI_&AM#1KK1s(u`Kqh=sc|Q^L1HD; z<7sV@#zqVpZYxqed=%zE?(gruOfz(H?W(}#Wtv?p(Lt( zbN0BnxQL^LcVql-Yg%Nn(_<%fqOPORZ_`j)Ca}%xFhiZsPBImJa%Mbec$+lY`nFm~ zKsljxBId3~&IbdXJ3uTU+zS~^)%{76QDutUe#V+$`YO(JTawbe?6a}DJrOstwrl$~ zW%xtogmgd$;Yb(g3wDk0TbsbP>E+39*4w%bE`F&~Qtt&?`~O)Y(PPFkSijC43Ch;vSqeVw86R zgM##c9cCH>O3LnPH2nAz^F$4L1Zn0%959SzWd{0L$K(HjTb)ffkk=6#vql$fjoc6< zp0^SC@XtH6CFSh~35!9LYXo-EXR~oURNTDAP85*b3d0;)=TY-;K{5zOAW$poPYJGX z8gzfkz4#q0ru4vj$uBcv0jWgP&2dBwtWEqBEom!*U$N}JlvivRos6UeR*zSe~7CiQdL#jRTJ{oSBAZ0o==p>U0E4zW% z$h$y4qHCe<33}9NzKD$l;2`{RNs3&*>iqb)kw2-_{UbieFOX>6?E7i!7aD8kd7^Hlq8{sz!ep)({mxy5YEIFrwY-0@R z;xOBA*rwTf5)5nr-b;tCFW#oCpn(t7c5dJOjK7-_T0RIAfJqBIA1YlBI`59|pFRVi z9qCmCD5NV+tR)Tc1imf{aQ_Q;3k(SfS-m`<_yD}ms-;T9kJsU61MuSrAC9G*qzt%z z01t^BcqT=D(f@+)T(3>jD8UL@Z@WQ4a&qL>J{AG8_oMF zYJqQhZtj#i_PLo6>$pPH!nqn`ph3C+L#0jJroS1bB6gw{wHgXuF$s2FEIPYtXYrbi z%dvIowa+^vm^B6e+Ajm%*t@Pc1<`-h$m`HRrdTiFhayi?BT@!)4-^jS7u<9OAPt0p ze%V523#!k^y04hSRL=9WOAuRZ1Ece?pM2iw6_)&|{UArY+vet`V{gta30T?Nq*YD8 z=#yF+rqB?!$=&m?8~RWcW&A&XVG2T|@3w zPamH`>cvpd_h@{MboM>DXVOIQHB)jz9ENEEvEXG!gD`DJSXy$|6Pl#SN%pC_&;bve z%eOAlSxeP1CBRO4owoewML_4PrV4e-mQojDN3xo^$Eh`PUf^Kt9y0A$s{k1P zC~f%%6aN;sNDtnODLW57T&@Ev(@tRadE6XOCXSEHg0COJf?cKSn6-XfwM)431}fXo z*?E@!JTB!jLk~fBTYrB+>ZbHVU5ZQH$Zl6jA zV@{e~aZSdsj&k&O{x$R7cW7m54bHtVf-etA2RoT7@RYsc=;%n>D3priefz5tlZ z5yQsD5d(9B!BGBUo>)tYUm0naH1z5IJY!AUo^1%{Ti9=6f)oiJc6JFs476p=<*(VR zX$^m%>Be81q&iXSor^=1-)bN3XR{zU9*ffUY#xFCx4PWIooy;GOeP2q}r;Yf5bJg^HhfrS}P zNGE8*4z+<0{&o?D3d+zoS?O5(qbO4$I%KC(CDUcx-uM2TY>G;b{!prKc^KPKCz;xL zK}*7eI=h~@GBq_dNs4buy>6Wpixyxk_fMW$dI?WkV^~xdnw*1)T_n3o1rd(-z8vQrP-a4+nq zj#%Evd;gXn!_aFM3M-nw@%Wgk%zH)fp}m|#2DGWeFIva`l(4~hNcCre@cP@1j;Kf4 z+8_6Tqc*>mA3<|p<4eBF2{qR$=VK(bWY4!A0z8Xh^TsSGyrQ_hC-_L8yFS)SVe@O5 zDVxV<{rx#2z^5SXZ7Rh{a=ViBh>OLod-8dw&zYCNKfC$KVGgUzO7ii{GPj zDHfRlwf-Eh+`PO6@xSeF4b2-0-YKY!&4PY%CgxXFRb5t7Q{w}@mOy2OekQj^B`TNJ z)dj#LvLk3U%DeWHRs*MlJRD{%q~kK1S^rplY+ntVudy(3vuF^`7?dSUO?eIBjVWZ` zesNuX$*CP`K=)_!{rmSZEJlMBuQ(*En*BpU_APIBmVKj5TlSouoUAXJY$<++4(IOf zzx?bE#~4|j=cwk_ZEPB~uh39+{x@Cdv(_q02BupTaeCDM(g&SPgLp_3SLeU|v~a|0 zj*N^%Tltv`?VtMLBX2?Oca+!CcKa2O=9vGeI%mLJp}S_gF|YF2v^-zIu=8M--=6Yq zZYanu7i_eWVQSI`kP88$^CaIcjU|~UW!Hv#HR;?d#PYMI4Z#a5Ohy*X} zH*sjGra74^(E%>Bd#vSKByS5jca=|t)W0cro@}&M45<2)#{H$wUQTA>ZW=_^^~o+^ zv9m-vK2rp;2NbdV#mOILeiNlf-7K7qVZ0P}G_eos2^_+_201dAN5&G_5nO)=S3ITq ztt{4c@mAB5lYvaIN0cQfiD;~3pOPdCFGpY_uKXREaAbv?4?umCpFm`vpkH97`HN?Ov*!ov8@mnJ zaQ6h|ds)tz5}7cy9=s*%_)owl)gDG=2N@$XR_>X&c5a^o-GnIH5FS!fUtlR&b(Vx@E;tW5B^+h;95Ptjzkk+Wh{h zA7aA-7}}E6hi(9&YQOKj%KRI3{tzGOn_^t>w!ucF|L}P~K8W7k0%_xe$&4b z6~n_KG*jtVynXDv;AC$539qkS*hQ})cisK&BahRB1vBjAD`=o5IG%a@y_%Yu!IZzu z&f6F2xzDIuY3K#c*!Ev%nLICA{ynl9uz<&#>4NuR!sR;;zzQ@qMsWf>hIma_nOv%L zHSkU4sZw6fAqJj@(MiJiXb5yMXZztJ4I1P+(&1Ix$7de+lZVjb9bd}W8?Z7AHdvq0 zlse|l!I*X&vQn-@*r)a3Hyc@g@QS_`DnxtrOWpPd=i#$TG{SyQd%|j3ZJgZRU^UH~ zx|CtZE>H}~lCW0pC%5ZXZH%Tkj=rH5Q#kIB=A~nq%N;?6+zygJ1~c1kgfNdkyLjzj z;bo6-)i&dZ7;TSJd$0k`9Wi%USs|b(nOH_0o9B=tBGbb*5dl29`KzX`fzZYzpC(Ow zbWP$5>qmSxQU=p6=*TI7hIfG-R zPdJ}mV0_Z~4!uC(>%KjWTFv#8=XSxrDQ$rk5sst;K8ekE2JiWgx)7CddkN58*(<#1 zy)~k-BrMv+Wcj%;$N}_xB|8I?{pcc1R3vg;fn=cnI$V=jf9RD0xkvY>2JT(~*Z&)J z&Ixd%70raUz#$d^a)Zb^6)AH3?Le2PZ^BeKQGXeSqbet&nu@YAA<41o_HAs{?IkT! zDRTZrR)5+7#{Rg`J*LG}6)97z&)rH2fiSx?>r5?7Vm@M^GFG+&vhTo6g6jf{mp?Aw9fN8 z3Om^X-Q|xhHMQdVIyt$!6nBY!$$g@h62z7LW%AHUVmj-~58y1M>r74H(_P>-1Ao%H z-%wl>4_Aym(-F{RI8>p3CjL61?I3926Zra!`>~86jF)}+>jC{kYa8x_fB*hTBQ`M5!0<~SQ>diO zU>1+}VaVW|!}=>kvHQHSv*{;)%OC#_mQX}?@UnR~I`BCn+75oI@_IVlQWX^ML<-2kdIDP_YPvf%a- zdL*8dmo#+dpwf@57qmR0BIMK`@JZxg(rx}c_NXVgAq(P{)(0X0;-vf6qFTp+H0?Ed zVXcpN6{dRDhu}TAYr)RqG3Ob~C8JaaOtD=+X+=dvn=VZ0&Zj@gS$1wY=Z*FCCu(n6 z-k^_i2}g3ERDu(*p?YCVXC;IT6hp1(*ag1}hP^w&$SUH7VId)D|3OE&q5)DQ`rd;tOlZ z3pD`j;_8%4|0}xJ%D2{5Mn4T3S9+J2n0V+nYF4(mw3@z1VF`Y(m_|l#ACCY<4L0-% zh!hhO$5f0@9g`3&djzS#lp8M?&5LI4!MhxFo(IaQ^6)+9?K(6}TGU_%Uhgd5pv-SX(qal`)( z3Jgs7tLh_!R4<;moV1D*_yipzzYi;DdP54!3oqDD4OGsbTyK7UUN}^rNC=;87RiLC znZV{K7M%ghPiiqNjhA+g6BB80=pk2JHqcC!praK(BbYs`mOX+@_R`2J&3!;EQ)frK z0T4FG1YKB}Hm!=;1$}VD4mx6pZT_yJz&g&3*l4OJBnA78toUyfqrTG%B%e|_D`6+S z)0W${q4tCgexSq^N0R7r+cZ!woE8|hU$XpGw4pI1$Yt7`(ggfDAL}C~QI+09bg9`$qO@Oh&|d-k~i4#XQu% zFNo|(Y8OFw4-fAA;T0s+f%}aTAJ3GqqAXKecN~))5f*Ha5QnNVhT*;#K1VgTv>e`D z3q%+cs&}Y_;+EbCL7Gb7WiLc|MXth!;?u?7^xz>&`c{_(^l5jZ1oL7g661Rt+`!Wi zZnp8{sgsT_D?LHVOgIO{H<#EZpT{uVmwZ!L3l_ev4hm-&bk0?9AabDL`$7%Y--6~^ z00(RPUOcbgCAMdEiiaBrD?egC!C)=X64?LPGT zGid3uz|D%4v>HDnm{&L6z_HI{{?;G_A1NOzM)Bj);zCeR(9{o$UBWvmI@P+rLt+Dn z=WpOW4WuyOdnR0j!`M~*Ke7oL2vN7i978PQ!|d?CW##4Pt?j=iTIc5GZdD~rWg|9D zF)avF@9L|In&6e9%h~*cT=$Iom&w{8#plYnn$XW3*y zW1DYPwJd8uHx$#zBDf&VHnv;FCbs6Muiynb&_30k@Lbfq9dQr{rZDuf6l$Ze$`~V! zxw*L|;TSa{B91Q}RwkD1j6+7Gjat6`KqI#5BA%p?Ucl!S|1@R?XJi56;=3yzjH)B} zCYJF~Q0`~E_yl>OwQ(kj=laTw$TK75 z{7#`Y#phE~Q*r{?!y~JwQcp21R|KM0Tek*u`x{@w*I6r@9W1X1$m?U_GlCA~rg-ss zLerZXSeXNK_w!O;GE*sGgWxHzVomo)WOso<`>@QA5^G`HBgRjoRv($pnbLsR!U7q1XU!F0SZ|K=F^7qM6E|9zlNK9cuL- zHEG+d=4eG3*?H-frhrojJc z0iJ!IovrxWCgZCA%C+fR^e>DO=fn-GfjzVAS4AhxDd<3YChwIyqFlT2S2u= z%Er9tlhUK41R&=%ZEX=YU)meq-D@(c7=kXI9Fj#maC{ny4`VX2M2aJTA9RawV49g_ zK)M#b?gTvhByDC34Qz&s)_&qNT;=ghDcgN`y7Pv$sdKwYimdh^*zHs_Z5faIx7CB# zfB>%_6W5O2aE>u>lg+r%*8|k&hs&PwZQAL7c7?DfRq*vPu8O_VbVeXSKiEVEyr+Hb z3gRKKO&NMw!({SX(s*atMmJH#n2uj_#uJ?W-f0y8K&tx}ne_1T@@@dlmjt$vPDEQVY%oIr9?t1_ zT~IEBj}Jn2?z{PV(8=^GqN^KEkA_d1{Z$#o>#B#oc*qvJQBR1;f^T60rG`zcL58rF zfT*a`HqK^YyAkRBA1h(=uL?-{#ISj5KYSVbJ9D>?A6^dSn5ZtOytlWPo%rQ>WB_*Z zay+kHO3S4wqhHY@t*xysQ=vT+0IaMIbC}J}81N4LVM01F^%DK{M?%H(9QOpDHn(s^ z)5dKMnX!(~0l{+pCr01~lL?|#3SKm46=A!?$;ZK-jqdiDzhHq1)4Uly+Yr3>D=+V(p@PjH#XWLZh zZpFKJ&IdI?yOcnkR3Pc|ALwoo;i$MdMCL+dVYJ$>`~`Af2=0A zfhow9a~F-CNOe#1kJ=r-uk?7_MS*3{a#<~7urh0mMB>kVruP<5K9DHqJN2Za@C*fCemvB~!A#8KjBlEEiY84J%$%^NJ>s9RZll)tB zb!o@52}dA+^i29XOFm1ALUBVwLuyrZbzuT~-g4z2UO~{J-U__ph9k+4lR9j?DR$c7 zgGk^3W) zcwvE0UlAMb;wI#~Emm8if}uD92Mh1k@!3D!f${0y1bQ=5m>MgZU-VyFX3ilAu`xgz zE94vAPa=ee2z+w9eYUXs}pZ*~Ln<)gJ z`Ov!_7stNYWDku*)F@EH7i&%N`ck)(39lcYeu_WL(a;TS2VA1~qpx0gaEcZ6UE54 zdipL}y{EkESn7Z(KQBw%!r!Xl`Zxi>H-kwYzhw}0mDd9xUObUAiGi1+^8?_6A5@)C_T$`BPO`h{pt zQCV+%Y312L%v0uLEW9kF_j!dRctsN$NIP_(iX<+C7lg2~tbpin>leZPloq+GseV<%DrhQ8n!Hv(vSwRx8S;k@ zxj}cC9*@aQwpDF-%NuITf>&N7ttwwV+wK7*f~@!P4$+CQGF!(`bR754cmc^|Zr{s6 zj1r4>Y#@}7{976qFVFhwQ^uiSov}MnF^-P-LW&dWZYN~tJ&)64hbx%|dGebOq>Yt+ zrNHx3a2+As3lx4uK$wDp|C@uV8F*zc%4g|Q(nG_8#fQ0m)@CBSpZt3WUp81YS($HF zUcJi2A=vR(E*P;vPxFwHGNJGV`tMS+17&QH#44FH++_d7zze?f_U+?4+YIRW6WU~ zZV#b5Fp;-?oJUzo;PU(0+Sca7>uwSuoZ$wC&6PH68N%|}bXbCMduLjxX`?W<-l(o{jI9`oEamSH7ok(?G17cDs3<7e9iqwC)fMY(a}GMjRiUZBIrzzA4s`XJY#g) znN?g;LXvrGd%@gNiC*ibnb8~NQudj2}>&Fqo?|_c{%ls05S7@GM%nv%xg^97@JWHEEEGVQmlE7Gr z%Qp+ICZY7R;Wq4h@;@(9d05#jVD)TC(!Ei(zdir4b=DJES=o~-V%}j7dfn5OUuOg| zpBlk%KNUy6mZlXN%YAF_>^$pJ?Am4xmMt%>f7D=EaJ4c~{{!%H1bG4UDT{JFthOr9 z%F#s%bu+&i)0R(ebibgeyt>GN*dVDWD{F!8e8Q)p|2iNGl6G#ozpy9HX2Pcvr@~a5 z3E@|}%SwH-IUw5_P@jk3Cc0aV?#st#H(RW>w(*ga>8B_Dh6hnm_rXDekb#@YaQ8y~ zLFAs#+nS+=41%FW|MG8>ZhsAf5AoeOf3bLU77l7<#zyu3ZuFe_(9}RU-O93T^qe_^ z%ChD@72jvTPD(ykGxz97g|8RlPhP>-S4xLc1Uu!L6>xiIn6PGHDh@73d2es;voSl` zOBJOOJzpwH&p=PVsb#|UW|7ulIc^OH-r+1qV#U|B-J%isUS`jvW=4T(YMyRZKtYOp z4Bch8_V3s%IqYG!#GKvtdbdI$_Sg%hoVUzU``V#L&D@fY^ym#SWYsjn_EOO=?;rR` zOBWZC!Fz+kdEQ{F8PDJ#fluP-!mHafz7CHc5-}~&MP7_iy5N1m;%7!`3#l~S-UTGJ zj6nWjXBo^C&p_DC?)A&h;HhT}b{p=ZV}^{#>rf|}@GczAYd1&Qk@3=wF-UYxfaKeU zk6<%Wi?s%RC$jS`fm}7}-IVk7Jy)XtUv`oWL<*I0!oKK1Y1^tI&i``6F;ubjs*+i6aGH->srK71SDfnRw*rjDOMmk zh~_(Lb)#zItYSYIR<=#m{I=KpJ%Lt= zya%Man*Y54v7MZm30w1R3ANVu$Y(Sa_O$Ggs-{u6Ia9NAMcHO9m7O~aor3W>-Ti9t z;xZ)Z4)%nF#rn$Pi~bz8yu7>x?$vLPgzL(e6=Sqyy4vE$TdcglnwVM$tx5C`Z_KFD z+BLj$EOXcvw{8vG5ajEBtOaW+V0L&fO{@=8KK*PrAXB#!b$%P!$=YcDnL}bLYT^8Y zG?EFJ1z1iI1!Oy0zht4*i5V%3oxA}JRPQ~-N7q$V zd3OzFh>TS`y)1g)KN8hwEq=*2ofM|kw4lM73qRv9ZyVFY5+(2(oP;c-(L- z&|B><%Zlv9TN0Mmoxm%jP+9tJ>T9f00}%xwbA}`^mQlyvdEx3=$L$XVsz)u9o}KvFNp0?{64^W_j6b>@cU=je1$zVi#tfF85K!q zd%}A9jk7KwAb^}lm0!|uhOD9X7gRNhzMz%EyNb}tT;1#kORQhuoS^U&Og6=Ve0fc< z!Q+RO@r>9vC(>6TH<0tUtL{92m4&>Foc^_ zq4I6qXf;D;yxzWmn8PbuK+V`o+U)x*<4>?v>E;sV4f5RGG^ZV7c*y-1H%~J%&1}L1 zcDf45bEvIz5%GX*3F}rDR>R~AEfkiv`xrWjOgW52wW3@FAd-|)E~V*|urfIc9xdo5Ak&|tgFku2?7#$MKV@F< zfA))f7{W79CQ^JIX%mm?mnLsE*!;6o!tMRR^BlQXgBV)mq^{g5Rq-&N53vrJle#;c z2{$Y9)w??pRQxi&ypdPY&ysTSCdl29hy#8nEcpiv8-^OT_4X#h6SC-bqhgpG@u#a@ z2fiYZ?Hv7*^Y8nqi%aBt=tAlWxvm}6ZPmq?S=lNYU*8V`eQ`#mvw!V!;$h(FTgB|r z)#5;R#l@1Rl=exp<`VflrzH=y4tOSps@SW6pDMnos?s-jt>|ZzLcXU3YY~c_1yyzJ zy_Y7Q!^#w1jUc-~PeTEX$y8K0YSn2vs2aTT&Msl+5um5d(@NLPmlGz<5=Glx?@yIF z^LlG*E2>04|H$oihA8%*P%$KD1~}kgX`l=RXTmxfRWagN9NTcW<2lc{* z>ivwlGglmwBXQ%uQj$-ySaT1x*tJm_-63?awSJ}q;b33V9vp{g4Fo=I9S*`T8L7&?(S{M548H+Z~j)+NHe(jYq!`vmZMn3k;xQZ@#$Ia(7FrC@V+HJ+{`t?VaHx#bW&g`rs)GlGd%epB>U? z5F5)mhgTk(Sv%Z?FlH9-fv6D&%4j(on zr-R0lkLy~R9tnS4RUBmi7{NR2x+P-{RmaHDmcI#;HJ2Kk zNyYM9@z6~ONO>q|ZuX&?5jf~d?6BzTyFMv1QopsfHCfNxuGv~fK7j59Ob1CadbFR_ zH#DSrdwQxSqUKZTjA(${Smc|U2vJ>yu!0aM58I_w+ME5KzXfqLGHtA{6aFis!e}mc zo+oc{dRPeMjN$JynS2f7XBVQhw6x@gmrY_d#r`>rXxrE4T*bC2;b7;X=yrl!WWvA!;RML?L#@jaz^Jf?(g zya3=5quZoHc5-&pLIrceWLfkCiqH>U!3QW_v}qN1rX`kW@fYyY4cfaQoHHwR>9&D;iZn*8A@qR0hSc^3XRYkAxO{ zhEEePUDt`EM#9m~B87F1`p}C1*SGG5?7x4taCaZw_Uuw$2%4i`IyyFl9X^O==|3(Y z>2%aN9J-K~fqtmkv{Y~#Da=_C!9|g3h!fR-zMxbr^P!p%7!wQW{jVY{O*LiAfiGvl z|B!h4t!&bk(5dykN}5#)5~Gl>zrei*z1TVv9v*&{;3V;(HC39MfpDYoIvA@8Y|@e3 zwipUXr-D!YIyJz1*J7>3IG6m#$ok;DcNdV0+2J~i7oz8fToT5XS?l*&>(;>K5!>zK zvqAg`eadg@FIR$Mvgy~|=3n%j%M%IH)7u;_w{nX3_pAZoEWhw~))L`0+X|Ev@6@ zgELxdg#{hzphnax&OrUpsjcSW@YTte38<|LqQPDpSIHO;FK548oi0?|WHmjfI;0fG zwQfLR4ju}-M+s8y37V^TsPqi8QBRnrIPinoTbSC;0ae$vUfX)!c%GgIyI|CDdzUn$ zF6B|z!|wf;Loe(PM{~*h%(ole_PpPecn=!*48Fc$WH325*Ks#JV&c07^o>Xh4G63H z0KCVCot(k*tOQRQv&?6u@wwx)Pk)%gHsgkxgm?+#<3j@8vdiDbfUzY(ri{w*}|d--v?AGrAiajpYyuzj52R5vq{z%1D< zOhH>0WNkfX`wmSNy!Cr^)qH@HEZ(a}N!jgP_SoGtA|gUZtiGNgw&LINjK!!n3OQd{ zTU)!NwcF7jfQDz&U?WXehw%7?8+W5mzHV-A`pGgqhz#O^Jge;u)aVJCU#`*ZHk2Z> z_js2SKL8JiVv!$(DR7~3fMOpfdB7n+Tt|d5UiliWavJ$2r0`N3Zn_*V+Mh!j)`LCD zk@|`F)~bNzytJr^+iNt^*Me5)C`Gf7pGA##QX+%v31&1L^%WF~wjQTiWl5C|)D<=_ z=>0w`U+5q|Ie+Qf(SJN&)($QlyE{KMCHLlYn1OJy5?x7#owzgv>FqV733077O z#uo4wyWDd%e%YBtsTdVTyS{RPg434UpDeZ2N9)r4X(b$Wf4OzlNd^8X3(xF4?d6*dxja8(-hl zWH5oaOR?y}87cC;k%57sn#P$P#3C3_{-$g^5c^m#=n59S0U;aTo_x~S`x9ukh8@0r z!+24hWgUMAt-pWO&%!jO6Fo*s7R@uA)cG8DiT=HE8YCXoSS>CwjOe)!Fz%@O=+}ze z#_C`4)eRJ2@OYRa-6pS~&~>A45mxq=!iu}^TOWUMh1Im0born7rK1pvRUfe;>Y6T6 z*c(qXrpD9uOZ>e3p;71t_NJ!)C1Yb0beqNbsDg`X!A+{5zLEcsT27*Q;TS2z z%gyaj>Uj?qZAm@16`bU7g6P9rzW=(W5Z?8^LH#Ah=YCj)K4&+3)keWh)A%HvXi_w4$??r)z>4mYOD__uB~tMt%Oepy#SZo$RmW`LU!_Dtwmgc zZak-UXXs>3qvN)hte^KgU|jyQo=+MJ?^GS)`>le{sciNt;MSOyL}2!z+Ga z-~8q%f&kpyQLQe`r_~mw#8|lE?mnGE+Ub-$!@STf`Ks-`mkBR;05xL_dM#Hf0jPu* z>}S`|X2I2ScZ_RHr-QnOi>_zV^9-k>X;;}#)bmeY^8EVycI*631qH)1V)H)6njNk_-Ic@D#O))uP z*@4Px3hQBLcs9iWSk!jYt*_!4#z3#+YuAl+4eVqO5S{F67Tz(8_n|uAV!=T^`|aPc z`0(osoON3BR)&Ysy!NZHoWq?!hmFo50yRAWVr&; zX?>TVehn{K1`rWq{_Z<@dC}4yXG0ZRgq&osPc?fotU9J5ho^}DH0=lw#EGGx)|Ctc zb*0AFTC-%*SfQ1z?KvlnfnwrHignnVai$zeilB*a0f@S>AsoLjP@G9CxR*$ zTQTdCo^p-LmM;dM&(~?8wBcosgAeI1Sh!#0LraRsyE<;TMaZ=RuE$o3)*h!+$j%#6 z0WAJcNMWR>(l62eBA`}2A@2q|;$1Gse-|oVpIQ>NSPju`=O~-rD0GP%M#TaO*QfJa zHRRaWq^(bu3e4vdvj|WdqxYiUzNo84pZpnc7(#>dhi&N_1xzRR=0v~8P*{C%8cer* zICt;+_;@W$y)cG5nB8mQOc$!sTqU-g^9U@*(XH=?cTQM`08Px}cMcV+iBDyvT(v4BQYb-fpWpJ}{(3*3FDOWo80$CdOuc*v7>LKE>75(GtY7qA z|5OtwMgCJjn$w2<*dHm97{3EF();O)S@{(0v>hpok>^w0)Aj&L6U6>Q>+7v)szlSD z;7=4|9lx#xS}Kuf_d2U+cRHkcDSW9|PVqr=FOF3-U}4NGb2N5=JMaROjZ8mYz8}BU zQEmuR%0itbxvn>MGAxP;e7e|!U{q7QN(~Hr41QjX#N#a!j9gq%x=#s5^4HD?xPu=Y zlfy^vCfPFKBq~LfxSYy~)3C`>BbB{P8Quhb`w}ahaKN2 zBCDZTm1T3Kb&E}#*L+g1ji5Y&$2h!b60Z>MxNNuWzme>QlXN0NwA0FXnj#9!g1vH! zHokAOoW;M0p%;kGfnjY{PnX)m$oEpY6}G0KvW(r&fcZfZS#p1_oC?4{(gYD}UOH}w zF=@Q{awwj*m1KH&6>?txqhGS`O7y-ru(N!dRBH-_j;wf#nZV{~?ap6ne;C@YVqVHC zdo0Sf2SsdrjPuIPGD#_EYincruK*)W>+&C;Mxy#Aztntmbget6Kj$IL!oH#*ItR1= zT&l_+zoS}RN$Y{PnjRnjN&Nqcxb}Fa_c#6(9paGCL2F1OxlGR7j@*t*%q`J!%Sp-H zDKV|G{{ry&qyO9c&3p@4$X<4uB+-Ix-Cjg90u|XWVU)&TX)iJ4pw_d7^lta zjH4Z){?e$$wg-g!)~K@?ik*c(;)Re`xdix0w80@T$T{eOkU( z7B(PPJt#7=Xk=hu`O*XwKl-^tL!W)*ngmYpr+(7CQd_ek%RRDvDKatxyKfF1)_A7w zAYpR@WwPyxi~LE;nFlGAttq}SP66r+dM=uuw{msZ^i?IFuKdU0rPUoU-&_ScD!2iS zjgGe6r(<$G^9)`c8l1GQHC9?ddW3E}Pe6(TBm=3yBS#yiqW@dX@}sCm=836iZ(q|2 zPFAeztZxWUjAs5*Qu1DP0^(=jE!i3BP<{L%HdS=Rn>`FVa>S%@OtE65mv(WbCd{9f z`3!DJLXl(!!zxBX9jdnljam;G+ytC##YnR^+YmT-L69|!Zz@zz*-)usP3g|?3eu{+ zm`ofxj@1uH3&S|AY^3iWr$wmkA4iW)a=i45j6b3f!UB_U46pghgTDDvi>wlz%b27 zpLAwwmdrV~ZGcr2hmCje-o@#=W^FjB4}UW5*FHZ!vu=;dXJP-!i@KQ;6{wo74-ZTI zVCzrA_VxAAg?2;Rza4F#Q27c5L}Q7(r7NNchwyG%fyA-(YJ7Pnq9CutRkK`g!SdEK z?IQZw1s^xHs!%>8$yYX=WWL=VC$H=tTrq(3T;$ut-+pj(IzvUzn zQmcIVv)OA2YW)+<6*8O5lsQ*7CG>o3S00s9Ylw*@VuEmyB1S%#pcX*sx8}^Lp$wmiL0Kd%KHyGC>VoviIZI!huH`pD z(p0H45l{*W3R-R)EBbX+LL=-&6Wo$yp?qji5m?{;lzsM{_By0u1p4jUw5s(XAs{s~4RUGO?bgCdWA~QhiN%Dwl209Dy&g-*81EjX zuExJRsGDB8agQ+V8=x}sdRkeR8f8~D_$Rs!TUJ&UI;5ZAGPDA<6(%pJ!I=303}t$| zw151I77Nn5IJ0$(S6;Y@$74F9OCj<)kAE60Zh1CNg<9_u7-DY7R-RG#c$l*j2bV`2 zk+cMRb9T5TB_$Re)5>DUrqmW%z_jzZ76D#{?0$;$D+;7-?u?8vJ@;!2KOytX$BAb3 zd-v{1l?Q)!hZ~`f$=7h5h^%p1;D?bqJPQ>oulRj93j&W&IC^x8;w1mLpOauU6i9v~ zF<&`Ot>0Q+US58));VImcp`4Ylcf9^n@F*eKWzK=6m3Us;g$HWLi6GL$*=284azG0 z8*jbD-bZ0@-f}`W5*xc{iAw(QK43NfjB)t)nA4ZACAVS0XW*X^9Z4s}zA1QRk{w9s|IsAH>s$WM-sW$bsJ9ufP{ z3kCVU-3m+W_o~H^X(j)u<^fwQu?W9r3u{3Py=nd|?0Me2D6P6E@@JUCWSMQeZK4*UQMK+ajEWvATc!-CTl+;c_iG;Nn&V8xyzF6_;Kl0Yn zOzFEJLm=FprSS#mP2fB8)2C0LmTM5C9{Kv9t5>fw-sWoY3K*k^{p_3B?zbIxf5%&i zUCLW??IR>%LWXGT&3p+H-|`vlg`4@X5>;eOBx#h~Fc{Q&#A@Si`N7e`1Hiy_`U~b) zEl_69M7(^8w7E_&E|mbRwad!N%CepDR$@H+@5sIz04j};U`DOy|5C4>^2}qKOCBsQ zH$$%9Y?*=fTkRi+D=mKv2|rv&6e_+1M)91jt*v*>#=ELB&FBu36R8)0Smt!k<;~7g zRM?d(AL>U*9vy~7y$L9eV7k>9whC*-XR(R3Ee+TW}H^3yqAqf>6Ie;9| zH&o%sYLqZeSw_H^2($_S0KhJbQ{*(fHXe<>hvRn3>!Nr|7?^w#u}NRA!IAAERLrM+ zxu@`P;9|6u*d_d05_0~O6aWClQRCV^O<#J;?C$sfxf4kmoz$rwoO1$V$L&9GD1v>z zXiaU~HTY)eadOs*HTDT+UZKe`etxsnTrQXQ=r>QfZf8z=L!B!S+kc0!ZJf1MK+XoL z`dI0u8(WNIy&f=UM-~p3BnVKpG8$_yuwBJ2CqoX~UQZ^NX)8?%KRkiyI7zKPt`67h zDVW~QB3lbpb%zFcMJOpLna^_I4UAUm^pc#>VE_O+H!E{cRx)<+LY~qvW>x^>kL+^k zSMm_E3(@Mpx8P>}Q`(nb;h;q+VSzcoq-KF*4?iR9dv26GQPr7kmH96X)zSDtvsPj}X&NztUC?3U%JUCq4X*%{l0Z zZ;q4kH`QKP$+s5zu~5~+&z62=XLCsU2R-kDY|_hm-p={=m;nH&`N#>)hQPfING~tpi;47ja}O`VnB&Hn(R CeP)jU literal 0 HcmV?d00001 diff --git a/app/images/looking-for-people.png b/app/images/looking-for-people.png new file mode 100644 index 0000000000000000000000000000000000000000..8980016496580887560865cfdbdcf72898fac981 GIT binary patch literal 18714 zcmXtARZtyWvpr~#;1Jy1Ew}`CcXxMpm*5iI<=`IN-4dMO?i?Hr?)=49_dZO`%T(>z z)xEn{ueD~PloTY95bzKH005G-l$Z(t09XeA03mR&004k-v-I-@?J6Rz1_uWR=LcsN z2><}#ousr~0RW_>&kI6?Cff@D0N}cbYq_a9TDW-{yO;w!Jw2JM9c*1qjh)Py99=B4 zFZl5Q01|+-n6R2x&RMRfo0|B`XfOMN!`5VJ5-dD?;X}L-;5ZUhI2taRrjCRJ7y>h1 z1zZk@ChH2JQCCZ$lP{5C{uS>AN0FGA*wQK8vd-E2gM~EPP6S;fq$A&zKO)z~(qY}w z^3YoJkoaH3KnymsI%LfSychV|ae&^*pi7O^Aoymt1ObeYgUzfSskz+=SygkLQP1fP(KQJP5{IW9%ROT^LOxB-OVDI2VA|JEgA)kLx7u}R!2 zc{P){3POv)T)~cHAd}#+*qDS#uj*87&Ak}7tWR=X@5ZN$dTk07pX5m@k1;r@ct{#* zdgMxJ|9fG{w1P0G`jdllP@eTSCyx~eKJq8433NG85CR}W#1A4U2o*N#BfJE_ibWi5 z)nC9k7luPl0yACp4U>fr@C(&K9R~wmm0swxw0B8V0q{}!ZB>}iRo960T$M)CCBnSkcj-u2sm55wrWd<)Q zayeU>=5O=1(;zVH_}1aLCN70O*Vl#2;`73O5gqsG5(c>T2^0iZjR$^yWsBe&+xuBH zfa>M0Ue|gN4SL9=;DpA;O&ei!U-@9kKZqeI^*@tRi_j}DDnH1^+RHjXX$hLvyCrJX z-d(?x$WGmN1p;B^r{|$dORb30algjWCNU=XMk*!gZ`{yL^r~6!mQ*h{K9R_1`M6IP zF+fKka1nT~yK4*k(-3fnj5m{=msR>2go6=s(*L1Zkx();c9bdOtypLE~wzWa!?B3tI^)n+yE(k=7rR%yZt$Uc$DK;pBT3m#I{ZY?~ql}d5_?= zfO{T>Sf%AxSky><^#(DakZ3d8<}upvLZKZC7B*Y!=?a7~TBpF3Is%_mlI*WtKp+sv z9vsXzYy}h}Z760HMiQ3aNsG28KNx7EOLqHkhXUwejfaE4&rAHDN3P3Ch1wu`~5EPCSV4Ck+R<_@( zwfkf{+5X|oeacZJ#X$MaO5CaSiIHWz4bG_Pa5t4xKL?>SAPKO2ZSL!)x5#8e)Vq&L7c^;bP}^ba?~aPh_NZVZ#eR*kg0R{baX0zZa*_Xn^}Y>> zP!*`n1cy*{hO1lHci4Dd8M-mOwFOMMrVXSe$a`*83Va!eY5=tYw5jCQ2SsWbl;>SA9q6VRUf-or$ zB`T+`4JvkVU>75H*S?A^JK;k2?j|iHOnZRzl4_a}S=&=6kcCBL$0%CGP#Kl+kJWh9 zfxUzXTdqRsKO5=UtsxspG#qyxE0kpIS7o#(9VY4fn8^CT{D9#Z6{nDO`YHnO;dgLS zPJun}N=&0_d6HNmTv?a|Ha*6Xbo`o~V3??qOcD#;0(JQ72oK!o8MG5BtgKg<|6KP~ zxQBG?+?AMu!Xg(cVYTo%&gkk%o~m+}3}JX3;HY>@tuG zjMvlMW<0KHty+sC!4j1P94H?CI`j?m;*|)XQi~_q{*Q;aQFsgq8OtH;Cl7|GsD65- z@02*}^BB*La;>Sk{Uv5wt%7z@vf z=}O?D^Vu|h=ISU`@LN$2mz=RpccV>CW4EphGt9pE^{fw4{7;Ex6l^9gI<6u@k)rcJ zDFi+l+NU+TD1H&lyt#IYp!K`LE=W*GP*RI=d&;j@ZkoJz{{go#k#`&U1@#;9>x5N2 zuL?i7VEq=CR;0zZ&g~=|&Bw;GnX9l_x)(Is8w5)po^yHee{nQ7$?$LvZ!94}2furF zw-K7rAks9us!S|tW*5mJT{4l*!jvv7lT-VzKTgu zdbD?QJS1R8Y&`et7|yH+ZEcIX*;E9o3%f$IQ@aRK%1jbSMmY0p>yhJ_6j71fMBuj_ zGd@+VU{qB)9xc)`w3^G~P^^H$K$aYY3Se>=q*V1qyBchN@W0Kb)5>4c*v+}*CON(o zvp4;WPYtBs*bvz(p$pljJl=7;r(I_U#Bl|7|y*t@i;CvRD7N!2dkj_k4DeKhxh}Qu+S`6zxd-y z+7XmkdLhxTaH)x7HWGOj7d{}Y6PgIH_oOdAygJJt-QzC^U^5RZ+xCYv$J^$eKqI)^ zH3i_%G-kZ+f2=ZAN>12#_bNu7)*d)j{O(&(kjJ5k=_$e?=M@=7R620b3@2Q+z7_y!wuJa=gmoD5m6l z+Q8*`6_PJ-J>EcA*n#D*xlVNHa$|@}_?~#q@uT$TJZ=}KDZ42iw%Hz(i^t#x??2vo z4T65n`2b6Pgv3PLqGkh}Ueu)b0Mz9^%n0_@DK`cB+MVVlx?!MWCwHyw<^t1}=#F1u zL(Lq{)D*|U^Et-U!+;1?IWp?7l?jkE&05tHyc)|FZLqko+#7M#V(wyKhD6D{?>`#1 z{2tQmTv|VJJRW{_W^ln;Yw)i*9SaZR+knQwA+RK{z^iXmvnwyNrq;6c8rCq7_Q@eu zm$W46?yON1Q+R2lZbh;VlL?z`E%WA|i6mS4JFSR5AICeG_{8?yiO-d6)8=Z ze|s=eE*>Fkqw67}mNq9!WCGmsVu2A$ZIvQ!FfQTU-WAC7c-fNCNow(^O~yyn8*kTQ zb`}}n2r!WoeJSgcpX>aRZJszRXgJ?mt^n2hok zA+7#pY{eabzPy>3FKgdH$o=$5gT3~?|mh`qT4=T%*Lz;m|xnF%(r5q{8Kzw!`EGSgFlae-J!Wle6J>On{#Cisl-#`p+yAktcq;P>?uyWjyCx>jY!H>vg!i9?YaOo()Q~7B+65m##d{D zawbn34{>anH*|GitEx>M&s1Ls@s+iL^cH{mfSSMLnE{O<=cgPqWZ6TO%DPP|KNc2q z=8|`z(=X?Pej0j4a5m3F{kofvNj3}r`N;M5co*HJxEPhb1@Zz}yB1BSs~3Nqqt?13 zHtDX$YEZmkgvm&9+aE3o-W^`{;G25W@+nZO>dVgyXtws;d?2J7DyoJ@z{aEo#8>@J zOG4w3{&knkvk~Wf_4o5{NRGk7SG|E9EhgC)%+WG>@X@02OvtklHb3D$&0#n~qi(PAQ<5b*-l z2T>vRFjg1tbA!C4vQ`hC$y1=*Ckb&+viJmds(%@pQK3X8pK)wX3chzFmopgRllSW1 zICesw8U$;GDx*+=J4x+?U|^{uH)FR*>ytML^o;&L52l z-NvQ}OZ=eqIf0w-__@-_^jzaQNE+pBuS#mULgkKac?UXg|NSgzDnwJWzgER`-FZCf zRPb4NVvCDv&`2=)J*J8L1^>$67OA0=ykyR!^zEqd^ zlB~#$aW}y)mlf3`>cPTTBku7z`qR}lTWX^(oD@nhs$(xIPJ+>7pkNUdOwA;cHD*v7 zdctM<^Vu)E(M%a-h;f7rpjvK>X*6h!kw;SmAGO&j*x$D_$O~0;t}4 z20~`o^{z5%{cgcMkgD&I^Vr{;teBq+zzyiP zSjaA$UsNQ0_Y0GXAUw9`tAyI+!e)A~6)gJVWDAo99oWr!w2c%#&9tn*+n9xyIl*xo zv+h++5Ew5bX=p2%)w39{n4>KZ;!E}wAUhNb$aH^gKFwKuZZ_OF$VQlcy8JU3_i?mW zSMz}kp;^_CCmq!xol&uuMXf`@{KDwmhfljAIo7>Ujdfh!z_&j}>2!bVzyYU~w)kP@ za4|fp8xwM@oci=HIq*#+T@aO;M?C68rjk00orMYn0GNtddhOhuPWt`QEf@5wX5O6rJU|FIzHme&tj9AM07Q|8xEuOrlW~@ zdGYJ(dSMX}E_#x#yTca=H_7&K5zWx(`Q<$sXbSjf*()%hhMbUAa09Q*I zmRT>+*i<*uNG#U zPOm{4$xfC0nk-(|G0jbvRB!>`=c~P!^|1bQRH(V&*^Ku)Jh{pHJ`8JjU^DtEcr78_bvNJ$A52n5bFS*yBTZW5lawSFnN=s=FQ z;%h?m_Ec^;I3iy<`x1u!vr1!(+KI!{Y~NyN?6Y|_DOWLXF(4^xEAaDbE5{ZIg$c;* zUAGNf9sWp$w%y306v$TL(S(#+j48?r0W&U)4AnpXAx2Sc7X8b6e<*GLIJHq?Us1kV zX!Xj>4;rg(9q=%|UHG@&fj~}ylqt=flbZE>ECkVENWPf?%y2a>`VX=&m0J3Yid z-rwrIZw^($kpQo6Z?mQH__2h1-{>`}`}#w_6wV!g#+pIDW4@*)iyehn>*HZ6^?@^0 z5{6%1L~Ug>|2YcXIHG-JG(WY>aLI}UjS1w>7dFGrz}7Hqe3&H_q2kEj}KqK=eCbJ-!J6l zviBo?cFYE+Hv81 zH_`Lvz43-;09W!~NSA2GImKRB0Ns=G3W(^uN-7{3Vi-YuA&uS8tKc`Y*(V1?qQ-r? zw%<5>kV&q1H{{}AR(%AA1FO9N5tfB(3Lxj-3CmRiIk1T{=)NglU;WO-pHuJ%S1@DS z%BegXcjI_qnt!~Qu08Q?f)8LUSD)1Mgc=%E_1DuQ_=$x7^B1|K#%cz;<^}zCI)%=mrbU%N`LLW) zBj6KS|F_(2spGd4f#$u@Xkh$yGj2YH0&f*fmXFwXoTh$XPYABuVi?(Rlvv@HQrFUA z7wbbzDiJ~_3T5mr%VhQ}_w7{c_G&ssLl!$daPhDA5Q_)>EooTq)cIj$S*HQf>qrSdg zN?N)<1O}1C>&gfa^pjnZp#6R4T88ilxx%SQ=wdOh0Hk)?mMCI{#=S{?Gg@i&9r8ZR($U@?v7p=#a|Fd zs-S3UpB$-AuLZGucXIj!@BOnx^V7fY091xq;^$UB$G%+t%gYe*cG*Pw z&W%0wV-|hYE#Zy2oitJC@4u~bHyk8S7i;P*r-`7Uq1RKv5lHwygQ32d%$H{`&6XXU z;kI4)<=ZsZeJd5|NeMaV`I+(u6pmEaTMZKcu~T^8cEMs+#BgaH(y9hc@SQ?-MUG zidjIT38XJ_Hdk6U<^^3J5j6~6*7C^V&lf8C&|!cI1UqS;XGuZ7)qSrftu8>6Nj;opyjHc+rGcS8b(MOE`ze%%W96YU6$ zd5CcRIc}OcvD9jK;!5(eFUx+N^pbj%qND-4xV0ID?k=k6Zo615TxZa2#F>5Rma&G8 zjUAy{rm!=XN)L!^(eAr}x_mA$FBX9uRko#r!PcnOOMk05yKO)JHdA&1gwpxIa1O)1 zSBkr{G_AROS?5^jviiCcnS=mn7&yLS*4yDXj(@r@HoQThI)(JyP>eKjE@^&ad>Oi> zY(Xs2Rlm)A?|`NBIc3mo`Kl;*>4RIDPXa^a_U#cBEaX2eGG|* zJF|+9`!+xweaV4Lzlp>j&)%_+3*Gv}CRP$~> zW;tKm{}7yA&`{3nrgxy2q|Oc`2{qlQ;3z)jH%r!KM2`c3f=okd$Nit*c^*u3`^oVW2u`%Q&< z>{&AN&W6brA^E|js+hm*wgfktLPxgY#44=95 zW|U7~MzIghw**Zz97W0{JJ%L#bZ6@fdl9D3PZp{kw?k1_EGBSnkLSJ!3k!R_oVSun zQ=a;9$BWw8F?gIULjWkoEc*n&A8iA_YGWf`2p12B*P(Cw@ijs#)nfVnsq~t_z>oJUFu1>1I{CB4?c{oH z_J3}mN|SkLG@StUVMY|i_Eyw{YLZ=SALKUmJY4NOLTNl=j( z9CIb7`7)AG5>Y{SinOBf5tzfFf+S+2G?fPdU{fbBQbPf(1om(f$u0cRay>}lo9uaY zq#kq3__cStAyc{4XoeiHWf@=)Zs)ssj8Psr=mmq;q=^!DuTcDjh9tv!5xJsM9q z_;T5skjv+3G7tuDwb8-B>v0xIt6FOGaIsk}0_a22w`)`E zMIRijj7C7r3H)=oh5M)>V$o@0LoIl;&PH`YRG35zs<_-3S@*h-fCQWQ?EbD$Y9qPw z<^lvIb>SQ7MIPLyNMb2-sbDvqN;@Vk@|Ed7-d;e9?Nwat4#$*h-&iF6S*|y&4|wz8 z|9HLeIx38qX|z=Adb#M#U@?ZIlFw4BzFc$cjazB79Gsq>-fMI0ed9#Oz#tvZBg}E* zC8R0&y>?DQlC@YkM^}wn$w0^8)WD-mGJn@7P%@4oekx~tT8CCiVNUs#ZUg3(2?+mk zC(mL}plZckHZAuBFm<`j1M@u^zVxro&8+0dro#9GGOyrJQ3EvK{&Fi}DwognZxUr> zOpK_Tn_E3+g1|jat$ycEL~MtH3wLgUHZL$4ultGGZrl6&yVWdEykc#vR6cum|8I8B zwT1in8r{tWr)Nq{WE~i!C;m@hN9qDytvboo!IlxXG!;)e4;Nxm%&}yL)D~(A%ItqijV<8F{NhII{ z01ySfI#=tpHxyjl9?w}VR%hzI2>9L$VQz$8XMeX2xdBb5bN5ooXKmje&jA2fmao2u zIkaY`2%~)1?f>}vwZ4Cgxw?-r@+�Er%=pX=q>8b>}Z>Q-!oLYWRWG;9O4k_*l?j z=5nvXxhn4W50_+^U>iob@3-;b)rRMm@!iRSl&q|%S6ipO|Hu1VWNa)npvig;cCFQM zGzx<{F)=Y{Xh^KJPX&;{@9k25UQ}1d%*M|CD={%hHl69S(*D+Q3^)537DmE&UWy#K z=T=k2XK@vO8F6F#c()!qH_agXL-4pe=qKVPP2||OxO2GuSiZa+!riso8!Q!%QN#1J ztp1U!J8YTpujeJNk5;z6^AA-EHWN8K7y`ccL*xnkAsd}BFLL=juCVgin-g6HbH`36 z^R%C7_FIeL&%^{nX}4Ru-i%GNgZX9m0};UJ=5TW6*bNmLLR(w=_ljL21-wl&9{;px ziAte#GS#~UTZ4v0Ur4cx_Y;ECC}i2yN&~0KuV z0Kp~Brepr_=;f3{?X3y{&p8RS$`VmdAc!ztRAc?{^LV z6&8xqaWnR}l)CN~)8h*Q0pQ4=ndi3WGGSWQtGa#v)=|Q3MfcUFZryOhdgDymx!K>w zQ91>a&$ZM8*{RswFSt_AaxzZP@NmM$#?9cAFSY#q%OujF&&1$viJOl^ot3DNZHijp z_5NSTV4utG2NttGsIOOpD4+EMu-y*IqqpkxzOisSTdv=3{A(r>hsWWxI}{btxL&K@ zxt`bSxFtAWu3RK$3>8#RKmrX7ZS?vG1^}{8$zFD*KT;pm>EmR&T^{SYQbvP!GvlhV z&gL+P%xVihq+56$pB8lPY6P0sOyV(+|Asul_=>DMq}TlAij|7kzREwAa%|VccDv2H zqWa)ND{xe*eECzMS~k<>Yzhzxg+=;px55^S$AQr8`!EuTMy^z6AYf)`X*&25Nvq3? z_5NbxbTRPx_SozF2?Rc^>-}bjcq6T#Fw*9{clGwLmCh@vo!0N%TCGaAi=lmiT_aY> zQ0Akal)lcK10T%JZGR}H&BmYWY(8(J1XgsNOW1@>u4}iO)WB$V&kMXkF{Gkf`-n=j z7=9J$UMJ$PJW5V}Y?>hObAR8=ZmWlo zD!KEb#CECnXuX{s76~t`(bAEMN>*Ne`1R>#w$V~?B8#KSsF@)O2s!F}lIYTFMX6Fj zzup~U7Go_tkL#F$|Lz!iK5%4)&^)W<+0O*Ha(tXLlJ;nL-Ind8%HL8YeGFvgPI!RW zcRd)xny=MgtC1_4+o>NV8Z=E1&?rUQ40y4BeZ86Xx?iz$zB^HehJ^*{wmK}=n|=ui z4Ru`Ga%dGT>pUVs)`OQ?#sjvsOS9H8opMwQ5_E4m(?W8wVjG$IktH!)$!2h z%X*iNYFEOf(%X$}8FKWuv7r+Yx$qOl<#r;0MXuJ8(v^`x7#MJ9zhgtSr=1)ZXS@OU zZ}dspWOAT!0Qx9(T<7e3Yl#$I3dGL49y&oVfbBBd@)PWv8=P*sBV`%2J3v92qGG%G z zl%SxX)hrOEp|P>vevMO1Qj(m6q_0Tl!@>1@wHZ>L77&!g76|gLq<%Z8y+kdrea(nw zzo-9e*hB-Z$4E&0{ouX49VJtJ2P%I3k*3k;@mg+k{6|6$YZab&HnHA;@G=5Pf$&|3 z1OpPsz0+F!n~U=mZ8*Fn!07R6hudizz+})BxfS?9SR|PM3wV9pOYl0(wEVCAe!RbU z-s$r@?+(Fp4Mzk2w00kfj=#(q%PV~K2a1nPDVav7+f3amSr3fh40gDmuKNi?1fi>+ zTNqEbo9EorI(?X|ig#h_jjz9afjn6u>_tr}H}R1OWOrYQT6LJ+=G<9TF_dr0#_n0M zlSpUM-@d#|c)9^;-Zzbl%g97$%i@MQ6*b*&6!p{+o$jqC^Gi30()1hNvID>b^ zV{zGGlc^P%mSw8A^LzLg6sJ)jK37%os-%{G9rf2xk>OLcQV&*aM)V$+mu{f;zQr5a zKNVj?7wL-zWmT%6V_?MK4vVj-n}kViWpK!!JR)sr5gGe}ay=7sa$>RQHHj6-MKJ@1|j z&p`DC`?VI63uA;vU%efh6TWiW=k*`?iEDNDPZN0p_+T(ttHlnNJ3dsQY%ms={kQX; zM5{;ei4Tmfb6wvXB6yBNE%UoFvu1JK&zrz5>t%Ka;ZfhZo~|6$5-pvQMEV*?vHuLSrHI*OvXu0V;%Vq<{#zAGmYdzUoz#P$yPo`ammZx~;#LGKtn+hkt3Vo)@ia!QMhm2P;veyrdET(5 zBbePykvQ33PBbscwDT*i@@=E z_PBH8mhTfXte37sda`@DzD-=;ccvF45rdi*IXZ?&pg=0Nlx2=26#9`sD_ejb9A+)- zUaq>wnw)xalE0W5P!CMd1oMQ*q}K+hT8Ku%=frHv=5iz&iNlW&@V#$)B1d;>ejc?u z>N&9+t$8w+bnr`A0UpNmUJYq&qyH-u{lcK#uwUCVUMiohRHi^UUuU>g}mo zR7Z8FC*7$qn_MN1?Id=t(DSIdbk4n);_FoSfzs2 zI^9+#CjAcRvN?gM6~4=^Q1kILEcTOYv88Uw+{=wxjGmg_UcLD5=2eFs_Pn1C(qlG$Q2&kJ(8b7eQq90oGtQAFJodP_4I4J;4}yTY z8pbxnTr+^;%5B1C($lh&6K<)MZ?X57gZF~DFdXYI)>Ofw!i0kxbv{~cwhbuVg>>U3 zw3yC|E6We)C?`pjQcbW9M`Y3pXaYDP@)EI{K&LV4YLxPzi6o|^ge@8P*35f&bP_5G zK2f;w5*m2*>l-XqYeNMF2DZ9l0+xryhwoe7PCuSmC@WP&8^x4|ej5+6n2%v5ERnO1 zusE!@g_K_p6d#TR>Na(2Zodz658rZx|M>BGHKv_GFZGEQKtle&2G8p`L+xlovW&b0cJw5uS(Z(<^Bfj@XrI+7i2(GnQ)Zq`*lKLiBdZdH|J&~S01Y?o?D1q29D1RqJL6!T)sX*`oN7i`4q47#soC5a1lTOAzS z+eO9#d-=4N&X-KoZo;B`1RtA+W3cyPn0xvv)vKb1FXZzD3AMGg9rDP-+nshGVNr-; z{a@~}5cA|G1-NfPt{V^E&Q%?PU8#^N_9iO4iAfs>!=eB}728H(>vHu&(AYaL)PO%- z3|D_#0f0FgphW702N!;wg1d_9zw+r+}A_bw`cdE;bG(J{l74p zNLwKXcCt82rA`@g;|17nYZ6`Em&|i$9%swJQ5e)FcP9%l(P>XNAk(+!JFQL+Mz5YW zhgpZ#BH47N3<2L3U8Zk@VjCPEn4XYQ{k1Sn9d1tto#~#R8~K4Z=m` zB_dOVv(&2+Un|hJ4At}{|M~|t71i!e1o23rXrALSA&U`UB!M{L{p}^)7Ww%2_-M76 zp3`wtFEOTp?Nh_q?hj>kJ)AHxpag6uvG?k^FqlZ}G*IhMOsl!}C}J}4xgDW?4%DA` zNJ>s_sPnQXrbf5*8(;bXyUjd}+tEzFYP82w(gDa|jVX@%w8F3*Mgh%DZOUrz6y0AQ zAY?9MX2Gx=$g-kU@L^7N2_@r@hk@&8be4u2f;JUFQzA)?9$t<)z~2j=^cjEy{;FRaFl;cDOqYkkDL?7M1wKko&1HI|2c3on*Feg|uu8J5M zZJUJHFvq1%Mh#z#>{aCP&ytxd8n>3VwQxu#i|2*j*;X&{R)DS?HKcG0gSH>Tt2)XCKy7GF>|Due z`GNuKDuLrG6xU_EYR=u)=<-9#^!#5TAWZS9g^0Sl$8mS&U%uXQwye}{Y{~5sDbv}G zwcXd!D=GV6G8yT5_Rfs@^>So?f!?zf%lu(%L%$v6Z8CClR+oLL&-&~2uyMVyw%qCI zSX@?CWY`<9++>a6=TCnaX4bmKYQOqDhsPB*o70}o<989n|H_jjWqUvpmppVcm z#!x}Of3u*9kVvBwAWstoP%Gs0Eg1&tJJBcbO?X^CW9O%Ede)^iKzoIXt9%*KZ6@_) zi~Dtoares>Jyv-uzbBoY3zQoOl)sNUmKBqoWQKRXK#n$8{o8t6X1H~c^J;l$xdlZ&yJo~go<^9qu{%vu+UItNnT4r30N#< zKVd${F?huI>H1)~-IcoM`Gh{<{NZ9F=6J5mbTpn2YfRVW{nh)ky_IX$5mCu!?H`)y zVP;SMRIk$5X`B`mJzcC}^?!C78cNAqQ8L~=7#VPc#NU3tI|VQ5IvS6sF~WH3P|0V7{rMv$m(5wD6&Ma*rjdL6DAckiR{?qnEU{hs z0khW5dTyq9BsS6pn=<`0pymr!?dm5}r`Wh#$k9hUeMBaR>LuurjKv97{PBhfn#!d= zEQ15&^16#`bhy`>{YB>o4`Cb-)fsgAd=9CccFRGUhW=Q`$H$+nCmyFATxfq#P7Z;Y zxw-LZJmK#ZyQ8&M=Fi7;n#VkT#LBk3<}lk4lK()eGwyPPkU?$XaXsv_Xf%GlJ%%xd zg8eF_pn$x)yZhVw#^P+f9m8@uZ}@DbG3L%PHfqUkEx$AOH^e5^g=5tDdWJ^>xf@-d zII8*-f$911kA2We7e<~<>JzzS6ATmHCrGbUui~fs0wAHMN6h7QANV|CYAa=BW#l9z zfRwZVpHD$E3WM6|`PO>*+#_Ss;_>OJ&~!AO$RFD=LR9X}Y$b{}|F-tVz8mf)G=^4r z;eOuXGp&pV(<0^LpkUwN+*4eDhyh`0l2uhTHQYG z%BJ4xB(!qe;!z!FGFmGR>utfc`kf||SsYd~1;J%h00&;@PCceq@F?+&)w(M@t=yyQ zLT3EictZyS7AiMsW9~}kN(1hfbkQ9c>Kzvs?oFF?q;`d(%U{6$vID9gZm6yX8v;;=ueV7XQ*CCGG;SOMV_2AFlN0ZxSLFyz|x*Te>6daf; zg*%gH7|NR| z=!_l%oavHdDSfmsLik5A*3yH64p??C=Cd3ho=?fYpqhFgI_ttU1_z@JRDU%JW5G8y z8jjlpK#ZFkT^oM1V2EU>41F6FGOGcf`T5^=(5LcFt329@`$o}HB-${zzMV1}`nNh# z;LYye(j-$|7Y&%3j>e0pGwI9RQuBM9;q3127R()A&4{AxJc06=Rq?J*_M4XsLK((C zg%=q+)5aC2E&m7Oo5RV-$jClf=I$`trCQ^^Nt8+zYQGWxI{NuGY;qmowyr~tYLhQ$ zAJ^X%mdmRhHY3c#itoe{?71P7D=0^1CWD^OOqZWID=!U^x*q7$Iw0QuefZ`j$L30b-A1P zQ{BR;OR5qQ-c(Ga=;E)Al;D4tc78bT0 ziXtc^B?b2xe8YgJ5)qkrV4GfTNL56Q27R>#eM4iTapMX?E#dvm4O9sxDJf~WekbS2 zVvX-w=htct`cElmp-QvJpxcMCWv$(ndei5W(Qd1!ds*YTaoR%L(EILSKkcEhY;-~? zo!)DO-su6*a){J&)AlRFINL?%rJ!6U8%zu~ z^G}JQ{+G+%%WpefHVbssTJ^x~Z*vWwqmzE8hw zdK|dt(pibTgBmTTP5u>2f3h$g?x*~)rjT6cn$!F@v67OzemM%%oc60>?x%~Sf`UX4 z5D-WIN)Sy3o#Nl~_#!?~?7TJ@?Z^tm0PsxOBq+ z+~D9~BBXT>(x*E|#YNqtLfyXpdwNs>*S9jX@)bTqzF>POa7$ zuR2QOS#=}S2Ga9gZ})55?kAX=-XOwi!(Jl!Y)*H+B}^(-Ry3$D$V2_1Ut$iV+Wnr` zEGDx)L06GtzM$9RPQ+}XXoN|{AzjR8^Q!B<$6IZ-jW8ciGwut5&}wziuedM@e1D>r zNFa)X`htwfw-ps_KMX7?oZkAI(*>z1zK@u-`!xq)w4qgULN+su?_mJb_QkDHPomys<7Uk z?*?a!B*ELED7$6(0d(~>K$=g~EckjgxXbD=;eFKF_!gnpNKBJHuBTin{^?LX9;NOc zrI7yt^k4$rMxFWt1Nb#{^1Jo}c1%3xBr||E;*ab3{`md|{Mda0kz_|!QwOsGn3mgW zBYnyBNL7MZk|3Rw;IP5Cwunyq*W`#U6R zW&K2tCJbYK+)Z(Te|GU^J=BxobNm52#P(keu5VXEN(`$_hP?q9Z00icdPGllYb{9A z{5M|-kiUG2JV!@IootrMn6o|pz63lj`z`i{UOO;3{NCtX4(oj=KVE}=B00h!o0*vv zeA4aRzOC6TY^7#~^|k(1<0Yd^UOpDy6)wT9+7~zDo$IFsiyvl&n;AKo>GPM7oR3nP z!^ij}`#%$cfEN`kVM-!6IMoFM8VbqX2H*U7S;7~u9pC#O&yrOQe~N^@Wd*veVOpxk zea5P!?0KB2x56koZq9Owb1A>Iqywa`OjO?4BQBqRksX~zO+i7z#Dok6gNde%z+iA> zT-9JDZL~>dYfcDBf&a@rw@;JS&mNOM4Y6N{{ZPAo?goJ3F;`Q3mqs%M z*(;PpzGtkr=WE#HlJN}lp*;W2d?RZ3=0HyE1_hXv{5pNV)&l@L#c$7lIN;l(pvZ8Q znfM&O8v<_)9YP)fgRG=V#k% ze>|ePKKl!il(9>=g<*KIm0nC|PYGomVG#KA?r^<$60# zvlgz8+i}A$Yyb|tv|FBA{#w6{Aat6nl$GAsU8jwQqp^k|xyJ|a9lL_5k{|I0m@b}~c|PqIDVPrI(G?em8!^Zi;$KM)miWK&KS z8{MZ_rCpkQnkC$EyboF-A8cTByZciu`^hkz#iFwDHH;cRW%D5^^j}UD$v@oQH1^f< zH>R(Ybl|V16O;$@TPgl$aa)awJ>Lg?9Q>FqHRi_dMj2qlXOvGfrJ)$g$}W;%ZnO;Y z!SR7N54-Qy1F(Q>=Aqog{Vo%p3$MJ<^>$bA1p73mSKud|(ffLhnCE-Zju4lcmZm|Q zM^^%V+)LQI+8LCRkrAmg=tjlGjlwqcL*wBQ_rHwdoG3fpy>L(4;>7aNuDE5S{B-PD zd(rc79SLjC@&hC$!Z18AHs7nLj?U=gn>gtgehz)G4>SCrf>P3Mtfh(XQz=4-D!%)Y z+z8Q!bN6NqC~J)G{TFmSRit*nP2DM{Zzj1Nb3fFNbS~{|2?tuAw07}$g86Xy-SunQ zeAvTyorVgRsJJ7=y63KUC6H^REnVvG#*1@UyC(|f(=UgmTEf*P>rm$2S9JNT@BI

5VySj`=i2fY+g9vSPI8$qlmYP4v1xyMOjPUwc%sDBcg2ZU?C49pKp#j zQ8i%sS;MQvoigNI#8{Z}&FTcFu~TXPx!gnvc{?)OBLSU?MIutM`>7M+3>Lsjxwz$E zzbTD&SKrV*GKveY2+c}H0PJdunbY60FK>QU1pMjbZG$fxYd$8YyLh@>du%KLq-ivL zY{eNeAwS*MRZKG8Udw&wUJXK;Hi&L|h z5cU52(|jwqEHBx^i+1D}{^hhC@pHfy@FB#~%&hOgg_MpCfnYTCC~Fu;^m4P?`P%I7 zV$MHEVS0=_5Uca9SouP0k4PviQb>M2@$>z8OtbA0(2}?}4FG@({QNI~LM|_iJM?#! z)rWp8zlkx)fNt)6Iq}h&8QiIZNGlXF$|GQ49Ay|R;Csr|fX>wvIRd%dn6(XE1vB{= z5}ggA$dB^k1$v_ri_~4OHKg!gco!%@AC{8&fafE3&55}G1uzKD_dZ`1&}jYytzY6u zN}a12xJbfV=YY$d#bkB)RA~QlE~T%|VNA{~T$YiMfkA@?;k)m?Lr_o0Ku|F7??(IO!0lvVJ2=nubi$c4eYzd~RN?2y(U04aTH%luD-vZ$HZWCLA+_Z%!se zx+E*Q0=EFkRU@1dCAri|*0`7;+G1+DtPS-V^O?UgDRW^^JAd)ip0h@(vs1gbU_xLA zMNze93f~rh9h`Uo)VYp{=9dA;D&sZJ5tUww5o+wA?Gv;7lft<{^78U9aNt0^`R1F5 zjg3W2Obpt#ZChfXYGPtyu{!~(3YW`OEImSv8a1HNXpoqg=#~w-bLY-jx^yXq3>kuz zD_5dXqecaFgiv$1sp}7e8todh=5!ZN$tg>vLIS4?-<7WNJ<-jc+^4Q@^l#_?3MDDH zo>$0>(Yh?{>q&xt119O3DuVBLIlXOTS9;qV5G9pJ#GG^H=_6Y6nPZBi4I@l0Rp4|! z2W?K$>yqO}$CeuNnY{qO7!6mea*~0YrUNOhOH)M*Xsbx7E`X{n04t$FJfY6td1Km# zes$NJ%=hweS?-&qh)!7-bktVLuwMS6U)^=hYMQ!!UijidQ4}$B=unIpF#>}J4+a2) zhK8bDyLLEm;DE>HSglr%E>(9MNs`d9V@Di5T->BKaNs~}+_(`FCQNWEZ`ZCJmM>q9 zAwz~>&6+huGM5oUpsv-o4>$f}+^SP;%aw(@!b%g~V3B<4tvKW9pr#JE=u`(>JOSVY zfFFUInT+i>de@P1Yjy+knJ%~jr--TDn}aeZ0lQ{7HDwglr~FZ(PbDWDA`btyaE8QN zlKZp*qcxqm;g7kFy*?9+YXYJqQcgI?YjS?%d@j9U2;b}`#8RP&YJB%+SM%Mw4-WAMbhkEnJL@H#P9b9t(n$} zfq{Xrt^R!bq?vC+;sXvV!PPN;3?#s{$6JSZk ziQ_nl;wP{aRQJxGg+fsj1mWS1`w4=8nwpxhI(Bm0ym?brK*VS?VrXaxJkJN|nBT)} zHsj>Ulc=hy!kIH?f;LhBFgmrtUc+A5&@q^IJQ81G(g>C?vnM$J6Yo*CO@Bco=?NL< z_A=^``T0@H7Jeb?C4r0IF7ldpIG>@BfItL4Lm=D$5e1r=IVAFGR;0Lpk_(O;j)nj0 zfn8fV-wzjTt*xyX92~^ivuDG0@uDbdISC#LP1CZrNbz_)SX_)wyvXnOOV)3J{f>rf4QMeG>Nlc=nQ5)*BK8*8rRVfZM~bZ;cmbeVQkGBx4m;hs>mxKSD_JRF}(zg9i_y zudfdpjYI`(nU$VmEX&G1MIvQpW=5jUdOui}gem>_oU+=&AR z4xp^83_U$PNK6c>BqTcZ=1(e&{S)6_E$)+>BZk@nV~feAkEJWs`;ffZ;eHg}C@fJ` z;C_`M2|EI>jgF4OX0yTRbguZr1pu8+hv{iKeaweP5QM13LySh_iV1a-$%Lk+rikXU zTCJF#o<><&8M?ZgsA(ES8AodDH=CwOUtfardAkCMLq?i##pc z*w_fYULUscEg^LJ*0g<}YU9!*+n!3mDF6T}@?38-bi($)Yo|_~!og($xp2uJ? z!0Yv{x_|f3>2yKW{HsMyPEOQ*+wFF^-EOqCwMlF=iGQIYzhCmjDFLT|U4{;$R+Spm zgmHLy7|qSiXm4+iYR#0In!08M$g4?ZWo6Ww1JpY#< z-Dagt-a02s`=WY}@w@Y(QoM6>bC{f*49z(ygkfZ41huub=QRII5{`!u+W3&DIf3C_dh`vZ_wg3PC07*qo IM6N<$f(lzd*#H0l literal 0 HcmV?d00001 diff --git a/app/images/project-logos/project-logo-01.png b/app/images/project-logos/project-logo-01.png new file mode 100644 index 0000000000000000000000000000000000000000..f849170239408b6728d91eaf75bd2a20baadc8fb GIT binary patch literal 1167 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}9Bd2>47O+4j2IXgSc;uILpV4%IBGajIv5xj zI14-?iy0VrZ-FqQx5T&C3=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNHH+5@OZj7hE&XXd)IJ9jH^W3!}lJu1YCTT+?ph=9C8biQ1NhZ6sVRKJ$UKXA-mG7 z-3<|$svWI2vL||7abwYK$_R1sRbp|J?Y<#poNe~~UHLrrZNJmgAM6w7n{#(FCj$z2 zGb439|AmyELaUSQlTXi!p7!*R*}2Vb?B9Mdr1Gik%x*Tlqg4LpE8~MOA(K!RUs1T9RG7qm%(iul6_&HKVCFEwoqlc`gGZw$0GkNf1KPCu6BLTex1|$(UxcJHqTlx z`OfSoTld->(Je4K87;jg()EI#8pLeAzo(oLPcMtGOOt9$1{J0|4jQU z{^e+Ds_}=}#&goA#&+_#f z>g9em*nQ3vR}8TDif-SR|K_)fyw92VlV6{=o~bD9`S|9@pT(2k@7HSjmv79UIkR?w z_avK-=ewg1sInsmGAz6!Z}(1B_U!XJ6Yu-!?zD58R(5;F&5QYb(Ts1C{94@?hdt$H z&e%WYulSd(sgZW`A`Sj`i|js)-6(KUnY#7vuYI$g-0u8&fAXAJGxpn*&rvzqsuJ&i z=Dz(gi~R@fR&Ut0VE_N;rJMUyD>IV*J-AaiN#*(U)XL9k?Z+k7t6OeAXt(;oq-JdK z2#(!TdiQ_H&8$E0!^pDU^yGBm&(n?nfB3|HT%zCqjQsqX|0+MTPk;V>?2UXydat3M zN@eot+cBP>9!>jxNW1&5o$v7BODwP?wCS|G{M=H@le?quJ47O+4j2IXgSc;uILpV4%IBGajIv5xj zI14-?iy0VrZ-FqQx5T&C3=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNHH+5^m)2ChE&XXd-q^>XsX2VkM|cpoxwcSQ%~tiZ})^UN2d@MNyT94a9x$yo6B5h zD|Gy8+{WglvL$|Blp}1XJWtxHrS><`*`M#j@|L&_x5+YAG_BuQTFrlr1bgHH_xrIXGoaDAr*!n zCB5POGyRN>!pp;te(dbn5f$-y$&%#Ouk)sT2}|^wWTtXg^UU1-4*y$xsn5D6ofEq@ zdC7OBM_z?x1;O^Y+m&7x6qflH=7nAgKDu+&-lIF!t7)C?;WMx(^q)wU5-o5h6kqVy+k?uOXlt~HF1|-AN^P?7`Sup)wr~EtLi4d2^TEf zc>L{`Re={~?o@l}lG@l|5j%5sb=W28qc`bxBtbSp*>F(1~FJ3tmuDfV@VUP1+E=K~$>+)gGr{@oxKGVN?^DNnpUt2Az)r{YZ zJ)5V!&b;lq(dX_%ceo~E^d1j^K(Zxq(O|vEt69So47MaeM^mX^WTealx+wGqxOy%1=`}6X(7e4)2 z^ncyW^UEG(q`ltkIcdxG9rv2AneN`VYvpU-e-{4|06@3WdeS;#eD YF}JmI?Z21a9|2O}>FVdQ&MBb@09XT8w*UYD literal 0 HcmV?d00001 diff --git a/app/images/project-logos/project-logo-03.png b/app/images/project-logos/project-logo-03.png new file mode 100644 index 0000000000000000000000000000000000000000..c3f2f8337bfaa001d2c8597c824ba48935ee4be6 GIT binary patch literal 1030 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}9Bd2>47O+4j2IXgSc;uILpV4%IBGajIv5xj zI14-?iy0VrZ-FqQx5T&C3=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNHH)lH+#A`hE&XXduL;JOsK@Mhu=L!e3_Co8>cX8cS~Gw+F+=Vk^fL$Av0%-K(*kO zzJ!@-txFRWwHO5hRT7qRh%s*Dh~T)z+Sl5Vu!ojhx67=~d-LO0 z`s6+PDxN)7*Tdqs!%q*Vg`RT)ET^%GAVvpH=ep?>&Bgw6ag(#qZbW4Ubhv>ldD|aYjp6NLENj>wi#~U2He| q$;npb{k?|!?q0P+$r20>3->eLRyZVn@Mo?$NXFCE&t;ucLK6T1w4jdw literal 0 HcmV?d00001 diff --git a/app/images/project-logos/project-logo-04.png b/app/images/project-logos/project-logo-04.png new file mode 100644 index 0000000000000000000000000000000000000000..a33c622e107610d6924d3fa3e016177b267d5bd9 GIT binary patch literal 1467 zcmeAS@N?(olHy`uVBq!ia0y~yVAuk}9Bd2>47O+4j2IXgSc;uILpV4%IBGajIv5xj zI14-?iy0VrZ-FqQx5T&C3=9mCC9V-A!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2 zNHH+5T6nrRhE&XXdv|}fJgdakTEsd#fA! zW=}NAF+GpmUKcmj|dA8ujkUJ~4vwhwF=}e9HrIPOH zGbWYnsW{{0qGK+5?u@yrZK%cnn;jNmcUErS@w7p-sSOvJ8fSlc;>>ap{+sttDc#+s zZ!E6G?s7ytSNHO^ICkBaR}1wG*p{pA4Vh6Nb?N!h>@VLW=kH^;wGiB>n7R06OujC%69&};rkVS^KHbZKe-zVC;+vi2X@?r9Y|V}Ho4wByp@c6oWPYCS)< z{#^UWZm-#v1*`7|3TFMgrsH|)(a)uG)fw`&&b;sMSnsyuoI=5AMx+=+^&tzdcJ2Qc zzoV|+W8b9uvj5PZSxep?`qQ#x=Ak_*wF)nb691h(b<()lZEOFv{?K#xO>6mo*&B z%C)0=G>rLIef#5;y6yWfxi4K=_8S&YlG8o2e93n&>8<&}Tc3x=P5u&)_^)M&xVLnv z$NlCw{d4k}soi8KY;pIDd4=TUM*XR2-KK{9M?q784&zWaxmKf~UeL4NmpQyxb#Z`ZG+(Xm$DZH%co<2kB;UDg))7es= z6@8R??{*~l=-jIAXg+8TgXA+b^5yT%GJMO7i?uex!eGAwt)Z3Sg>6<4m;my>{dOI%=X}xrpKE(vv6Wg33}24eKc4rFVWIn5H;@8PS3j3^P6=iGZv9mf0W>6q#O z0HEi$9~S@s!0q+c(f|O!qhG@b006aQZ$E;TmX=nwRumfm0BzcSVln{ey;yIRgT@PQ z0RS*b@d-{jl1NQqL?lH63pQlj*~oqywYPMW3%BJ(lwwpg}gdoC7JN*Dja&)#X9v|jq6q%Zo=)>f9_Je z*qd^vI(qcio&JU)b-~A)DCI+^^8!)-4Gvrys}PV>?6=?Tc5+!QfHEFSy9j_4G>6Lp8et%oE+O8GyeYj}7pj*n5wagSgqU zQ7A}sLZuE}L(nTsg@PKE9)GD3G51n3Jfq4m8dr7KI&^)IdGbrje4=-&IIKwEUY9;S znN*~PPH=rg74s+y#Vvm%Ps0k`eO%iEKr%`J+sNC3SQSK?K?H5em9X=|q@5$8_O;*cdCY$%Pds-pu z)UfiYUXd^BZ~l~T4+qax3!yu_?_OvF6^K6Ir4?oCSnWjMM07}h9I;EXYyD6dKDami z=OAa;s0rv{$Fw7S;rc#lOer6Fa8;Q9Du9=kYC%*rY>8*+HdKK*dg*_F9uC;tV8M-u zw2FAX5!iWtD7mhte#jtBD0o2U`EHYld5P51Z?ENBNoGpPRHnt!KDsOvLC>Vi#(K(F zO?;7~+qJe#yu1k_Dr=z4;%Z8o?JNPavb&)PR1%J$-`$^VW-q1MfRF=h++chU647lZ zRQl1Sca|y-fAfRZcPI$KD?Hv~OIl*2yZBQkeD1~IC(DDJphl<-)_T=ASAWR|v+`Jw zT?K}zbNzD4OvMgRMm(k?gDiV0;8lW3C)#n$3KO#kwbyZwh3rSVrO;beJgkvJ;N14W ztR3$m@|Q}yQcLCb;voK%8)EJuC2ec_W<^x0i`9Yr^D$CQc^4Etf3Q5{=3p$}yJoX) zT_F1Qm%#N_ZVK$Z^NUhwIiIqSO@`0XF}Xg}qFTB}LkF0H!q%gyEe6RYJKMZ{aZQJ# zq(74H^(-}7Ni6sxIBe7mRL0>*f!t15dK7tq!?M+Bi-ci~C`oxw1GfCmes&>elUyA0 zjo|rceM%@^@m)H@vKxi1F^d{C1$DisdtkK=I23fAB64=LftG)urD3`>|3c>L&LU=2 z8_K~oGg=mA