From 2dbea30842ec63a68055245fe26633bb7913daf3 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Tue, 12 May 2015 19:20:58 +0200 Subject: [PATCH] Renaissance _,,ad8888888888bba,_ ,ad88888I888888888888888ba, ,88888888I88888888888888888888a, ,d888888888I8888888888888888888888b, d88888PP"""" ""YY88888888888888888888b, ,d88"'__,,--------,,,,.;ZZZY8888888888888, ,8IIl'" ;;l"ZZZIII8888888888, ,I88l;' ;lZZZZZ888III8888888, ,II88Zl;. ;llZZZZZ888888I888888, ,II888Zl;. .;;;;;lllZZZ888888I8888b ,II8888Z;; `;;;;;''llZZ8888888I8888, II88888Z;' .;lZZZ8888888I888b II88888Z; _,aaa, .,aaaaa,__.l;llZZZ88888888I888 II88888IZZZZZZZZZ, .ZZZZZZZZZZZZZZ;llZZ88888888I888, II88888IZZ<'(@@>Z| |ZZZ<'(@@>ZZZZ;;llZZ888888888I88I ,II88888; `""" ;| |ZZ; `""" ;;llZ8888888888I888 II888888l `;; .;llZZ8888888888I888, ,II888888Z; ;;; .;;llZZZ8888888888I888I III888888Zl; .., `;; ,;;lllZZZ88888888888I888 II88888888Z;;...;(_ _) ,;;;llZZZZ88888888888I888, II88888888Zl;;;;;' `--'Z;. .,;;;;llZZZZ88888888888I888b ]I888888888Z;;;;' ";llllll;..;;;lllZZZZ88888888888I8888, II888888888Zl.;;"Y88bd888P";;,..;lllZZZZZ88888888888I8888I II8888888888Zl;.; `"PPP";;;,..;lllZZZZZZZ88888888888I88888 II888888888888Zl;;. `;;;l;;;;lllZZZZZZZZW88888888888I88888 `II8888888888888Zl;. ,;;lllZZZZZZZZWMZ88888888888I88888 II8888888888888888ZbaalllZZZZZZZZZWWMZZZ8888888888I888888, `II88888888888888888b"WWZZZZZWWWMMZZZZZZI888888888I888888b `II88888888888888888;ZZMMMMMMZZZZZZZZllI888888888I8888888 `II8888888888888888 `;lZZZZZZZZZZZlllll888888888I8888888, II8888888888888888, `;lllZZZZllllll;;.Y88888888I8888888b, ,II8888888888888888b .;;lllllll;;;.;..88888888I88888888b, II888888888888888PZI;. .`;;;.;;;..; ...88888888I8888888888, II888888888888PZ;;';;. ;. .;. .;. .. Y8888888I88888888888b, ,II888888888PZ;;' `8888888I8888888888888b, II888888888' 888888I8888888888888888 ,II888888888 ,888888I8888888888888888 ,d88888888888 d888888I8888888888ZZZZZZ ,ad888888888888I 8888888I8888ZZZZZZZZZZZZ 888888888888888' 888888IZZZZZZZZZZZZZZZZZ 8888888888P'8P' Y888ZZZZZZZZZZZZZZZZZZZZ 888888888, " ,ZZZZZZZZZZZZZZZZZZZZZZZ 8888888888, ,ZZZZZZZZZZZZZZZZZZZZZZZZZZ 888888888888a, _ ,ZZZZZZZZZZZZZZZZZZZZ88888888 888888888888888ba,_d' ,ZZZZZZZZZZZZZZZZZ8888888888888 8888888888888888888888bbbaaa,,,______,ZZZZZZZZZZZZZZZ88888888888888888 88888888888888888888888888888888888ZZZZZZZZZZZZZZZ88888888888888888888 8888888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888 888888888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888888888 8888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888 88888888888888888888888888888ZZZZZZZZZZZZZZ888888888888888888888888888 8888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888 Normand 8 88888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888 Veilleux 8 8888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888888888 --- .gitignore | 5 + .jscsrc | 77 ++ .jshintrc | 82 ++ .meteor/.finished-upgraders | 8 + .meteor/.gitignore | 1 + .meteor/.id | 7 + .meteor/cordova-plugins | 1 + .meteor/packages | 53 ++ .meteor/platforms | 2 + .meteor/release | 1 + .meteor/versions | 120 +++ .travis.yml | 7 + Contributing.md | 57 ++ Dockerfile | 9 + LICENSE | 21 + README.md | 25 + client/components/activities/activities.jade | 8 + client/components/activities/activities.js | 77 ++ client/components/activities/comments.jade | 0 client/components/activities/comments.js | 0 client/components/activities/events.js | 30 + client/components/activities/templates.html | 154 ++++ client/components/boards/body.jade | 33 + client/components/boards/body.js | 70 ++ client/components/boards/body.styl | 54 ++ client/components/boards/colors.styl | 34 + client/components/boards/events.js | 96 +++ client/components/boards/header.jade | 87 ++ client/components/boards/header.js | 7 + client/components/boards/header.styl | 137 +++ client/components/boards/helpers.js | 45 + client/components/boards/list.jade | 14 + client/components/boards/list.styl | 85 ++ client/components/boards/router.js | 34 + client/components/cards/details.jade | 47 + client/components/cards/details.js | 103 +++ client/components/cards/details.styl | 161 ++++ client/components/cards/events.js | 285 ++++++ client/components/cards/helpers.js | 48 ++ client/components/cards/labels.styl | 183 ++++ client/components/cards/minicard.styl | 136 +++ client/components/cards/popups.jade | 12 + client/components/cards/router.js | 15 + client/components/cards/templates.html | 336 ++++++++ client/components/forms/cachedValue.js | 22 + client/components/forms/forms.styl | 636 ++++++++++++++ client/components/forms/inlinedform.jade | 6 + client/components/forms/inlinedform.js | 93 ++ client/components/lists/body.jade | 50 ++ client/components/lists/body.js | 73 ++ client/components/lists/events.js | 16 + client/components/lists/header.jade | 13 + client/components/lists/header.js | 25 + client/components/lists/main.jade | 5 + client/components/lists/main.js | 81 ++ client/components/lists/main.styl | 136 +++ client/components/lists/menu.jade | 28 + client/components/lists/menu.js | 46 + client/components/main/events.js | 8 + client/components/main/header.jade | 40 + client/components/main/header.js | 10 + client/components/main/header.styl | 266 ++++++ client/components/main/helpers.js | 63 ++ client/components/main/layouts.jade | 17 + client/components/main/popup.js | 16 + client/components/main/popup.styl | 585 +++++++++++++ client/components/main/popup.tpl.jade | 13 + client/components/main/rendered.js | 40 + client/components/main/router.js | 5 + client/components/main/spinner.styl | 45 + client/components/main/spinner.tpl.jade | 6 + client/components/main/templates.html | 18 + client/components/modal/events.js | 14 + client/components/modal/helpers.js | 0 client/components/modal/modal.tpl.jade | 5 + client/components/sidebar/events.js | 93 ++ client/components/sidebar/helpers.js | 51 ++ .../components/sidebar/infiniteScrolling.js | 37 + client/components/sidebar/rendered.js | 21 + client/components/sidebar/sidebar.js | 55 ++ client/components/sidebar/sidebar.styl | 154 ++++ client/components/sidebar/templates.html.old | 307 +++++++ client/components/sidebar/templates.jade | 103 +++ client/components/users/avatar.jade | 7 + client/components/users/events.js | 59 ++ client/components/users/form.styl | 50 ++ client/components/users/headerButtons.jade | 27 + client/components/users/headerButtons.js | 5 + client/components/users/helpers.js | 27 + client/components/users/member.styl | 107 +++ client/components/users/router.js | 29 + client/components/users/templates.html | 118 +++ client/config/accounts.js | 35 + client/config/avatar.js | 3 + client/config/router.js | 28 + client/lib/emoji-values.js | 152 ++++ client/lib/filter.js | 133 +++ client/lib/i18n.js | 22 + client/lib/keyboard.js | 55 ++ client/lib/mixins.js | 1 + client/lib/popup.js | 200 +++++ client/lib/utils.js | 96 +++ client/styles/cheat.styl | 79 ++ client/styles/fancy-scrollbar.styl | 45 + client/styles/main.styl | 814 ++++++++++++++++++ client/styles/temp.styl | 110 +++ collections/activities.js | 51 ++ collections/attachments.js | 79 ++ collections/boards.js | 251 ++++++ collections/cards.js | 287 ++++++ collections/lists.js | 94 ++ collections/users.js | 106 +++ i18n/de.i18n.json | 175 ++++ i18n/en.i18n.json | 182 ++++ i18n/fr.i18n.json | 175 ++++ i18n/ja.i18n.json | 175 ++++ i18n/pt-BR.i18n.json | 175 ++++ i18n/tr.i18n.json | 175 ++++ public/favicon.png | Bin 0 -> 16160 bytes public/logo.png | Bin 0 -> 13517 bytes sandstorm-pkgdef.capnp | 61 ++ sandstorm.js | 94 ++ server/lib/utils.js | 8 + server/migrations.js | 113 +++ server/publications/activities.js | 24 + server/publications/boards.js | 121 +++ server/publications/cards.js | 4 + server/publications/users.js | 0 128 files changed, 10521 insertions(+) create mode 100644 .gitignore create mode 100644 .jscsrc create mode 100644 .jshintrc create mode 100644 .meteor/.finished-upgraders create mode 100644 .meteor/.gitignore create mode 100644 .meteor/.id create mode 100644 .meteor/cordova-plugins create mode 100644 .meteor/packages create mode 100644 .meteor/platforms create mode 100644 .meteor/release create mode 100644 .meteor/versions create mode 100644 .travis.yml create mode 100644 Contributing.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client/components/activities/activities.jade create mode 100644 client/components/activities/activities.js create mode 100644 client/components/activities/comments.jade create mode 100644 client/components/activities/comments.js create mode 100644 client/components/activities/events.js create mode 100644 client/components/activities/templates.html create mode 100644 client/components/boards/body.jade create mode 100644 client/components/boards/body.js create mode 100644 client/components/boards/body.styl create mode 100644 client/components/boards/colors.styl create mode 100644 client/components/boards/events.js create mode 100644 client/components/boards/header.jade create mode 100644 client/components/boards/header.js create mode 100644 client/components/boards/header.styl create mode 100644 client/components/boards/helpers.js create mode 100644 client/components/boards/list.jade create mode 100644 client/components/boards/list.styl create mode 100644 client/components/boards/router.js create mode 100644 client/components/cards/details.jade create mode 100644 client/components/cards/details.js create mode 100644 client/components/cards/details.styl create mode 100644 client/components/cards/events.js create mode 100644 client/components/cards/helpers.js create mode 100644 client/components/cards/labels.styl create mode 100644 client/components/cards/minicard.styl create mode 100644 client/components/cards/popups.jade create mode 100644 client/components/cards/router.js create mode 100644 client/components/cards/templates.html create mode 100644 client/components/forms/cachedValue.js create mode 100644 client/components/forms/forms.styl create mode 100644 client/components/forms/inlinedform.jade create mode 100644 client/components/forms/inlinedform.js create mode 100644 client/components/lists/body.jade create mode 100644 client/components/lists/body.js create mode 100644 client/components/lists/events.js create mode 100644 client/components/lists/header.jade create mode 100644 client/components/lists/header.js create mode 100644 client/components/lists/main.jade create mode 100644 client/components/lists/main.js create mode 100644 client/components/lists/main.styl create mode 100644 client/components/lists/menu.jade create mode 100644 client/components/lists/menu.js create mode 100644 client/components/main/events.js create mode 100644 client/components/main/header.jade create mode 100644 client/components/main/header.js create mode 100644 client/components/main/header.styl create mode 100644 client/components/main/helpers.js create mode 100644 client/components/main/layouts.jade create mode 100644 client/components/main/popup.js create mode 100644 client/components/main/popup.styl create mode 100644 client/components/main/popup.tpl.jade create mode 100644 client/components/main/rendered.js create mode 100644 client/components/main/router.js create mode 100644 client/components/main/spinner.styl create mode 100644 client/components/main/spinner.tpl.jade create mode 100644 client/components/main/templates.html create mode 100644 client/components/modal/events.js create mode 100644 client/components/modal/helpers.js create mode 100644 client/components/modal/modal.tpl.jade create mode 100644 client/components/sidebar/events.js create mode 100644 client/components/sidebar/helpers.js create mode 100644 client/components/sidebar/infiniteScrolling.js create mode 100644 client/components/sidebar/rendered.js create mode 100644 client/components/sidebar/sidebar.js create mode 100644 client/components/sidebar/sidebar.styl create mode 100644 client/components/sidebar/templates.html.old create mode 100644 client/components/sidebar/templates.jade create mode 100644 client/components/users/avatar.jade create mode 100644 client/components/users/events.js create mode 100644 client/components/users/form.styl create mode 100644 client/components/users/headerButtons.jade create mode 100644 client/components/users/headerButtons.js create mode 100644 client/components/users/helpers.js create mode 100644 client/components/users/member.styl create mode 100644 client/components/users/router.js create mode 100644 client/components/users/templates.html create mode 100644 client/config/accounts.js create mode 100644 client/config/avatar.js create mode 100644 client/config/router.js create mode 100644 client/lib/emoji-values.js create mode 100644 client/lib/filter.js create mode 100644 client/lib/i18n.js create mode 100644 client/lib/keyboard.js create mode 100644 client/lib/mixins.js create mode 100644 client/lib/popup.js create mode 100644 client/lib/utils.js create mode 100644 client/styles/cheat.styl create mode 100644 client/styles/fancy-scrollbar.styl create mode 100644 client/styles/main.styl create mode 100644 client/styles/temp.styl create mode 100644 collections/activities.js create mode 100644 collections/attachments.js create mode 100644 collections/boards.js create mode 100644 collections/cards.js create mode 100644 collections/lists.js create mode 100644 collections/users.js create mode 100644 i18n/de.i18n.json create mode 100644 i18n/en.i18n.json create mode 100644 i18n/fr.i18n.json create mode 100644 i18n/ja.i18n.json create mode 100644 i18n/pt-BR.i18n.json create mode 100644 i18n/tr.i18n.json create mode 100644 public/favicon.png create mode 100644 public/logo.png create mode 100644 sandstorm-pkgdef.capnp create mode 100644 sandstorm.js create mode 100644 server/lib/utils.js create mode 100644 server/migrations.js create mode 100644 server/publications/activities.js create mode 100644 server/publications/boards.js create mode 100644 server/publications/cards.js create mode 100644 server/publications/users.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..f86ef4fc881 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*~ +*.swp +.meteor-spk +.tx/ +*.sublime-workspace diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 00000000000..ce061dbec30 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,77 @@ +{ + "disallowSpacesInNamedFunctionExpression": { + "beforeOpeningRoundBrace": true + }, + "disallowSpacesInFunctionExpression": { + "beforeOpeningRoundBrace": true + }, + "disallowSpacesInAnonymousFunctionExpression": { + "beforeOpeningRoundBrace": true + }, + "disallowSpacesInFunctionDeclaration": { + "beforeOpeningRoundBrace": true + }, + "disallowEmptyBlocks": true, + "disallowSpacesInsideArrayBrackets": true, + "disallowSpacesInsideParentheses": true, + "disallowQuotedKeysInObjects": "allButReserved", + "disallowSpaceAfterObjectKeys": true, + "disallowSpaceAfterPrefixUnaryOperators": [ + "++", + "--", + "+", + "-", + "~" + ], + "disallowSpaceBeforePostfixUnaryOperators": true, + "disallowSpaceBeforeBinaryOperators": [ + "," + ], + "disallowMixedSpacesAndTabs": true, + "disallowTrailingWhitespace": true, + "disallowTrailingComma": true, + "disallowYodaConditions": true, + "disallowKeywords": [ "with" ], + "disallowMultipleLineBreaks": true, + "disallowMultipleVarDecl": "exceptUndefined", + "requireSpaceBeforeBlockStatements": true, + "requireParenthesesAroundIIFE": true, + "requireSpacesInConditionalExpression": true, + "requireBlocksOnNewline": 1, + "requireCommaBeforeLineBreak": true, + "requireSpaceAfterPrefixUnaryOperators": [ + "!" + ], + "requireSpaceBeforeBinaryOperators": true, + "requireSpaceAfterBinaryOperators": true, + "requireCamelCaseOrUpperCaseIdentifiers": true, + "requireLineFeedAtFileEnd": true, + "requireCapitalizedConstructors": true, + "requireDotNotation": true, + "requireSpacesInForStatement": true, + "requireSpaceBetweenArguments": true, + "requireCurlyBraces": [ + "do" + ], + "requireSpaceAfterKeywords": [ + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "return", + "try", + "catch", + "typeof" + ], + "safeContextKeyword": [ + "self", + "view" + ], + "validateLineBreaks": "LF", + "validateQuoteMarks": "'", + "validateIndentation": 2, + "maximumLineLength": 80 +} diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 00000000000..bcb1f6981af --- /dev/null +++ b/.jshintrc @@ -0,0 +1,82 @@ +{ + // JSHint options: http://jshint.com/docs/options/ + "maxerr": 50, + + // Enforcing + "camelcase": true, + "eqeqeq": true, + "undef": true, + "unused": true, + + // Environments + "browser": true, + "devel": true, + + // Authorized globals + "globals": { + // Meteor globals + "Meteor": false, + "DDP": false, + "Mongo": false, + "Session": false, + "Accounts": false, + "Template": false, + "Blaze": false, + "UI": false, + "Match": false, + "check": false, + "Tracker": false, + "Deps": false, + "ReactiveVar": false, + "EJSON": false, + "HTTP": false, + "Email": false, + "Assets": false, + "Handlebars": false, + "Package": false, + "App": false, + "Npm": false, + "Tinytest": false, + "Random": false, + "HTML": false, + + // Exported by packages we use + "_": false, + "$": false, + "Router": false, + "SimpleSchema": false, + "getSlug": false, + "Migrations": false, + "FS": false, + "BlazeComponent": false, + "TAPi18n": false, + "T9n": false, + "SubsManager": false, + "Mousetrap": false, + "Avatar": true, + + // Our collections + "Boards": true, + "Lists": true, + "Cards": true, + "CardComments": true, + "Activities": true, + "Attachments": true, + "Users": true, + "AccountsTemplates": true, + + // Our objects + "Utils": true, + "Popup": true, + "Filter": true, + "Sidebar": true, + "Mixins": true, + + // XXX Temp, we should remove these + "allowIsBoardAdmin": true, + "allowIsBoardMember": true, + "BoardSubsManager": true, + "currentlyOpenedForm": true, + "Emoji": true + } +} diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders new file mode 100644 index 00000000000..8a761038c51 --- /dev/null +++ b/.meteor/.finished-upgraders @@ -0,0 +1,8 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 diff --git a/.meteor/.gitignore b/.meteor/.gitignore new file mode 100644 index 00000000000..40830374235 --- /dev/null +++ b/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/.meteor/.id b/.meteor/.id new file mode 100644 index 00000000000..0556ccf7b12 --- /dev/null +++ b/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +dvyihgykyzec6y1dpg diff --git a/.meteor/cordova-plugins b/.meteor/cordova-plugins new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/.meteor/cordova-plugins @@ -0,0 +1 @@ + diff --git a/.meteor/packages b/.meteor/packages new file mode 100644 index 00000000000..5968f052ed1 --- /dev/null +++ b/.meteor/packages @@ -0,0 +1,53 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-platform + +# Account system +accounts-password +kenton:accounts-sandstorm +service-configuration +useraccounts:unstyled + +# Compilers +mquandalle:jade +mquandalle:stylus + +# Collections +aldeed:collection2 +cfs:gridfs +cfs:standard-packages +dburles:collection-helpers +idmontie:migrations +matb33:collection-hooks +matteodem:easy-search +reywood:publish-composite + +# Utilities +alethes:pages +audit-argument-checks +iron:router +meteorhacks:subs-manager +mquandalle:autofocus +mquandalle:moment +ongoworks:speakingurl +raix:handlebar-helpers +random +reactive-dict +tap:i18n +tmeasday:presence +underscore + +# UI components +bengott:avatar +fortawesome:fontawesome +linto:jquery-ui +markdown +mousetrap:mousetrap +mquandalle:jquery-textcomplete +peerlibrary:blaze-components +reactive-var +seriousm:emoji-continued +useraccounts:core diff --git a/.meteor/platforms b/.meteor/platforms new file mode 100644 index 00000000000..efeba1b50c7 --- /dev/null +++ b/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/.meteor/release b/.meteor/release new file mode 100644 index 00000000000..dab6b552c01 --- /dev/null +++ b/.meteor/release @@ -0,0 +1 @@ +METEOR@1.1.0.2 diff --git a/.meteor/versions b/.meteor/versions new file mode 100644 index 00000000000..5710788bc3c --- /dev/null +++ b/.meteor/versions @@ -0,0 +1,120 @@ +accounts-base@1.2.0 +accounts-password@1.1.1 +aldeed:collection2@2.3.3 +aldeed:simple-schema@1.3.3 +alethes:pages@1.8.4 +audit-argument-checks@1.0.3 +autoupdate@1.2.1 +base64@1.0.3 +bengott:avatar@0.7.6 +binary-heap@1.0.3 +blaze@2.1.2 +blaze-tools@1.0.3 +boilerplate-generator@1.0.3 +callback-hook@1.0.3 +cfs:access-point@0.1.49 +cfs:base-package@0.0.30 +cfs:collection@0.5.5 +cfs:collection-filters@0.2.4 +cfs:data-man@0.0.6 +cfs:file@0.1.17 +cfs:gridfs@0.0.33 +cfs:http-methods@0.0.29 +cfs:http-publish@0.0.13 +cfs:power-queue@0.9.11 +cfs:reactive-list@0.0.9 +cfs:reactive-property@0.0.4 +cfs:standard-packages@0.5.9 +cfs:storage-adapter@0.2.2 +cfs:tempstore@0.1.5 +cfs:upload-http@0.0.20 +cfs:worker@0.1.4 +check@1.0.5 +coffeescript@1.0.6 +dburles:collection-helpers@1.0.3 +ddp@1.1.0 +deps@1.0.7 +ejson@1.0.6 +email@1.0.6 +fastclick@1.0.3 +fortawesome:fontawesome@4.3.0 +geojson-utils@1.0.3 +html-tools@1.0.4 +htmljs@1.0.4 +http@1.1.0 +id-map@1.0.3 +idmontie:migrations@1.0.0 +iron:controller@1.0.7 +iron:core@1.0.7 +iron:dynamic-template@1.0.7 +iron:layout@1.0.7 +iron:location@1.0.7 +iron:middleware-stack@1.0.7 +iron:router@1.0.7 +iron:url@1.0.7 +jparker:crypto-core@0.1.0 +jparker:crypto-md5@0.1.1 +jparker:gravatar@0.3.1 +jquery@1.11.3_2 +json@1.0.3 +kenton:accounts-sandstorm@0.1.3 +launch-screen@1.0.2 +less@1.0.14 +linto:jquery-ui@1.11.2 +livedata@1.0.13 +localstorage@1.0.3 +logging@1.0.7 +markdown@1.0.4 +matb33:collection-hooks@0.7.13 +matteodem:easy-search@1.5.6 +meteor@1.1.6 +meteor-platform@1.2.2 +meteorhacks:subs-manager@1.3.0 +minifiers@1.1.5 +minimongo@1.0.8 +mobile-status-bar@1.0.3 +mongo@1.1.0 +mongo-livedata@1.0.8 +mousetrap:mousetrap@1.4.6_1 +mquandalle:autofocus@1.0.0 +mquandalle:jade@0.4.3 +mquandalle:jade-compiler@0.4.3 +mquandalle:jquery-textcomplete@0.3.6_1 +mquandalle:moment@1.0.0 +mquandalle:stylus@1.1.1 +npm-bcrypt@0.7.8_2 +observe-sequence@1.0.6 +ongoworks:speakingurl@1.1.0 +ordered-dict@1.0.3 +peerlibrary:assert@0.2.5 +peerlibrary:base-component@0.8.0 +peerlibrary:blaze-components@0.10.0 +raix:eventemitter@0.1.2 +raix:handlebar-helpers@0.2.4 +random@1.0.3 +reactive-dict@1.1.0 +reactive-var@1.0.5 +reload@1.1.3 +retry@1.0.3 +reywood:publish-composite@1.3.6 +routepolicy@1.0.5 +seriousm:emoji-continued@1.4.0 +service-configuration@1.0.4 +session@1.1.0 +sha@1.0.3 +softwarerero:accounts-t9n@1.0.9 +spacebars@1.0.6 +spacebars-compiler@1.0.6 +srp@1.0.3 +stylus@1.0.7 +tap:i18n@1.4.1 +templating@1.1.1 +tmeasday:presence@1.0.6 +tracker@1.0.7 +ui@1.0.6 +underscore@1.0.3 +url@1.0.4 +useraccounts:core@1.9.1 +useraccounts:unstyled@1.9.1 +webapp@1.2.0 +webapp-hashing@1.0.3 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000000..a2ceb090d7b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - "0.10" +before_install: + - "curl -L http://git.io/ejPSng | /bin/sh" +services: + - mongodb diff --git a/Contributing.md b/Contributing.md new file mode 100644 index 00000000000..cfd672160cc --- /dev/null +++ b/Contributing.md @@ -0,0 +1,57 @@ +# Contributing + +We’re glad you’re interested in helping the LibreBoard project! We welcome bug +reports, enhancement ideas, and pull requests, in our GitHub bug tracker. Before +opening a new thread please verify that your issue hasn’t already been reported. + + + +## Translations + +You are encouraged to translate (or improve the translation of) LibreBoard in +your locale language. For that purpose we rely on +[Transifex](https://www.transifex.com/projects/p/libreboard). So the first step +is to create a Transifex account if you don’t have one already. You can then +send a request to join one of the translation teams. If there we will create a +new one. + +Once you are in a team you can start translating the application. Please take a +look at the glossary so you can agree with other (present and future) +contributors on words to use to translate key concepts in the application like +“boards” and “cards”. + +The original application is written in English, and if you want to contribute to +the application itself, you are asked to fill the `i18n/en.i18n.json` file. When +you do that the new strings of text to translate automatically appears on +Transifex to be translated (the refresh may take a few hours). + +We pull all translations from Transifex before every new LibreBoard release +candidate, ask the translators to review the app, and pull all translations +again for the final release. + +## Installation + +LibreBoard is made with [Meteor](https://www.meteor.com). Thus the easiest way +to start hacking is by installing the framework, cloning the git repository, and +launching the application: + +```bash +$ curl https://install.meteor.com/ | sh # On Mac or Linux +$ git clone https://github.com/libreboard/libreboard.git +$ cd libreboard +$ meteor +``` + +As for any Meteor application, LibreBoard is automatically refreshed when you +change any file of the source code, just play with it to see how it behaves! + +## Style guide + +We follow the +[meteor style guide](https://github.com/meteor/meteor/wiki/Meteor-Style-Guide). + +Please read the meteor style guide before making any significant contribution. + +## Code organisation + +TODO diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..858fc07b4b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM meteorhacks/meteord +MAINTAINER Maxime Quandalle + +# Run as you wish! +# +# sudo docker run -d \ +# -e "ROOT_URL=http://example.com" +# -e "MONGO_URL=mongodb://172.17.0.3:27017/libreboard-test" \ +# -p 8080:80 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..2ae84cf584a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 Yasar Icli, Maxime Quandalle + +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. diff --git a/README.md b/README.md new file mode 100644 index 00000000000..174649620fa --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# LibreBoard [![Build Status][travis-status]][travis-link] + +LibreBoard is an open-source *kanban* board that let you organize things in +cards, and cards in lists. You can use it alone, or with your team and family +thanks to our real-time synchronisation feature. Libreboard is a land of liberty +and you can implement all sort of workflows on it using tags, comments, member +assignation, and many more. + +[![Our roadmap is self-hosted on LibreBoard][thumbnail]][roadmap] + +Since it is a free software, you don’t have to trust us with your data and can +install LibreBoard on your own computer or server. In fact we encourage you to +do that by providing one-click installation for the +[Sandstorm](https://sandstorm.io) platform and verified +[Docker](https://www.docker.com) images. + +LibreBoard is released under the very permissive [MIT license](LICENSE), and +made with [Meteor](https://www.meteor.com). + +[Our roadmap is self-hosted on LibreBoard][roadmap] + +[travis-status]: https://travis-ci.org/libreboard/libreboard.svg +[travis-link]: https://travis-ci.org/libreboard/libreboard.svg +[thumbnail]: http://i.imgur.com/IIdHUmW.png +[roadmap]: http://libreboard.com/boards/MeSsFJaSqeuo9M6bs/libreboard-roadmap diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade new file mode 100644 index 00000000000..1c6b9fafb52 --- /dev/null +++ b/client/components/activities/activities.jade @@ -0,0 +1,8 @@ +template(name="activities") + .js-sidebar-activities + //- We should use Template.dynamic here but there is a bug with + //- blaze-components: https://github.com/peerlibrary/meteor-blaze-components/issues/30 + if $eq mode "board" + +boardActivities + else + +cardActivities diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js new file mode 100644 index 00000000000..c806e87b833 --- /dev/null +++ b/client/components/activities/activities.js @@ -0,0 +1,77 @@ +var activitiesPerPage = 20; + +BlazeComponent.extendComponent({ + template: function() { + return 'activities'; + }, + + onCreated: function() { + var self = this; + // XXX Should we use ReactiveNumber? + self.page = new ReactiveVar(1); + self.loadNextPageLocked = false; + var sidebar = self.componentParent(); // XXX for some reason not working + sidebar.callFirstWith(null, 'resetNextPeak'); + self.autorun(function() { + var mode = self.data().mode; + var capitalizedMode = Utils.capitalize(mode); + var id = Session.get('current' + capitalizedMode); + var limit = self.page.get() * activitiesPerPage; + if (id === null) + return; + + self.subscribe('activities', mode, id, limit, function() { + self.loadNextPageLocked = false; + + // If the sibear peak hasn't increased, that mean that there are no more + // activities, and we can stop calling new subscriptions. + // XXX This is hacky! We need to know excatly and reactively how many + // activities there are, we probably want to denormalize this number + // dirrectly into card and board documents. + var a = sidebar.callFirstWith(null, 'getNextPeak'); + sidebar.calculateNextPeak(); + var b = sidebar.callFirstWith(null, 'getNextPeak'); + if (a === b) { + sidebar.callFirstWith(null, 'resetNextPeak'); + } + }); + }); + }, + + loadNextPage: function() { + if (this.loadNextPageLocked === false) { + this.page.set(this.page.get() + 1); + this.loadNextPageLocked = true; + } + }, + + boardLabel: function() { + return TAPi18n.__('this-board'); + }, + + cardLabel: function() { + return TAPi18n.__('this-card'); + }, + + cardLink: function() { + var card = this.currentData().card(); + return Blaze.toHTML(HTML.A({ + href: card.absoluteUrl(), + 'class': 'action-card' + }, card.title)); + }, + + memberLink: function() { + return Blaze.toHTMLWithData(Template.memberName, { + user: this.currentData().member() + }); + }, + + attachmentLink: function() { + var attachment = this.currentData().attachment(); + return Blaze.toHTML(HTML.A({ + href: attachment.url(), + 'class': 'js-open-attachment-viewer' + }, attachment.name())); + } +}).register('activities'); diff --git a/client/components/activities/comments.jade b/client/components/activities/comments.jade new file mode 100644 index 00000000000..e69de29bb2d diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/client/components/activities/events.js b/client/components/activities/events.js new file mode 100644 index 00000000000..ea98e65fc00 --- /dev/null +++ b/client/components/activities/events.js @@ -0,0 +1,30 @@ +Template.cardActivities.events({ + 'click .js-edit-action': function(evt) { + var $this = $(evt.currentTarget); + var container = $this.parents('.phenom-comment'); + + // open and focus + container.addClass('editing'); + container.find('textarea').focus(); + }, + 'click .js-confirm-delete-action': function() { + CardComments.remove(this._id); + }, + 'submit form': function(evt) { + var $this = $(evt.currentTarget); + var container = $this.parents('.phenom-comment'); + var text = container.find('textarea'); + + if ($.trim(text.val())) { + CardComments.update(this._id, { + $set: { + text: text.val() + } + }); + + // reset editing class + $('.editing').removeClass('editing'); + } + evt.preventDefault(); + } +}); diff --git a/client/components/activities/templates.html b/client/components/activities/templates.html new file mode 100644 index 00000000000..8d3ff763951 --- /dev/null +++ b/client/components/activities/templates.html @@ -0,0 +1,154 @@ + + + diff --git a/client/components/boards/body.jade b/client/components/boards/body.jade new file mode 100644 index 00000000000..5406ee2f638 --- /dev/null +++ b/client/components/boards/body.jade @@ -0,0 +1,33 @@ +//- + XXX This template can't be transformed into a component because it is + included by iron-router. That's a bug. +template(name="board") + +boardComponent + +template(name="boardComponent") + if this + .board-wrapper(class=colorClass) + .board-canvas(class=sidebarSize) + .lists.js-lists + each lists + +list(this) + if currentUser.isBoardMember + +addlistForm + +boardSidebar + if currentCard + +cardSidebar(currentCard) + else + +message(label="board-no-found") + +template(name="addlistForm") + .list.js-list.add-list.js-add-list + +inlinedForm(autoclose=false) + input.list-name-input(type="text" placeholder="{{_ 'add-list'}}" + autocomplete="off" autofocus) + div.edit-controls.clearfix + button.primary.confirm.js-save-edit(type="submit") {{_ 'save'}} + a.fa.fa-times.dark-hover.cancel.js-close-inlined-form + else + .js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-list'}} diff --git a/client/components/boards/body.js b/client/components/boards/body.js new file mode 100644 index 00000000000..2b4baf5392e --- /dev/null +++ b/client/components/boards/body.js @@ -0,0 +1,70 @@ +BlazeComponent.extendComponent({ + template: function() { + return 'boardComponent'; + }, + + openNewListForm: function() { + this.componentChildren('addlistForm')[0].open(); + }, + + scrollLeft: function() { + // TODO + }, + + onRendered: function() { + var self = this; + + self.scrollLeft(); + + if (Meteor.user().isBoardMember()) { + self.$('.js-lists').sortable({ + tolerance: 'pointer', + appendTo: '.js-lists', + helper: 'clone', + items: '.js-list:not(.add-list)', + placeholder: 'list placeholder', + start: function(event, ui) { + $('.list.placeholder').height(ui.item.height()); + Popup.close(); + }, + stop: function() { + self.$('.js-lists').find('.js-list:not(.add-list)').each( + function(i, list) { + var data = Blaze.getData(list); + Lists.update(data._id, { + $set: { + sort: i + } + }); + } + ); + } + }); + + // If there is no data in the board (ie, no lists) we autofocus the list + // creation form by clicking on the corresponding element. + if (self.data().lists().count() === 0) { + this.openNewListForm(); + } + } + }, + + sidebarSize: function() { + var sidebar = this.componentChildren('boardSidebar')[0]; + if (Session.get('currentCard') !== null) + return 'next-large-sidebar'; + else if (sidebar && sidebar.isOpen()) + return 'next-small-sidebar'; + } +}).register('boardComponent'); + +BlazeComponent.extendComponent({ + template: function() { + return 'addlistForm'; + }, + + // Proxy + open: function() { + this.componentChildren('inlinedForm')[0].open(); + } +}).register('addlistForm'); diff --git a/client/components/boards/body.styl b/client/components/boards/body.styl new file mode 100644 index 00000000000..cb351e46aca --- /dev/null +++ b/client/components/boards/body.styl @@ -0,0 +1,54 @@ +@import 'nib' + +.board-wrapper + left: 0 + top: 0 + bottom: 0 + right: 0 + position: absolute + overflow: hidden + + .board-canvas + position: absolute + left: 0 + right: 0 + top: 0 + bottom: 0 + transition: margin .1s + + &.next-small-sidebar + margin-right: 248px + + &.next-large-sidebar + opacity: 0.8 + margin-right: 496px + +.lists + align-items: flex-start + display: flex + flex-direction: row + margin-bottom: 10px + overflow-x: auto + overflow-y: hidden + padding-bottom: 10px + position: absolute + top: 0 + right: 0 + bottom: 0 + left: 0 + + &::-webkit-scrollbar + height: 13px + width: 13px + + &::-webkit-scrollbar-thumb:vertical, + &::-webkit-scrollbar-thumb:horizontal + background: rgba(255, 255, 255, .4) + + &::-webkit-scrollbar-track-piece + background: rgba(0, 0, 0, .15) + + &::-webkit-scrollbar-button + display: block + height: 5px + width: 5px diff --git a/client/components/boards/colors.styl b/client/components/boards/colors.styl new file mode 100644 index 00000000000..1db44845a81 --- /dev/null +++ b/client/components/boards/colors.styl @@ -0,0 +1,34 @@ +// We define a set of six board colors that we took from the FlatUI palette. +// http://flatuicolors.com + +setBoardColor(color) + &#header, + &.sk-spinner div, + .board-backgrounds-list &.background-box, + &.pop-over .pop-over-list li a:hover, + .board-list & a + background-color: color + + & .minicard.is-selected .minicard-details + border-bottom: 2px solid color + + button[type=submit].primary, input[type=submit].primary + background-color: darken(color, 20%) + +.board-color-nephritis + setBoardColor(#27AE60) + +.board-color-pomegranate + setBoardColor(#C0392B) + +.board-color-belize + setBoardColor(#2980B9) + +.board-color-wisteria + setBoardColor(#8E44AD) + +.board-color-midnight + setBoardColor(#2C3E50) + +.board-color-pumpkin + setBoardColor(#E67E22) diff --git a/client/components/boards/events.js b/client/components/boards/events.js new file mode 100644 index 00000000000..6f9d7fc6224 --- /dev/null +++ b/client/components/boards/events.js @@ -0,0 +1,96 @@ +var toggleBoardStar = function(boardId) { + var queryType = Meteor.user().hasStarred(boardId) ? '$pull' : '$addToSet'; + var query = {}; + query[queryType] = { + 'profile.starredBoards': boardId + }; + Meteor.users.update(Meteor.userId(), query); +}; + +Template.boards.events({ + 'click .js-star-board': function(evt) { + toggleBoardStar(this._id); + evt.preventDefault(); + } +}); + +Template.headerBoard.events({ + 'click .js-star-board': function() { + toggleBoardStar(this._id); + }, + 'click .js-open-board-menu': Popup.open('boardMenu'), + 'click #permission-level:not(.no-edit)': Popup.open('boardChangePermission'), + 'click .js-filter-cards-indicator': function(evt) { + Session.set('currentWidget', 'filter'); + evt.preventDefault(); + }, + 'click .js-filter-card-clear': function(evt) { + Filter.reset(); + evt.stopPropagation(); + } +}); + +Template.boardMenuPopup.events({ + 'click .js-rename-board': Popup.open('boardChangeTitle'), + 'click .js-change-board-color': Popup.open('boardChangeColor') +}); + +Template.createBoardPopup.events({ + 'submit #CreateBoardForm': function(evt, t) { + var title = t.$('#boardNewTitle'); + + // trim value title + if ($.trim(title.val())) { + // İnsert Board title + var boardId = Boards.insert({ + title: title.val(), + permission: 'public' + }); + + // Go to Board _id + Utils.goBoardId(boardId); + } + evt.preventDefault(); + } +}); + +Template.boardChangeTitlePopup.events({ + 'submit #ChangeBoardTitleForm': function(evt, t) { + var title = t.$('.js-board-name').val().trim(); + if (title) { + Boards.update(this._id, { + $set: { + title: title + } + }); + Popup.close(); + } + evt.preventDefault(); + } +}); + +Template.boardChangePermissionPopup.events({ + 'click .js-select': function(evt) { + var $this = $(evt.currentTarget); + var permission = $this.attr('name'); + + Boards.update(this._id, { + $set: { + permission: permission + } + }); + Popup.close(); + } +}); + +Template.boardChangeColorPopup.events({ + 'click .js-select-background': function(evt) { + var currentBoardId = Session.get('currentBoard'); + Boards.update(currentBoardId, { + $set: { + color: this.toString() + } + }); + evt.preventDefault(); + } +}); diff --git a/client/components/boards/header.jade b/client/components/boards/header.jade new file mode 100644 index 00000000000..189cdac4671 --- /dev/null +++ b/client/components/boards/header.jade @@ -0,0 +1,87 @@ +template(name="headerBoard") + h1.header-board-menu.js-open-board-menu + = title + span.fa.fa-angle-down + + .board-header-btns.left + unless isSandstorm + a.board-header-btn.js-star-board(class="{{#if isStarred}}board-header-starred{{/if}}" + title="{{# if isStarred }}{{_ 'click-to-unstar'}}{{ else }}{{_ 'click-to-star'}}{{/ if }} {{_ 'starred-boards-description'}}") + span.board-header-btn-icon.icon-sm.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}") + //- XXX To implement + span.board-header-btn-text Starred + //- + XXX Normally we would disable this field for sandstorm, but we keep it + until sandstorm implements sharing capabilities + + a.board-header-btn.perms-btn.js-change-vis(class="{{#unless currentUser.isBoardAdmin}}no-edit{{/ unless}}" id="permission-level") + span.board-header-btn-icon.icon-sm.fa(class="{{#if isPublic}}fa-globe{{else}}fa-lock{{/if}}") + span.board-header-btn-text {{_ permission}} + + a.board-header-btn.js-search + span.board-header-btn-icon.icon-sm.fa.fa-tag + span.board-header-btn-text Labels + + //- XXX Clicking here should open a search field + a.board-header-btn.js-search + span.board-header-btn-icon.icon-sm.fa.fa-search + span.board-header-btn-text {{_ 'search'}} + + //- +boardMembersHeader + +template(name="boardMembersHeader") + .board-header-members + each currentBoard.members + +userAvatar(userId=userId draggable=true showBadges=true) + unless isSandstorm + if currentUser.isBoardAdmin + a.member.add-board-member.js-open-manage-board-members + i.fa.fa-plus + +template(name="boardMenuPopup") + ul.pop-over-list + li: a.js-rename-board {{_ 'rename-board'}} + li: a.js-change-board-color Change color + li: a Copy this board + li: a Rules + +template(name="boardChangeTitlePopup") + form#ChangeBoardTitleForm + label {{_ 'name'}} + input.js-board-name(type="text" value="{{ title }}" autofocus) + input.primary.wide.js-rename-board(type="submit" value="{{_ 'rename'}}") + +template(name="boardChangePermissionPopup") + ul.pop-over-list + li + a.js-select.light-hover(name="private") + span.icon-sm.fa.fa-lock.vis-icon + | {{_ 'private'}} + if check 'private' + span.icon-sm.fa.fa-check + span.sub-name {{_ 'private-desc'}} + li + a.js-select.light-hover(name="public") + span.icon-sm.fa.fa-globe.vis-icon + | {{_ 'public'}} + if check 'public' + span.icon-sm.fa.fa-check + span.sub-name {{_ 'public-desc'}} + +template(name="boardChangeColorPopup") + .board-backgrounds-list.clearfix + each backgroundColors + .board-background-select.js-select-background + span.background-box(class="board-color-{{this}}") + if isSelected + i.fa.fa-check + +template(name="createBoardPopup") + .content.clearfix + form#CreateBoardForm + label(for="boardNewTitle") {{_ 'title'}} + input#boardNewTitle.non-empty(type="text" name="name" placeholder="{{_ 'bucket-example'}}" autofocus) + p.quiet + span.icon-sm.fa.fa-globe + | {{{_ 'board-public-info'}}} + input.primary.wide(type="submit" value="{{_ 'create'}}") diff --git a/client/components/boards/header.js b/client/components/boards/header.js new file mode 100644 index 00000000000..7d02df48a86 --- /dev/null +++ b/client/components/boards/header.js @@ -0,0 +1,7 @@ +Template.headerBoard.helpers({ + isStarred: function() { + var boardId = Session.get('currentBoard'); + var user = Meteor.user(); + return boardId && user && user.hasStarred(boardId); + } +}); diff --git a/client/components/boards/header.styl b/client/components/boards/header.styl new file mode 100644 index 00000000000..44c38a4b4f1 --- /dev/null +++ b/client/components/boards/header.styl @@ -0,0 +1,137 @@ +@import 'nib' + +.board-header { + height: auto; + overflow: hidden; + padding: 10px 30px 10px 8px; + position: relative; + transition: padding .15s ease-in; +} + +.board-header-btns { + position: relative; + display: block; +} + +.board-header-btn { + border-radius: 3px; + color: #f6f6f6; + cursor: default; + float: left; + font-size: 12px; + height: 30px; + line-height: 32px; + margin: 2px 4px 0 0; + overflow: hidden; + padding-left: 30px; + position: relative; + text-decoration: none; +} + +.board-header-btn:empty { + display: none; +} + +.board-header-btn-without-icon { + padding-left: 8px; +} + +.board-header-btn-icon { + background-clip: content-box; + background-origin: content-box; + color: #f6f6f6 !important; + padding: 6px; + position: absolute; + top: 0; + left: 0; +} + +.board-header-btn-text { + padding-right: 8px; +} + +.board-header-btn:not(.no-edit) .text { + text-decoration: underline; +} + +.board-header-btn:not(.no-edit):hover { + background: rgba(0, 0, 0, .12); + cursor: pointer; +} + +.board-header-btn:hover { + color: #f6f6f6; +} + +.board-header-btn.board-header-btn-enabled { + background-color: rgba(0, 0, 0, .1); + + &:hover { + background-color: rgba(0, 0, 0, .3); + } + + .board-header-btn-icon.icon-star { + color: #e6bf00 !important; + } +} + +.board-header-btn-name { + cursor: default; + font-size: 18px; + font-weight: 700; + line-height: 30px; + padding-left: 4px; + text-decoration: none; + + .board-header-btn-text { + padding-left: 6px; + } +} + +.board-header-btn-name-org-logo { + border-radius: 3px; + height: 30px; + left: 0; + position: absolute; + top: 0; + width: 30px; + + .board-header-btn-text { + padding-left: 32px; + } +} + +.board-header-btn-org-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 400px; +} + +.board-header-btn-filter-indicator { + background: #3d990f; + padding-right: 30px; + color: #fff; + text-shadow: 0; + + &:hover { + background: #43a711 !important; + } + + .board-header-btn-icon-close { + background: #43a711; + border-top-left-radius: 0; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 0; + color: #fff; + padding: 6px; + position: absolute; + right: 0; + top: 0; + + &:hover { + background: #48b512; + } + } +} diff --git a/client/components/boards/helpers.js b/client/components/boards/helpers.js new file mode 100644 index 00000000000..05be987dc8f --- /dev/null +++ b/client/components/boards/helpers.js @@ -0,0 +1,45 @@ +Template.boards.helpers({ + boards: function() { + return Boards.find({}, { + sort: ['title'] + }); + }, + + starredBoards: function() { + var cursor = Boards.find({ + _id: { $in: Meteor.user().profile.starredBoards || [] } + }, { + sort: ['title'] + }); + return cursor.count() === 0 ? null : cursor; + }, + + isStarred: function() { + var user = Meteor.user(); + return user && user.hasStarred(this._id); + } +}); + +Template.boardChangePermissionPopup.helpers({ + check: function(perm) { + return this.permission === perm; + } +}); + +Template.boardChangeColorPopup.helpers({ + backgroundColors: function() { + return Boards.simpleSchema()._schema.color.allowedValues; + }, + + isSelected: function() { + var currentBoard = Boards.findOne(Session.get('currentBoard')); + return currentBoard.color === this.toString(); + } +}); + +Blaze.registerHelper('currentBoard', function() { + var boardId = Session.get('currentBoard'); + if (boardId) { + return Boards.findOne(boardId); + } +}); diff --git a/client/components/boards/list.jade b/client/components/boards/list.jade new file mode 100644 index 00000000000..3a8fecd2281 --- /dev/null +++ b/client/components/boards/list.jade @@ -0,0 +1,14 @@ +template(name="boards") + if boards + ul.board-list.clearfix + each boards + li(class="{{#if isStarred}}starred{{/if}}" class=colorClass) + a.js-open-board(href="{{ pathFor route='Board' boardId=_id }}") + span.details + span.board-list-item-name= title + i.fa.fa-star-o.js-star-board( + class="{{#if isStarred}}is-star-active{{/if}}" + title="{{_ 'star-board-title'}}") + else + p.quiet {{_ 'no-boards'}} + button.js-add-board {{_ 'add-board'}} diff --git a/client/components/boards/list.styl b/client/components/boards/list.styl new file mode 100644 index 00000000000..c068dbb0ccc --- /dev/null +++ b/client/components/boards/list.styl @@ -0,0 +1,85 @@ +.board-list + margin: 25px auto + width: 1200px + + li + float: left + width: 25% + box-sizing: border-box + position: relative + + &.starred .fa-star-o + opacity: 1 + + a + background-color: #999 + color: #f6f6f6 + height: 90px + font-size: 16px + line-height: 22px + border-radius: 3px + display: block + font-weight: 700 + min-height: 18px + padding: 8px 12px 8px 12px + margin: 0 16px 16px 0 + position: relative + text-decoration: none + + &.tile + background-size: auto + background-repeat: repeat + + .details + height: 84px + padding-right: 36px + bottom: 0 + left: 0 + overflow: hidden + padding: 9px 12px + position: absolute + right: 0 + top: 0 + + .board-list-item-sub-name + color: rgba(255, 255, 255, .5) + display: block + font-size: 14px + font-weight: 400 + line-height: 22px + + .fa-star-o + bottom: 0 + font-size: 14px + height: 18px + line-height: 18px + opacity: 0 + padding: 9px 9px + position: absolute + right: 0 + top: 0 + transition-duration: .15s + transition-property: color, font-size, background + + .is-star-active + color: #e6bf00 + + li:hover a + color: #f6f6f6 + + .fa-star-o + color: #fff + opacity: .75 + + &:hover + font-size: 18px + opacity: 1 + + &.is-star-active + color: #e6bf00 + opacity: 1 + + &:hover + color: #ffd91a + font-size: 16px + opacity: 1 diff --git a/client/components/boards/router.js b/client/components/boards/router.js new file mode 100644 index 00000000000..6845b7f2402 --- /dev/null +++ b/client/components/boards/router.js @@ -0,0 +1,34 @@ +Meteor.subscribe('boards'); + +BoardSubsManager = new SubsManager(); + +Router.route('/boards', { + name: 'Boards', + template: 'boards', + authenticated: true, + onBeforeAction: function() { + Session.set('currentBoard', ''); + Filter.reset(); + this.next(); + } +}); + +Router.route('/boards/:_id/:slug', { + name: 'Board', + template: 'board', + onAfterAction: function() { + Session.set('sidebarIsOpen', true); + Session.set('currentWidget', 'home'); + Session.set('menuWidgetIsOpen', false); + }, + waitOn: function() { + var params = this.params; + Session.set('currentBoard', params._id); + Session.set('currentCard', null); + + return BoardSubsManager.subscribe('board', params._id, params.slug); + }, + data: function() { + return Boards.findOne(this.params._id); + } +}); diff --git a/client/components/cards/details.jade b/client/components/cards/details.jade new file mode 100644 index 00000000000..0de59297cc2 --- /dev/null +++ b/client/components/cards/details.jade @@ -0,0 +1,47 @@ +template(name="cardSidebar") + .card-sidebar.sidebar + .card-detail.sidebar-content.js-card-sidebar-content + if cover + .card-detail-cover(style="background-image: url({{ card.cover.url }})") + .card-detail-header(class="{{#if currentUser.isBoardMember}}editable{{/if}}") + a.js-close-card-detail + i.fa.fa-times + h2.card-detail-title.js-card-title= title + p.card-detail-list.js-move-card + | {{_ 'in-list'}} + a.card-detail-list-title( + class="{{#if currentUser.isBoardMember}}js-open-move-from-header is-editable{{/if}}") + = list.title + hr + //- if card.members + .card-detail-item.card-detail-item-members.clearfix.js-card-detail-members + h3.card-detail-item-header {{_ 'members'}} + .js-card-detail-members-list.clearfix + each members + +userAvatar(userId=this size="small" cardId=../_id) + a.card-detail-item-add-button.dark-hover.js-details-edit-members + i.fa.fa-plus + //- We should use "editable" to avoide repetiting ourselves + .clearfix + if currentUser.isBoardMember + h3 Description + +inlinedForm(classNames="js-card-description") + i.fa.fa-times.js-close-inlined-form + textarea(autofocus)= description + button(type="submit") {{_ 'edit'}} + else + .js-open-inlined-form + a {{_ 'edit'}} + +viewer + = description + else if description + h3 Description + +viewer + = description + hr + if attachments.count + +WindowAttachmentsModule(card=this) + +WindowActivityModule(card=this) + +template(name="moveCardPopup") + +boardLists diff --git a/client/components/cards/details.js b/client/components/cards/details.js new file mode 100644 index 00000000000..a4fe89a3bd1 --- /dev/null +++ b/client/components/cards/details.js @@ -0,0 +1,103 @@ +BlazeComponent.extendComponent({ + template: function() { + return 'cardSidebar'; + }, + + mixins: function() { + return [Mixins.InfiniteScrolling]; + }, + + calculateNextPeak: function() { + var altitude = this.find('.js-card-sidebar-content').scrollHeight; + this.callFirstWith(this, 'setNextPeak', altitude); + }, + + reachNextPeak: function() { + var activitiesComponent = this.componentChildren('activities')[0]; + activitiesComponent.loadNextPage(); + }, + + events: function() { + return [{ + 'click .js-move-card': Popup.open('moveCard'), + 'submit .js-card-description': function(evt) { + evt.preventDefault(); + var cardId = Session.get('currentCard'); + var form = this.componentChildren('inlinedForm')[0]; + var newDescription = form.getValue(); + Cards.update(cardId, { + $set: { + description: newDescription + } + }); + form.close(); + }, + 'click .js-close-card-detail': function() { + Utils.goBoardId(Session.get('currentBoard')); + }, + 'click .editable .js-card-title': function(event, t) { + var editable = t.$('.card-detail-title'); + + // add class editing and focus + $('.editing').removeClass('editing'); + editable.addClass('editing'); + editable.find('#title').focus(); + }, + 'click .js-edit-desc': function(event, t) { + var editable = t.$('.card-detail-item'); + + // editing remove based and add current editing. + $('.editing').removeClass('editing'); + editable.addClass('editing'); + editable.find('#desc').focus(); + + event.preventDefault(); + }, + 'click .js-cancel-edit': function(event, t) { + // remove editing hide. + $('.editing').removeClass('editing'); + }, + 'submit #WindowTitleEdit': function(event, t) { + var title = t.find('#title').value; + if ($.trim(title)) { + Cards.update(this.card._id, { + $set: { + title: title + } + }, function (err, res) { + if (!err) $('.editing').removeClass('editing'); + }); + } + + event.preventDefault(); + }, + 'submit #WindowDescEdit': function(event, t) { + Cards.update(this.card._id, { + $set: { + description: t.find('#desc').value + } + }, function(err) { + if (!err) $('.editing').removeClass('editing'); + }); + event.preventDefault(); + }, + 'click .member': Popup.open('cardMember'), + 'click .js-details-edit-members': Popup.open('cardMembers'), + 'click .js-details-edit-labels': Popup.open('cardLabels') + }]; + } +}).register('cardSidebar'); + +Template.moveCardPopup.events({ + 'click .js-select-list': function() { + // XXX We should *not* get the currentCard from the global state, but + // instead from a “component” state. + var cardId = Session.get('currentCard'); + var newListId = this._id; + Cards.update(cardId, { + $set: { + listId: newListId + } + }); + } +}); diff --git a/client/components/cards/details.styl b/client/components/cards/details.styl new file mode 100644 index 00000000000..faf15d79d81 --- /dev/null +++ b/client/components/cards/details.styl @@ -0,0 +1,161 @@ +@import 'nib' + +.card-detail.sidebar-content + width: 496px - 2 * 20px + top: -46px !important + z-index: 20 !important + // XXX Animate apparition + + .card-detail-header + background: #F7F7F7 + border-bottom: 1px solid darken(white, 10%) + position: absolute + min-height: 38px + top: 0 + left: 0 + right: 0 + padding 7px 20px 0 + + i.fa + float: right + font-size: 1.3em + color: darken(white, 35%) + margin-top: 7px + + .card-detail-title + font-weight: bold + font-size: 1.7em + margin: 3px 0 0 + padding: 0 + + .card-detail-list + font-size: 0.85em + margin-bottom: 3px + + a.card-detail-list-title + font-weight: bold + + &.is-editable + display: inline-block + background: darken(white, 10%) + border-radius: 3px + padding: 0px 5px + +.new-comment + position: relative + margin: 0 0 20px 38px + + .member + opacity: .7 + position: absolute + top: 1px + left: -38px + + .helper + bottom: 0 + display: none + position: absolute + right: 9px + + &.focus + + .member + opacity: 1 + + .helper + display: inline-block + + .new-comment-input + min-height: 108px + color: #4d4d4d + cursor: auto + overflow: hidden + word-wrap: break-word + + .too-long + margin-top: 8px + +.new-comment-input + background-color: #fff + border: 0 + box-shadow: 0 1px 2px rgba(0, 0, 0, .23) + color: #8c8c8c + height: 36px + margin: 4px 4px 6px 0 + padding: 9px 11px + width: 100% + + &:hover, + &:focus + background-color: #fff + box-shadow: 0 1px 3px rgba(0, 0, 0, .33) + border: 0 + cursor: pointer + + &:focus + cursor: auto + +.list-voters.compact .voter + position: relative + min-height: 36px + + .member + left: 0 + position: absolute + top: 0 + + .title + display: block + line-height: 30px + left: 0 + overflow: hidden + padding-left: 38px + position: absolute + text-overflow: ellipsis + top: 0 + white-space: nowrap + width: 230px + +.list-voters .title + display: none + +.card-composer + padding-bottom: 8px + +.cc-controls + margin-top: 1px + + input[type="submit"] + float: left + margin-top: 0 + padding: 5px 18px + + .icon-lg + float: left + + .cc-opt + float: right + +.minicard-placeholder, +.minicard.placeholder + background: silver + border: none + min-height: 18px + + .hook + height: 18px + position: absolute + right: 0 + top: 0 + width: 18px + +input[type="text"].attachment-add-link-input + float: left + margin: 0 0 8px + width: 80% + +input[type="submit"].attachment-add-link-submit + float: left + margin: 0 0 8px 4px + padding: 6px 12px + width: 18% diff --git a/client/components/cards/events.js b/client/components/cards/events.js new file mode 100644 index 00000000000..9c270e8d7a8 --- /dev/null +++ b/client/components/cards/events.js @@ -0,0 +1,285 @@ +// Template.cards.events({ +// // 'click .js-cancel': function(event, t) { +// // var composer = t.$('.card-composer'); + +// // // Keep the old value in memory to display it again next time +// // var inputCacheKey = "addCard-" + this.listId; +// // var oldValue = composer.find('.js-card-title').val(); +// // InputsCache.set(inputCacheKey, oldValue); + +// // // add composer hide class +// // composer.addClass('hide'); +// // composer.find('.js-card-title').val(''); + +// // // remove hide open link class +// // $('.js-open-card-composer').removeClass('hide'); +// // }, +// 'submit': function(evt, tpl) { +// evt.preventDefault(); +// var textarea = $(evt.currentTarget).find('textarea'); +// var title = textarea.val(); +// var lastCard = tpl.find('.js-minicard:last-child'); +// var sort; +// if (lastCard === null) { +// sort = 0; +// } else { +// sort = Blaze.getData(lastCard).sort + 1; +// } +// // debugger + +// // Clear the form in-memory cache +// // var inputCacheKey = "addCard-" + this.listId; +// // InputsCache.set(inputCacheKey, ''); + +// // title trim if not empty then +// if ($.trim(title)) { +// Cards.insert({ +// title: title, +// listId: Template.currentData().listId, +// boardId: Template.currentData().board._id, +// sort: sort +// }, function(err, _id) { +// // In case the filter is active we need to add the newly +// // inserted card in the list of exceptions -- cards that are +// // not filtered. Otherwise the card will disappear instantly. +// // See https://github.com/libreboard/libreboard/issues/80 +// Filter.addException(_id); +// }); + +// // empty and focus. +// textarea.val('').focus(); + +// // focus complete then scroll top +// Utils.Scroll(tpl.find('.js-minicards')).top(1000, true); +// } +// } +// }); + +// Template.cards.events({ +// 'click .member': Popup.open('cardMember') +// }); + +Template.cardMemberPopup.events({ + 'click .js-remove-member': function() { + Cards.update(this.cardId, {$pull: {members: this.userId}}); + Popup.close(); + } +}); + +Template.WindowActivityModule.events({ + 'click .js-new-comment:not(.focus)': function(evt) { + var $this = $(evt.currentTarget); + $this.addClass('focus'); + }, + 'submit #CommentForm': function(evt, t) { + var text = t.$('.js-new-comment-input'); + if ($.trim(text.val())) { + CardComments.insert({ + boardId: this.card.boardId, + cardId: this.card._id, + text: text.val() + }); + text.val(''); + $('.focus').removeClass('focus'); + } + evt.preventDefault(); + } +}); + +Template.WindowSidebarModule.events({ + 'click .js-change-card-members': Popup.open('cardMembers'), + 'click .js-edit-labels': Popup.open('cardLabels'), + 'click .js-archive-card': function(evt) { + // Update + Cards.update(this.card._id, { + $set: { + archived: true + } + }); + evt.preventDefault(); + }, + 'click .js-unarchive-card': function(evt) { + Cards.update(this.card._id, { + $set: { + archived: false + } + }); + evt.preventDefault(); + }, + 'click .js-delete-card': Popup.afterConfirm('cardDelete', function() { + Cards.remove(this.card._id); + + // redirect board + Utils.goBoardId(this.card.board()._id); + Popup.close(); + }), + 'click .js-more-menu': Popup.open('cardMore'), + 'click .js-attach': Popup.open('cardAttachments') +}); + +Template.WindowAttachmentsModule.events({ + 'click .js-attach': Popup.open('cardAttachments'), + 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', + function() { + Attachments.remove(this._id); + Popup.close(); + } + ), + // If we let this event bubble, Iron-Router will handle it and empty the + // page content, see #101. + 'click .js-open-viewer, click .js-download': function(event) { + event.stopPropagation(); + }, + 'click .js-add-cover': function() { + Cards.update(this.cardId, { $set: { coverId: this._id } }); + }, + 'click .js-remove-cover': function() { + Cards.update(this.cardId, { $unset: { coverId: '' } }); + } +}); + +Template.cardMembersPopup.events({ + 'click .js-select-member': function(evt) { + var cardId = Template.parentData(2).data._id; + var memberId = this.userId; + var operation; + if (Cards.find({ _id: cardId, members: memberId}).count() === 0) + operation = '$addToSet'; + else + operation = '$pull'; + + var query = {}; + query[operation] = { + members: memberId + }; + Cards.update(cardId, query); + evt.preventDefault(); + } +}); + +Template.cardLabelsPopup.events({ + 'click .js-select-label': function(evt) { + var cardId = Template.parentData(2).data._id; + var labelId = this._id; + var operation; + if (Cards.find({ _id: cardId, labelIds: labelId}).count() === 0) + operation = '$addToSet'; + else + operation = '$pull'; + + var query = {}; + query[operation] = { + labelIds: labelId + }; + Cards.update(cardId, query); + evt.preventDefault(); + }, + 'click .js-edit-label': Popup.open('editLabel'), + 'click .js-add-label': Popup.open('createLabel') +}); + +Template.formLabel.events({ + 'click .js-palette-color': function(evt) { + var $this = $(evt.currentTarget); + + // hide selected ll colors + $('.js-palette-select').addClass('hide'); + + // show select color + $this.find('.js-palette-select').removeClass('hide'); + } +}); + +Template.createLabelPopup.events({ + // Create the new label + 'submit .create-label': function(evt, tpl) { + var name = tpl.$('#labelName').val().trim(); + var boardId = Session.get('currentBoard'); + var selectLabelDom = tpl.$('.js-palette-select:not(.hide)').get(0); + var selectLabel = Blaze.getData(selectLabelDom); + Boards.update(boardId, { + $push: { + labels: { + _id: Random.id(6), + name: name, + color: selectLabel.color + } + } + }); + Popup.back(); + evt.preventDefault(); + } +}); + +Template.editLabelPopup.events({ + 'click .js-delete-label': Popup.afterConfirm('deleteLabel', function() { + var boardId = Session.get('currentBoard'); + Boards.update(boardId, { + $pull: { + labels: { + _id: this._id + } + } + }); + Popup.back(2); + }), + 'submit .edit-label': function(evt, tpl) { + var name = tpl.$('#labelName').val().trim(); + var boardId = Session.get('currentBoard'); + var getLabel = Utils.getLabelIndex(boardId, this._id); + var selectLabelDom = tpl.$('.js-palette-select:not(.hide)').get(0); + var selectLabel = Blaze.getData(selectLabelDom); + var $set = {}; + + // set label index + $set[getLabel.key('name')] = name; + + // set color + $set[getLabel.key('color')] = selectLabel.color; + + // update + Boards.update(boardId, { $set: $set }); + + // return to the previous popup view trigger + Popup.back(); + + evt.preventDefault(); + }, + 'click .js-select-label': function() { + Cards.remove(this.cardId); + + // redirect board + Utils.goBoardId(this.boardId); + } +}); + +Template.cardMorePopup.events({ + 'click .js-delete': Popup.afterConfirm('cardDelete', function() { + Cards.remove(this.card._id); + + // redirect board + Utils.goBoardId(this.card.board()._id); + }) +}); + +Template.cardAttachmentsPopup.events({ + 'change .js-attach-file': function(evt) { + var card = this.card; + FS.Utility.eachFile(evt, function(f) { + var file = new FS.File(f); + + // set Ids + file.boardId = card.boardId; + file.cardId = card._id; + + // upload file + Attachments.insert(file); + + Popup.close(); + }); + }, + 'click .js-computer-upload': function(evt, t) { + t.find('.js-attach-file').click(); + evt.preventDefault(); + } +}); diff --git a/client/components/cards/helpers.js b/client/components/cards/helpers.js new file mode 100644 index 00000000000..708b1b562a7 --- /dev/null +++ b/client/components/cards/helpers.js @@ -0,0 +1,48 @@ +Template.cardMembersPopup.helpers({ + isCardMember: function() { + var cardId = Template.parentData()._id; + var cardMembers = Cards.findOne(cardId).members || []; + return _.contains(cardMembers, this.userId); + }, + user: function() { + return Users.findOne(this.userId); + } +}); + +Template.cardLabelsPopup.helpers({ + isLabelSelected: function(cardId) { + return _.contains(Cards.findOne(cardId).labelIds, this._id); + } +}); + +var labelColors; +Meteor.startup(function() { + labelColors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; +}); + +Template.createLabelPopup.helpers({ + // This is the default color for a new label. We search the first color that + // is not already used in the board (although it's not a problem if two + // labels have the same color). + defaultColor: function() { + var labels = this.labels || this.card.board().labels; + var usedColors = _.pluck(labels, 'color'); + var availableColors = _.difference(labelColors, usedColors); + return availableColors.length > 1 ? availableColors[0] : 'green'; + } +}); + +Template.formLabel.helpers({ + labels: function() { + return _.map(labelColors, function(color) { + return { color: color, name: '' }; + }); + } +}); + +Blaze.registerHelper('currentCard', function() { + var cardId = Session.get('currentCard'); + if (cardId) { + return Cards.findOne(cardId); + } +}); diff --git a/client/components/cards/labels.styl b/client/components/cards/labels.styl new file mode 100644 index 00000000000..27058b21a47 --- /dev/null +++ b/client/components/cards/labels.styl @@ -0,0 +1,183 @@ +@import 'nib' + +// XXX Use .board-widget-labels as a flexbox container +.card-label + background-color: #b3b3b3 + border-radius: 4px + color: white + display: inline-block + font-weight: 700 + font-size: 13px + margin-right: 4px + padding: 3px 8px + position:relative + max-width: 100% + min-width: 8px + overflow: ellipsis + height: 18px + + &:hover + color: white + +.card-label-green + background-color: #3cb500 + +.card-label-yellow + background-color: #fad900 + +.card-label-orange + background-color: #ff9f19 + +.card-label-red + background-color: #eb4646 + +.card-label-purple + background-color: #a632db + +.card-label-blue + background-color: #0079bf + +.card-label-pink + background-color: #ff78cb + +.card-label-sky + background-color: #00c2e0 + +.card-label-black + background-color: #4d4d4d + +.card-label-lime + background-color: #51e898 + +.edit-label, +.create-label + .card-label + float: left + height: 25px + margin: 0px 3% 7px 0px + width: 10.5% + cursor: pointer + +.edit-labels + input[type="text"] + margin: 4px 0 6px 38px + width: 243px + + .card-label + height: 30px + left: 0 + padding: 1px 5px + position: absolute + top: 0 + width: 24px + + .labels-static .card-label + line-height: 30px + margin-bottom: 4px + position: relative + top: auto + left: 0 + width: 260px + +.minicard-labels + position: relative + z-index: 30 + top: -6px + + .card-label + border-radius: 0 + float: left + height: 4px + margin-bottom: 1px + padding: 0 + width: 40px + line-height: 100px + +.card-detail-item-labels .card-label + border-radius: 3px + display: block + float: left + height: 20px + line-height: 20px + margin: 0 4px 4px 0 + min-width: 30px + padding: 5px 10px + width: auto + +.editable-labels .card-label:hover + cursor: pointer + opacity: .75 + +.edit-labels-pop-over + margin-bottom: 8px + +.edit-labels-pop-over .shortcut + display: inline-block + +.card-label-selectable + border-radius: 3px + cursor: pointer + margin: 0 50px 4px 0 + min-height: 18px + padding: 8px + position: relative + transition: margin-right .1s + + .card-label-selectable-icon + position: absolute + top: 8px + right: -20px + + &.active:hover, + &.active, + &.active.selected:hover, + &.active.selected + margin-right: 38px + padding-right: 32px + + .card-label-selectable-icon + right: 6px + + &.active:hover:hover, + &.active:hover, + &.active.selected:hover:hover, + &.active.selected:hover + margin-right: 38px + + &.selected, + &:hover + margin-right: 38px + opacity: .8 + +.active .card-label-selectable + &, + &:hover + margin-right: 0 + + .card-label-selectable-icon + right: 8px + +.card-label-edit-button + border-radius: 3px + float: right + padding: 8px + + &:hover + background: #dbdbdb + +.card-label-color-select-icon + left: 14px + position: absolute + top: 9px + +.phenom .card-label + display: inline-block + font-size: 12px + height: 14px + line-height: 13px + padding: 0 4px + min-width: 16px + overflow: ellipsis + +.board-widget .phenom .card-label + max-width: 130px diff --git a/client/components/cards/minicard.styl b/client/components/cards/minicard.styl new file mode 100644 index 00000000000..a78cd46f657 --- /dev/null +++ b/client/components/cards/minicard.styl @@ -0,0 +1,136 @@ +.minicard + background-color: #fff + box-shadow: 0 1px 2px rgba(0,0,0,.2) + border-radius: 2px + cursor: pointer + margin-bottom: 9px + max-width: 300px + min-height: 20px + position: relative + z-index: 0 + overflow: hidden + + a + color: #4d4d4d + + &.active-card + background-color: #f0f0f0 + border-bottom-color: #c2c2c2 + + .minicard-operation + display: block + + &.draggable-hover-card + background-color: #f0f0f0 + border-bottom-color: #c2c2c2 + + .minicard-cover + background-position: center + background-repeat: no-repeat + background-size: cover + height: 145px + user-select: none + margin: -6px -8px 6px -8px + border-radius: top 2px + + &.no-preview-size + background-size: auto + background-position: center + + .minicard-details + padding: 6px 8px 2px + position: relative + z-index: 10 + + + &.is-selected + .minicard-details + padding-bottom: 0 + + a.minicard-details + text-decoration:none + + .minicard-details-overlay + background: transparent + bottom: 0 + left: 0 + position: absolute + right: 0 + top: 0 + + .minicard-dropzone + display: none + + .minicard.drophover .minicard-dropzone + background: rgba(255, 255, 255, .8) + // border-radius: 3px + // bottom: 0 + // display: block + // font-weight: 700 + // line-height: 100% + // left: 0 + // margin: 0 + // opacity: 1 + // padding: 0 + // position: absolute + // right: 0 + // text-align: center + // top: 0 + // z-index: 40 + + .minicard-title + display: block + font-weight: 400 + margin: 0 0 4px + overflow: hidden + text-decoration: none + word-wrap: break-word + + &::selection + background: transparent + + .minicard-labels + padding-top: 3px + margin-top: 4px + float: right + + .minicard-label + float: right + width: 8px + height: @width + border-radius: 2px + margin-left: 4px + + .minicard-members + float: right + margin: 2px -8px -2px 0 + + .member + float: right + border-radius: 50% + height: 28px + width: @height + + + .badges + margin-top: 10px + + .minicard-members:empty + display: none + +.badges + float: left + + &:empty + display: none + +textarea.minicard-composer-textarea, +textarea.minicard-composer-textarea:focus + background: none + border: none + box-shadow: none + height: auto + margin-bottom: 4px + padding: 0 + max-height: 162px + min-height: 54px + overflow-y: auto diff --git a/client/components/cards/popups.jade b/client/components/cards/popups.jade new file mode 100644 index 00000000000..0b5aa4c0c7b --- /dev/null +++ b/client/components/cards/popups.jade @@ -0,0 +1,12 @@ +template(name="cardMembersPopup") + //- input.js-search-mem(autofocus placeholder="Search members…" type="text") + ul.pop-over-member-list.checkable.js-mem-list + each board.members + li.item.js-member-item(class="{{#if isCardMember}}active{{/if}}") + a.name.js-select-member(href="#") + +userAvatar(user=user size="small") + span.full-name + = user.profile.name + | ({{ user.username }}) + if isCardMember + i.fa.fa-check diff --git a/client/components/cards/router.js b/client/components/cards/router.js new file mode 100644 index 00000000000..48bb9a957ee --- /dev/null +++ b/client/components/cards/router.js @@ -0,0 +1,15 @@ +Router.route('/boards/:boardId/:slug/:cardId', { + name: 'Card', + template: 'board', + waitOn: function() { + var params = this.params; + // XXX We probably shouldn't rely on Session + Session.set('currentBoard', params.boardId); + Session.set('currentCard', params.cardId); + + return BoardSubsManager.subscribe('board', params.boardId, params.slug); + }, + data: function() { + return Boards.findOne(this.params.boardId); + } +}); diff --git a/client/components/cards/templates.html b/client/components/cards/templates.html new file mode 100644 index 00000000000..4c65e429f8e --- /dev/null +++ b/client/components/cards/templates.html @@ -0,0 +1,336 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/components/forms/cachedValue.js b/client/components/forms/cachedValue.js new file mode 100644 index 00000000000..a2898d85b3a --- /dev/null +++ b/client/components/forms/cachedValue.js @@ -0,0 +1,22 @@ +var emptyValue = ''; + +Mixins.CachedValue = BlazeComponent.extendComponent({ + onCreated: function() { + this._cachedValue = emptyValue; + }, + + setCache: function(value) { + this._cachedValue = value; + }, + + getCache: function(defaultValue) { + if (this._cachedValue === emptyValue) + return defaultValue || ''; + else + return this._cachedValue; + }, + + resetCache: function() { + this.setCache(''); + } +}); diff --git a/client/components/forms/forms.styl b/client/components/forms/forms.styl new file mode 100644 index 00000000000..1084a4a6a14 --- /dev/null +++ b/client/components/forms/forms.styl @@ -0,0 +1,636 @@ +@import 'nib' + +textarea, +input:not([type=file]), +button + box-sizing: border-box + -webkit-appearance: none + background-color: #ebebeb + border: 1px solid #ccc + border-radius: 3px + display: block + margin-bottom: 12px + min-height: 34px + padding: 7px + + &.full + width: 100% + + &.input-error + background-color: #ece9e9 + border-color: #ba1212 + + &:focus + outline: 0 + +input[type="file"] + margin-bottom: 16px + +input[type="radio"] + -webkit-appearance: radio + min-height: inherit + +input[type="checkbox"] + -webkit-appearance: checkbox + margin-right: 4px + +input[type="text"], +input[type="password"], +input[type="email"] + transition: background 85ms ease-in, + border-color 85ms ease-in + width: 250px + + &.inline-input + background: none + border: 0 + margin: 0 + padding: 2px + min-height: 0 + height: 18px + width: 200px + +input[type="email"]:invalid + box-shadow: none + +input[type="text"], +input[type="password"], +input[type="email"], +textarea + + &:hover + border-color: #999 + + &.input-error + border-color: #ba1212 + + &:focus + background: #fff + border-color: #318ec4 + box-shadow: 0 0 2px #318ec4 + + &.input-error + background-color: #f8f7f7 + border-color: #ba1212 + box-shadow: 0 0 2px #d11515 + + &:disabled + background-color: #dcdcdc + border-color: #bfbfbf + color: #8c8c8c + -webkit-touch-callout: none + user-select: none + +select + max-height: 300px + width: 256px + margin-bottom: 8px + +option[disabled] + color: #8c8c8c + +textarea + height: 150px + transition: background 85ms ease-in, + border-color 85ms ease-in + resize: vertical + width: 100% + +.button + border-radius: 3px + text-decoration: none + position: relative + +input[type="submit"], +button + background: #cfcfcf + background: linear-gradient(#cfcfcf, #c2c2c2) + border: none + box-shadow: 0 1px 0 #8c8c8c + cursor: pointer + display: inline-block + font-weight: 700 + line-height: 22px + margin: 8px 4px 0 0 + padding: 7px 20px + text-align: center + + .wide + padding-left: 30px + padding-right: 30px + + &:hover, + &:focus + background: #c2c2c2 + background: linear-gradient(#c2c2c2, #b5b5b5) + + &:active + background: #b5b5b5 + background: linear-gradient(#b5b5b5, #a8a8a8) + box-shadow: inset 0 3px 6px rgba(0, 0, 0, .1) + + &:hover, + &:focus, + &:active + background: #e6e6e6 + background: linear-gradient(#e6e6e6, #e6e6e6) + + &.primary + background: #005377 + box-shadow: 0 1px 0 #4d4d4d + color: white + + &:hover, + &:focus + background: #004766 + + &:active + background: #01628C + + &.negate + &:hover, + &:focus + background: #990f0f + background: linear-gradient(#990f0f, #7d0c0c) + box-shadow: 0 1px 0 #4d4d4d + color: #fff + + &:active + background: #7d0c0c + box-shadow: 0 1px 0 #4d4d4d + color: #fff + +input[type="submit"].disabled, +input[type="submit"]:disabled, +input[type="button"].disabled, +button.disabled, +.button.disabled + + &, + &:hover, + &:active + background: #cfcfcf + cursor: default + box-shadow: none + color: #a8a8a8 + +fieldset + border: 1px solid #bfbfbf + padding: 15px + margin-bottom: 15px + +input[type="hidden"] + display: none + +input[type="checkbox"], +input[type="radio"] + display: inline + +.radio-div, +.check-div + display: block + margin: 0 0 4px 20px + min-height: 20px + position: relative + + input + left: -18px + min-height: 0 + margin: 0 + padding: 0 + position: absolute + top: 2px + + label + font-weight: 400 + +label + display: block + font-weight: 700 + margin-bottom: 4px + + &.form-error + color: #ba1212 + +input, +textarea + &::-webkit-input-placeholder, + &::-moz-placeholder + color: #8c8c8c + +.edit-controls, +.add-controls + margin-top: 0 + + button[type=submit] + float: left + height: 32px + margin-top: -2px + padding-top: 5px + padding-bottom: 5px + + i.fa.fa-times + font-size: 20px + + .option + border-color: transparent + border-radius: 3px + color: #8c8c8c + display: block + float: right + height: 30px + line-height: 30px + padding: 0 8px + margin: 0 2px + + &:hover + background-color: #dbdbdb + color: #4d4d4d + + &:active + background-color: #ccc + +.button-link + background: #fff + background: linear-gradient(#fff, #f5f5f5) + border-radius: 3px + box-sizing: border-box + user-select: none + border: 1px solid #e3e3e3 + border-bottom-color: #c2c2c2 + cursor: pointer + display: block + font-weight: 700 + height: 34px + margin-top: 6px + max-width: 300px + padding: 7px + position: relative + text-decoration: none + overflow: ellipsis + + .on + background: #48b512 + background: linear-gradient(#48b512, #3d990f) + border-radius: 3px + color: #fff + display: none + font-size: 12px + font-weight: 700 + height: 17px + line-height: @height + margin: 0 + padding: 2px 4px + position: absolute + right: 5px + top: 5px + text-align: center + + &.is-on + padding-right: 30px + max-width: 196px + + .on + display: block + + &.inline + color: #666 + padding: 2px 14px + margin-left: 4px + + &.setting + height: 52px + float: left + position: relative + margin-top: 0 + + &.disabled + background: #fff + border-color: #e9e9e9 + color: #8c8c8c + cursor: default + + select + display: none + + &:hover .label + color: #8c8c8c + + &, + &:hover, + &:active, + &.primary, + &.primary:hover, + &.primary:active + background: #cfcfcf + border-color: #c2c2c2 + border-bottom-color: #b5b5b5 + cursor: default + box-shadow: none + color: #a8a8a8 + + .label + color: #8c8c8c + display: block + font-size: 12px + line-height: 14px + margin-bottom: 0 + + &:hover .label + color: #eee + + .value + display: block + font-size: 18px + line-height: 24px + overflow: hidden + text-overflow: ellipsis + + label + display: none + + select + border: none + cursor: pointer + height: 50px + left: 0 + margin: 0 + opacity: 0 + position: absolute + top: 0 + z-index: 2 + width: 100% + + &:hover + background: #318ec4 + background: linear-gradient(#318ec4, #2b7cab) + border-color: #2e85b8 + color: #fff + + .on + background-image: none + background-color: rgba(255, 255, 255, .3) + border-color: transparent + + .icon-sm + color: #fff + + &:active + background: #2e85b8 + background: linear-gradient(#2e85b8, #28739f) + border-color: #2b7cab + color: #fff + + .button-link.negate + + &:hover + background: #990f0f + background: linear-gradient(#990f0f, #7d0c0c) + border-color: @background + + &:active + background: #7d0c0c + border-color: #990f0f + + + &.primary + background: #48b512 + background: linear-gradient(#48b512, #3d990f) + border: 1px solid + border-color: #3d990f + color: #fff + + &:hover + background: #3d990f + background: linear-gradient(#3d990f, #327d0c) + border-color: #3d990f + + &.danger + background: #ba1212 + background: linear-gradient(#ba1212, #8b0e0e) + border: 1px solid + border-color: #a21010 + color: #fff + + &:hover + background: #a21010 + background: linear-gradient(#a21010, #740b0b) + border-color: #8b0e0e + +button + + &.quiet-button, + &.loud-text-button + background: none + text-align: left + line-height: normal + border: none + box-shadow: none + + &:active + color: #4d4d4d + background: #d3d3d3 + box-shadow: none + + &.quiet-button + font-weight: 400 + text-decoration: underline + + &.loud-text-button + width: 100% + + &:hover + color: #111 + +.emphasis-button, +.quiet-button + border-radius: 3px + user-select: none + color: #8c8c8c + display: block + margin: 2px 0 + padding: 6px 8px + position: relative + + &.w-img + padding-left: 28px + + .icon-sm + left: 6px + position: absolute + top: 6px + + &:hover + color: #4d4d4d + background: #dcdcdc + + &:active + color: #4d4d4d + background: #d3d3d3 + +.quiet-button-large + padding: 16px 24px + +.emphasis-button + color: #74663e + background: #ecdfbb + + &:hover + color: #53492d + background: #e7d6a7 + + &:active + color: #53492d + background: #e1cc93 + +.big-link + border-radius: 3px + display: block + margin: 6px 0 6px 40px + padding: 11px + position: relative + text-decoration: none + font-size: 16px + line-height: 20px + + .text + text-decoration: underline + + &:hover + background: #dcdcdc + + &.options + padding-right: 41px + + .option + height: 30px + width: @height + position: absolute + right: 6px + top: 6px + + &.none + color: #8c8c8c + text-decoration: none + + &:hover + background: transparent + + &.avatar-changer + padding-right: 51px + + .member + border: 1px solid #ccc + border-radius: 3px + height: 40px + width: @height + position: absolute + right: 0 + top: 0 + + .member-avatar + height: 40px + width: @height + + .member-initials + font-size: 16px + height: 40px + line-height: @height + max-height: @height + +.show-more + border-radius: 3px + color: #8c8c8c + display: block + padding: 16px 8px 16px 40px + margin: 8px 0 + + &:hover + background: #dcdcdc + text-decoration: underline + + &.compact + padding: 12px 8px + margin: 8px 0 0 + text-align: center + +.board-widget .show-more + padding: 12px 8px 12px 40px + +.uploader + clear: both + cursor: pointer + position: relative + height: 34px + width: 100% + + .realfile + cursor: pointer + height: 34px + line-height: @height + position: absolute + top: 0 + left: 0 + width: 100% + z-index: 2 + font-size: 23px + + input[type="file"] + cursor: pointer + height: 34px + line-height: @height + margin: 0 + opacity: 0 + padding: 0 + width: 100% + z-index: 2 + font-size: 23px + + &:hover .fakefile + background: #318ec4 + background: linear-gradient(#318ec4, #2b7cab) + border-color: #2e85b8 + color: #fff + +.form-grid + display: flex + flex-wrap: wrap + width: 100% + +.form-grid-child + flex: 1 + margin: 0 0 8px + +.form-grid-child-full + flex: 1 1 100% + +.form-grid-child-threequarters + flex: 3 + margin-right: 8px + +.form-grid-child-twothirds + flex: 2 + margin-right: 8px + +.dropdown-menu + border-radius: 2px + // padding-bottom: 3px + overflow: hidden + + li + border-top: none + + a + padding: 4px 12px 4px 8px + + img + width: 18px + height: @width + margin-right: 5px + vertical-align: middle + + &.active + background: #005377 + + a + color: white diff --git a/client/components/forms/inlinedform.jade b/client/components/forms/inlinedform.jade new file mode 100644 index 00000000000..5ad9039e2ed --- /dev/null +++ b/client/components/forms/inlinedform.jade @@ -0,0 +1,6 @@ +template(name='inlinedForm') + if isOpen.get + form(id=id class=classNames) + +Template.contentBlock + else + +Template.elseBlock diff --git a/client/components/forms/inlinedform.js b/client/components/forms/inlinedform.js new file mode 100644 index 00000000000..2e2b2eba67e --- /dev/null +++ b/client/components/forms/inlinedform.js @@ -0,0 +1,93 @@ +// A inlined form is used to provide a quick edition of single field for a given +// document. Clicking on a edit button should display the form to edit the field +// value. The form can then be submited, or just closed. +// +// When the form is closed we save non-submitted values in memory to avoid any +// data loss. +// +// Usage: +// +// +inlineForm +// // the content when the form is open +// else +// // the content when the form is close (optional) + +// We can only have one inlined form element opened at a time +// XXX Could we avoid using a global here ? This is used in Mousetrap +// keyboard.js +currentlyOpenedForm = new ReactiveVar(null); + +BlazeComponent.extendComponent({ + template: function() { + return 'inlinedForm'; + }, + + mixins: function() { + return [Mixins.CachedValue]; + }, + + onCreated: function() { + this.isOpen = new ReactiveVar(false); + }, + + open: function() { + // Close currently opened form, if any + if (currentlyOpenedForm.get() !== null) { + currentlyOpenedForm.get().close(); + } + this.isOpen.set(true); + currentlyOpenedForm.set(this); + }, + + close: function() { + this.saveValue(); + this.isOpen.set(false); + currentlyOpenedForm.set(null); + }, + + getValue: function() { + return this.isOpen.get() && this.find('textarea,input[type=text]').value; + }, + + saveValue: function() { + this.callFirstWith(this, 'setCache', this.getValue()); + }, + + events: function() { + return [{ + 'click .js-close-inlined-form': this.close, + 'click .js-open-inlined-form': this.open, + + // Close the inlined form by pressing escape. + // + // Keydown (and not keypress) in necessary here because the `keyCode` + // property is consistent in all browsers, (there is not keyCode for the + // `keypress` event in firefox) + 'keydown form input, keydown form textarea': function(evt) { + if (evt.keyCode === 27) { + evt.preventDefault(); + this.close(); + } + }, + + // Pressing Ctrl+Enter should submit the form + 'keydown form textarea': function(evt) { + if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) { + $(evt.currentTarget).parents('form:first').submit(); + } + }, + + // Close the inlined form when after its submission + submit: function() { + var self = this; + // XXX Swith to an arrow function here when we'll have ES6 + if (this.currentData().autoclose !== false) { + Tracker.afterFlush(function() { + self.close(); + self.callFirstWith(self, 'resetCache'); + }); + } + } + }]; + } +}).register('inlinedForm'); diff --git a/client/components/lists/body.jade b/client/components/lists/body.jade new file mode 100644 index 00000000000..0e8efeeb47e --- /dev/null +++ b/client/components/lists/body.jade @@ -0,0 +1,50 @@ +template(name="listBody") + .minicards.clearfix.js-minicards + if cards.count + +inlinedForm(autoclose=false position="top") + +addCardForm + each cards + .minicard.card.js-minicard.js-member-droppable( + class="{{#if isSelected}}is-selected{{/if}}") + a.minicard-details.clearfix.show(href=absoluteUrl) + if cover + .minicard-cover.js-card-cover(style="background-image: url({{cover.url}});") + if labels + .minicard-labels + each labels + .minicard-label(class="card-label-{{color}}" title="{{name}}") + .minicard-title= title + if members + .minicard-members.js-minicard-members + each members + +userAvatar(userId=this size="small" cardId="{{../_id}}") + .badges + if comments.count + .badge(title="{{_ 'card-comments-title' comments.count }}") + span.badge-icon.icon-sm.fa.fa-comment-o + .badge-text= comments.count + if description + .badge.badge-state-image-only(title=description) + span.badge-icon.icon-sm.fa.fa-align-left + if attachments.count + .badge + span.badge-icon.icon-sm.fa.fa-paperclip + span.badge-text= attachments.count + if currentUser.isBoardMember + +inlinedForm(autoclose=false position="bottom") + +addCardForm + else + a.open-card-composer.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-card'}} + +template(name="addCardForm") + .minicard.js-composer + .minicard-labels.js-minicard-composer-labels + .minicard-details.clearfix + textarea.minicard-composer-textarea.js-card-title(autofocus) + = getCache + .minicard-members.js-minicard-composer-members + .add-controls.clearfix + button.primary.confirm(type="submit") {{_ 'add'}} + a.fa.fa-times.dark-hover.cancel.js-close-inlined-form diff --git a/client/components/lists/body.js b/client/components/lists/body.js new file mode 100644 index 00000000000..fa6ec0964a2 --- /dev/null +++ b/client/components/lists/body.js @@ -0,0 +1,73 @@ +BlazeComponent.extendComponent({ + template: function() { + return 'listBody'; + }, + + isSelected: function() { + return Session.equals('currentCard', this.currentData()._id); + }, + + addCard: function(evt) { + evt.preventDefault(); + var textarea = $(evt.currentTarget).find('textarea'); + var title = textarea.val(); + var position = this.currentData().position; + var sortIndex; + if (position === 'top') { + sortIndex = Utils.getSortIndex(null, this.find('.js-minicard:first')); + } else if (position === 'bottom') { + sortIndex = Utils.getSortIndex(this.find('.js-minicard:last'), null); + } + + // Clear the form in-memory cache + // var inputCacheKey = "addCard-" + this.listId; + // InputsCache.set(inputCacheKey, ''); + + // title trim if not empty then + if ($.trim(title)) { + Cards.insert({ + title: title, + listId: this.data()._id, + boardId: this.data().board()._id, + sort: sortIndex + }, function(err, _id) { + // In case the filter is active we need to add the newly + // inserted card in the list of exceptions -- cards that are + // not filtered. Otherwise the card will disappear instantly. + // See https://github.com/libreboard/libreboard/issues/80 + Filter.addException(_id); + }); + + // We keep the form opened, empty it, and scroll to it. + textarea.val('').focus(); + Utils.Scroll(this.find('.js-minicards')).top(1000, true); + } + }, + + events: function() { + return [{ + submit: this.addCard, + 'keydown form textarea': function(evt) { + // Pressing Enter should submit the card + if (evt.keyCode === 13) { + evt.preventDefault(); + $(evt.currentTarget).parents('form:first').submit(); + + // Pressing Tab should open the form of the next column, and Maj+Tab go + // in the reverse order + } else if (evt.keyCode === 9) { + evt.preventDefault(); + var isReverse = evt.shiftKey; + var list = $('#js-list-' + this.data()._id); + var nextList = list[isReverse ? 'prev' : 'next']('.js-list').get(0) || + $('.js-list:' + (isReverse ? 'last' : 'first')).get(0); + var nextListComponent = BlazeComponent.getComponentForElement(nextList); + + // XXX Get the real position + var position = 'bottom'; + nextListComponent.openForm({position: position}); + } + } + }]; + } +}).register('listBody'); diff --git a/client/components/lists/events.js b/client/components/lists/events.js new file mode 100644 index 00000000000..f636de75f73 --- /dev/null +++ b/client/components/lists/events.js @@ -0,0 +1,16 @@ +Template.addlistForm.events({ + submit: function(event, t) { + event.preventDefault(); + var title = t.find('.list-name-input'); + if ($.trim(title.value)) { + Lists.insert({ + title: title.value, + boardId: Session.get('currentBoard'), + sort: $('.list').length + }); + + Utils.Scroll('.js-lists').left(270, true); + title.value = ''; + } + } +}); diff --git a/client/components/lists/header.jade b/client/components/lists/header.jade new file mode 100644 index 00000000000..5196af5ddc5 --- /dev/null +++ b/client/components/lists/header.jade @@ -0,0 +1,13 @@ +template(name="listHeader") + .list-header.js-list-header + +inlinedForm + +editListTitleForm + else + h2.list-header-name.js-open-inlined-form= title + a.list-header-menu-icon.fa.fa-bars.js-open-list-menu + +template(name="editListTitleForm") + input.field.single-line(type="text" value="{{getCache title}}" autofocus) + .edit-controls.clearfix + input.primary.confirm(type="submit" value="{{_ 'save'}}") + a.fa.fa-times.js-close-inlined-form diff --git a/client/components/lists/header.js b/client/components/lists/header.js new file mode 100644 index 00000000000..014cfd80726 --- /dev/null +++ b/client/components/lists/header.js @@ -0,0 +1,25 @@ +BlazeComponent.extendComponent({ + template: function() { + return 'listHeader'; + }, + + editTitle: function(evt) { + evt.preventDefault(); + var form = this.componentChildren('inlinedForm')[0]; + var newTitle = form.getValue(); + if ($.trim(newTitle)) { + Lists.update(this.currentData()._id, { + $set: { + title: newTitle + } + }); + } + }, + + events: function() { + return [{ + 'click .js-open-list-menu': Popup.open('listAction'), + submit: this.editTitle + }]; + } +}).register('listHeader'); diff --git a/client/components/lists/main.jade b/client/components/lists/main.jade new file mode 100644 index 00000000000..dd4bb49afce --- /dev/null +++ b/client/components/lists/main.jade @@ -0,0 +1,5 @@ +template(name='list') + .list.js-list(id="js-list-{{_id}}") + .list-wrapper + +listHeader + +listBody diff --git a/client/components/lists/main.js b/client/components/lists/main.js new file mode 100644 index 00000000000..3d458055bda --- /dev/null +++ b/client/components/lists/main.js @@ -0,0 +1,81 @@ +ListComponent = BlazeComponent.extendComponent({ + template: function() { + return 'list'; + }, + + openForm: function(options) { + options = options || {}; + options.position = options.position || 'top'; + + var listComponent = this.componentChildren('listBody')[0]; + var forms = listComponent.componentChildren('inlinedForm'); + + if (options.position === 'top') { + forms[0].open(); + } else { + forms[forms.length - 1].open(); + } + }, + + // XXX The jQuery UI sortable plugin is far from ideal here. First we include + // all jQuery components but only use one. Second, it modifies the DOM itself, + // resulting in Blaze abandoning reactive update of the nodes that have been + // moved which result in bugs if multiple users use the board in real time. + // I tried sortable:sortable but that was not better. Should we “simply” write + // the drag&drop code ourselves? + onRendered: function() { + if (Meteor.user().isBoardMember()) { + var $cards = this.$('.js-minicards'); + $cards.sortable({ + connectWith: ".js-minicards", + tolerance: 'pointer', + appendTo: '.js-lists', + helper: "clone", + items: '.js-minicard:not(.placeholder, .hide, .js-composer)', + placeholder: 'minicard placeholder', + start: function (event, ui) { + $('.minicard.placeholder').height(ui.item.height()); + Popup.close(); + }, + stop: function(event, ui) { + // To attribute the new index number, we need to get the dom element of + // the previous and the following card -- if any. + var cardDomElement = ui.item.get(0); + var prevCardDomElement = ui.item.prev('.js-minicard').get(0); + var nextCardDomElement = ui.item.next('.js-minicard').get(0); + var sort = Utils.getSortIndex(prevCardDomElement, nextCardDomElement); + var cardId = Blaze.getData(cardDomElement)._id; + var listId = Blaze.getData(ui.item.parents('.list').get(0))._id; + Cards.update(cardId, { + $set: { + listId: listId, + sort: sort + } + }); + } + }).disableSelection(); + + Utils.liveEvent('mouseover', function($el) { + $el.find('.js-member-droppable').droppable({ + hoverClass: "draggable-hover-card", + accept: '.js-member', + drop: function(event, ui) { + var memberId = Blaze.getData(ui.draggable.get(0)).userId; + var cardId = Blaze.getData(this)._id; + Cards.update(cardId, {$addToSet: {members: memberId}}); + } + }); + + $el.find('.js-member-droppable').droppable({ + hoverClass: "draggable-hover-card", + accept: '.js-label', + drop: function(event, ui) { + var labelId = Blaze.getData(ui.draggable.get(0))._id; + var cardId = Blaze.getData(this)._id; + Cards.update(cardId, {$addToSet: {labelIds: labelId}}); + } + }); + }); + } + } +}).register('list'); diff --git a/client/components/lists/main.styl b/client/components/lists/main.styl new file mode 100644 index 00000000000..18484174605 --- /dev/null +++ b/client/components/lists/main.styl @@ -0,0 +1,136 @@ +@import 'nib' + +.list + box-sizing: border-box + display: flex + flex-direction: column + flex: 0 0 270px + position: relative + // Even if this background color is the same as the body we can't leave it + // transparent, because that won't work during a list drag. + background: darken(white, 10%) + height: 100% + border-right: 1px solid darken(white, 17%) + border-left: 1px solid darken(white, 4%) + padding: 12px 7px 5px + overflow-y: auto + + &:first-child + margin-left: 5px + border-left: none + + &:last-child + margin-right: 5px + border-right: none + + &.editable + cursor: grab + + .list-wrapper + cursor: default + + &.add-list + &.fade + opacity: 0 + + .list-name-input + background: rgba(0, 0, 0, .05) + border-color: #aaa + box-shadow: inset 0 1px 8px rgba(0, 0, 0, .15) + display: block + margin: 0 + transition: margin 85ms ease-in, + background 85ms ease-in + width: 100% + + .edit-controls + height: 32px + transition: margin 85ms ease-in, + height 85ms ease-in + overflow: hidden + margin: 4px 0 0 + + input[type=submit] + margin-top: 0 + min-height: 30px + height: 30px + +.list-header + flex: 0 0 auto + padding: 10px 26px 4px 6px + position: relative + min-height: 20px + + .list-header-name + display: inline + font-size: 16px + line-height: 17px + margin: 0 + font-weight: bold + min-height: 9px + min-width: 30px + overflow: hidden + text-overflow: ellipsis + word-wrap: break-word + + .list-header-menu-icon + background-clip: content-box + background-origin: content-box + padding: 6px 8px + position: absolute + top: 3px + right: -5px + color: #a6a6a6 + + .list-header-num-cards + color: #8c8c8c + margin: 0 + +.minicards + // flex: 1 1 auto + overflow-y: auto + overflow-x: hidden + padding: 4px 4px 1px + z-index: 1 + height: 100% + + &::-webkit-scrollbar-button + display: block + height: 4px + +.open-card-composer + border-top-left-radius: 0 + border-top-right-radius: 0 + border-bottom-right-radius: 3px + border-bottom-left-radius: 3px + color: #8c8c8c + display: block + // flex: 0 0 auto + margin: 2px -3px -3px + padding: 7px 10px + position: relative + text-decoration: none + + &:hover + background: #c3c3c3 + color: #222 + text-decoration: underline + + + &::selection + background: transparent + +.list.placeholder + background-color: rgba(0, 0, 0, .2) + border-color: transparent + box-shadow: none + height: 100px + +.list.ui-sortable-helper + cursor: grabbing + box-shadow: -2px 2px 8px rgba(0, 0, 0, .3), 0 0 1px rgba(0, 0, 0, .5) + transform: rotate(4deg) + + +.list.ui-sortable-helper .list-header-menu-icon + display: none diff --git a/client/components/lists/menu.jade b/client/components/lists/menu.jade new file mode 100644 index 00000000000..ff7820a4657 --- /dev/null +++ b/client/components/lists/menu.jade @@ -0,0 +1,28 @@ +template(name="listActionPopup") + ul.pop-over-list + li: a.js-add-card {{_ 'add-card'}} + li: a.highlight-icon.js-list-subscribe {{_ 'subscribe'}} + if cards.count + hr + ul.pop-over-list + li: a.js-move-cards {{_ 'list-move-cards'}} + li: a.js-archive-cards {{_ 'list-archive-cards'}} + hr + ul.pop-over-list + li: a.js-close-list {{_ 'archive-list'}} + +template(name="listMoveCardsPopup") + +boardLists + +template(name="boardLists") + ul.pop-over-list + each currentBoard.lists + li + if($eq ../_id _id) + a.disabled {{title}} ({{_ 'current'}}) + else + a.js-select-list= title + +template(name="listArchiveCardsPopup") + p {{_ 'list-archive-cards-pop'}} + input.js-confirm.negate.full(type="submit" value="{{_ 'archive-all'}}") diff --git a/client/components/lists/menu.js b/client/components/lists/menu.js new file mode 100644 index 00000000000..ef08cf76cf7 --- /dev/null +++ b/client/components/lists/menu.js @@ -0,0 +1,46 @@ +Template.listActionPopup.events({ + 'click .js-add-card': function() { + // XXX We need a better API and architecture here. See + // https://github.com/peerlibrary/meteor-blaze-components/issues/19 + var listDom = document.getElementById('js-list-' + this._id); + var listComponent = Blaze.getView(listDom).templateInstance().get('component'); + listComponent.openForm(); + Popup.close(); + }, + 'click .js-list-subscribe': function() {}, + 'click .js-move-cards': Popup.open('listMoveCards'), + 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() { + Cards.find({listId: this._id}).forEach(function(card) { + Cards.update(card._id, { + $set: { + archived: true + } + }); + }); + Popup.close(); + }), + 'click .js-close-list': function(evt) { + evt.preventDefault(); + Lists.update(this._id, { + $set: { + archived: true + } + }); + Popup.close(); + } +}); + +Template.listMoveCardsPopup.events({ + 'click .js-select-list': function() { + var fromList = Template.parentData(2).data._id; + var toList = this._id; + Cards.find({listId: fromList}).forEach(function(card) { + Cards.update(card._id, { + $set: { + listId: toList + } + }); + }); + Popup.close(); + } +}); diff --git a/client/components/main/events.js b/client/components/main/events.js new file mode 100644 index 00000000000..beb90c5e6e3 --- /dev/null +++ b/client/components/main/events.js @@ -0,0 +1,8 @@ +Template.editor.events({ + // Pressing Ctrl+Enter should submit the form. + 'keydown textarea': function(event) { + if (event.keyCode === 13 && (event.metaKey || event.ctrlKey)) { + $(event.currentTarget).parents('form:first').submit(); + } + } +}); diff --git a/client/components/main/header.jade b/client/components/main/header.jade new file mode 100644 index 00000000000..588c9b6ecaa --- /dev/null +++ b/client/components/main/header.jade @@ -0,0 +1,40 @@ +template(name="header") + #header(class=currentBoard.colorClass) + //- + If the user is connected we display a small "quick-access" top bar that + list all starred boards with a link to go there. This is inspired by the + Reddit "subreddit" bar. + The first link goes to the boards page. + if currentUser + #header-quick-access + ul + li + +linkTo(route="Boards") + span.fa.fa-home + | All boards + each currentUser.starredBoards + li.separator - + li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}") + +linkTo(route="Board" data=this) + = title + else + li.current Star a board to add a shortcut in this bar. + + li + a.js-create-board + i.fa.fa-plus(title="Create a new board") + + +headerUserBar + + //- + The main bar is a colorful bar that provide all the meta-data for the + current page. This bar is contextual based. + If the user is not connected we display "sign in" and "log in" buttons. + #header-main-bar + if $.Session.get 'currentBoard' + +headerBoard + else + +headerTitle + +template(name="headerTitle") + h1 LibreBoard diff --git a/client/components/main/header.js b/client/components/main/header.js new file mode 100644 index 00000000000..2a545309ec0 --- /dev/null +++ b/client/components/main/header.js @@ -0,0 +1,10 @@ +Template.header.helpers({ + // Reactively set the color of the page from the color of the current board. + headerTemplate: function() { + return 'headerBoard'; + } +}); + +Template.header.events({ + 'click .js-create-board': Popup.open('createBoard') +}); diff --git a/client/components/main/header.styl b/client/components/main/header.styl new file mode 100644 index 00000000000..1177d930975 --- /dev/null +++ b/client/components/main/header.styl @@ -0,0 +1,266 @@ +@import 'nib' + +global-reset() + +#header + color: white + transition: background-color 0.4s + background: #27AE60 + + #header-quick-access + background-color: rgba(0, 0, 0, 0.2) + padding: 4px 10px + height: 16px + font-size: 12px + display: flex + + ul li, #header-user-bar + color: darken(white, 17%) + + a + color: inherit + text-decoration: none + + &:hover + color: white + + ul + flex: 1 + transition: opacity 0.2s + margin-left: 5px + + li + display: block + float: left + width: auto + color: darken(white, 15%) + padding: 0 4px 1px 4px + + &.separator + padding: 0 2px 1px 2px + + &.current + font-style: italic + + &:first-child .fa-home + margin-right: 5px + + #header-main-bar + height: 30px + padding: 8px + + h1 + font-size: 19px + line-height: 1.7em + margin: 0 20px 0 10px + float: left + + &.header-board-menu + cursor: pointer + + .fa-angle-down + font-size: 0.8em + // line-height: 1.1em + margin-left: 5px + + .board-header-starred .fa + color: yellow + + .board-header-members + float: right + + .member + display: block + width: 32px + height: @width + + .add-board-member + color: white + display: flex + align-items: center + justify-content: center + border: 1px solid white + height: 32px - 2px + width: @height + + i.fa-plus + margin-top: 2px + + .header-btn:last-child + margin-right: 0 + + + +// #header { +// background: #138871; +// height: 30px; +// overflow: hidden; +// padding: 5px; +// position: relative; +// z-index: 10; +// } + +// .header-logo { +// bottom: 0; +// display: block; +// height: 25px; +// left: 50%; +// position: absolute; +// top: 8px; +// width: 80px; +// margin-left: - @width/2; +// text-align: center; +// z-index: 2; +// opacity: .5; +// transition: opacity ease-in 85ms; +// color: white; +// font-size: 22px; +// text-decoration: none; +// background-image: url('/logos/white_logo.png'); + +// &:hover { +// opacity: .8; +// color: white; +// } +// } + +// .header-btn.header-btn-feedback { +// background: rgba(255, 255, 255, .1); +// background: linear-gradient(to bottom, rgba(255, 255, 255, .1) 0, rgba(255, 255, 255, .05) 100%); +// padding-left: 22px; +// margin-right: 16px; + +// .header-btn-icon { +// top: 1px; +// } +// } + +.header-btn { + border-radius: 3px; + user-select: none; + background: rgba(255, 255, 255, .3); + background: linear-gradient(to bottom, rgba(255, 255, 255, .3) 0, rgba(255, 255, 255, .2) 100%); + color: #f3f3f3; + display: block; + float: left; + font-weight: 700; + height: 30px; + line-height: 30px; + padding: 0 10px; + position: relative; + margin-right: 8px; + min-width: 30px; + text-decoration: none; + cursor: pointer; + + .header-btn-icon { + font-size: 16px; + line-height: 28px; + position: absolute; + top: 0; + left: 0; + } + + &.new-notifications { + background: #ba1212; + + &:hover { + background: #d11515; + } + } + + &.header-member .member { + margin: 0; + border-top-left-radius: 3px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 3px; + + &:hover .member-avatar { + opacity: 1; + } + } + + &:hover { + background: rgba(255, 255, 255, .4); + background: linear-gradient(to bottom, rgba(255, 255, 255, .4) 0, rgba(255, 255, 255, .3) 100%); + color: #fff; + + .header-btn-count { + background: #d11515; + } + } + + &:active { + background: rgba(255, 255, 255, .4); + background: linear-gradient(to bottom, rgba(255, 255, 255, .4) 0, rgba(255, 255, 255, .3) 100%); + } + + &.upgrade { + margin-right: 16px; + + .icon-sm { + padding: 6px 2px 6px 4px; + } + } + + &.upgrade, + &.header-boards { + padding-left: 4px; + } + + &.header-boards { + padding-right: 4px; + } + + &.header-login, + &.header-signup { + padding: 0 12px; + } + + &.header-signup { + background: #48b512; + background: linear-gradient(to bottom, #48b512 0, #3d990f 100%); + + &:hover { + background: #3d990f; + background: linear-gradient(to bottom, #3d990f 0, #327d0c 100%); + } + + &:active { + background: #327d0c; + } + } + + &.header-go-to-boards { + padding: 0 8px 0 38px; + } + + &.header-go-to-boards .member { + border-top-left-radius: 3px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 3px; + position: absolute; + left: 0; + } +} + +// .header-btn-text { +// padding: 0 8px; +// } + +// .header-notification-list ul { +// margin-top: 8px; +// } + +// .header-notification-list .action-comment { +// max-height: 250px; +// overflow-y: auto; +// } + +// .header-user { +// position: absolute; +// top: 5px; +// right: 0; +// } diff --git a/client/components/main/helpers.js b/client/components/main/helpers.js new file mode 100644 index 00000000000..7ad602f180e --- /dev/null +++ b/client/components/main/helpers.js @@ -0,0 +1,63 @@ +var Helpers = { + error: function() { + return Session.get('error'); + }, + + toLowerCase: function(text) { + return text && text.toLowerCase(); + }, + + toUpperCase: function(text) { + return text && text.toUpperCase(); + }, + + firstChar: function(text) { + return text && text[0].toUpperCase(); + }, + + session: function(prop) { + return Session.get(prop); + }, + + getUser: function(userId) { + return Users.findOne(userId); + } +}; + +// Register all Helpers +_.each(Helpers, function(fn, name) { Blaze.registerHelper(name, fn); }); + +// XXX I believe we should compute a HTML rendered field on the server that +// would handle markdown, emojies and user mentions. We can simply have two +// fields, one source, and one compiled version (in HTML) and send only the +// compiled version to most users -- who don't need to edit. +// In the meantime, all the transformation are done on the client using the +// Blaze API. +var at = HTML.CharRef({html: '@', str: '@'}); +Blaze.Template.registerHelper('mentions', new Template('mentions', function() { + var view = this; + var content = Blaze.toHTML(view.templateContentBlock); + var currentBoard = Session.get('currentBoard'); + var knowedUsers = _.map(currentBoard.members, function(member) { + member.username = Users.findOne(member.userId).username; + return member; + }); + + var mentionRegex = /\B@(\w*)/gi; + var currentMention, knowedUser, href, linkClass, linkValue, link; + while (currentMention = mentionRegex.exec(content)) { + + knowedUser = _.findWhere(knowedUsers, { username: currentMention[1] }); + if (! knowedUser) + continue; + + linkValue = [' ', at, knowedUser.username]; + href = Router.url('Profile', { username: knowedUser.username }); + linkClass = 'atMention' + (knowedUser.userId === Meteor.userId() ? ' me' : ''); + link = HTML.A({ href: href, 'class': linkClass }, linkValue); + + content = content.replace(currentMention[0], Blaze.toHTML(link)); + } + + return HTML.Raw(content); +})); diff --git a/client/components/main/layouts.jade b/client/components/main/layouts.jade new file mode 100644 index 00000000000..18df4d9e876 --- /dev/null +++ b/client/components/main/layouts.jade @@ -0,0 +1,17 @@ +head + title LibreBoard + meta(name="viewport" + content="maximum-scale=1.0,width=device-width,initial-scale=1.0,user-scalable=0") + link(rel="shortcut icon" href="/favicon.png") + +template(name="userFormsLayout") + h1.at-form-landing-logo + img(src="/logo.png" title="LibreBoard") + +yield + +template(name="defaultLayout") + #surface + +header + #content + +yield + diff --git a/client/components/main/popup.js b/client/components/main/popup.js new file mode 100644 index 00000000000..53695d10441 --- /dev/null +++ b/client/components/main/popup.js @@ -0,0 +1,16 @@ +Popup.template.events({ + click: function(evt) { + if (evt.originalEvent) { + evt.originalEvent.clickInPopup = true; + } + }, + 'click .js-back-view': function() { + Popup.back(); + }, + 'click .js-close-popover': function() { + Popup.close(); + }, + 'click .js-confirm': function() { + this.__afterConfirmAction.call(this); + } +}); diff --git a/client/components/main/popup.styl b/client/components/main/popup.styl new file mode 100644 index 00000000000..8c9993af97d --- /dev/null +++ b/client/components/main/popup.styl @@ -0,0 +1,585 @@ +@import 'nib' + +.pop-over + background: #fff + border-radius: 3px + border: 1px solid #dbdbdb + border-bottom-color: #c2c2c2 + box-shadow: 0 1px 6px rgba(0, 0, 0, .3) + display: none + overflow: hidden + position: absolute + width: 300px + z-index: 99999 + margin-top: 5px + + hr + margin: 4px -10px + width: 275px + 2*10px + + input[type="text"], + input[type="email"], + input[type="password"] + margin: 4px 0 12px + width: 100% + + input[type="file"] + width: 240px + + select + width: 100% + margin-bottom: 14px + + textarea + height: 72px + margin: 4px 0 12px + width: 100% + + .empty + margin: 0 + + img + max-width: 270px + + .custom-image img + height: 18px + left: 9px + top: 9px + width: 18px + + .title + line-height: 32px + + .header + height: 36px + position: relative + margin-bottom: 8px + background: #F7F7F7 + border-bottom: 1px solid #dcdcdc + color: darken(white, 60%) + + .header-title + display: block + line-height: 32px + padding-top: 4px + margin: 0 10px + font-weight: bold + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + + .back-btn, .close-btn + &:hover .icon-sm + color: darken(white, 80%) + + .back-btn + padding: 10px + float: left + + .close-btn + padding: 10px 10px 10px 4px + position: absolute + top: 0 + right: 0 + + .content + overflow-x: hidden + overflow-y: auto + padding: 0 10px 10px + max-height: 550px + + .quiet + padding: 6px 6px 4px + + &.search-over + background: #f0f0f0 + min-height: 114px + + .header + display: none + + .content + padding: 8px 4px 8px 10px + margin-right: 8px + + &::-webkit-scrollbar-button + display: block + height: 4px + width: 4px + +.select-members-list + margin-bottom: 8px + +.pop-over-list + + &.navigable li.not-selectable>a:hover, + li.not-selectable>a:hover + color: #8c8c8c + cursor: default + + .icon-sm + color: #a6a6a6 + + li > a + cursor: pointer + display: block + font-weight: 700 + padding: 6px 10px + position: relative + margin: 0 -10px + text-decoration: none + + .item-name + display: block + width: auto + padding-right: 22px + + &:hover + background-color: #005377 + color: #fff + + .sub-name, + .quiet + color: #eee + + .unread-indicator + background: #fff + + .icon-sm + color: #fff + + .sub-name + clear: both + color: #8c8c8c + display: block + font-size: 12px + font-weight: 400 + line-height: 15px + margin-top: 4px + + &.current + background-color: #e2e6e9 + + .unread-indicator + background: #2e85b8 + background: linear-gradient(to bottom, #2e85b8 0, #2b7cab 100%) + border-radius: 7px + display: block + height: 14px + opacity: 0 + position: absolute + right: 16px + top: 8px + width: 14px + + &.any + opacity: 1 + + &:active + background-color: #2e85b8 + + &.disabled + color: #8c8c8c + cursor: default + + .vis-icon + opacity: .35 + + .icon-sm + color: #a6a6a6 + + &:hover + background: none + + .sub-name, + .quiet + color: #8c8c8c + + .icon-sm + color: #a6a6a6 + + &:active + background: none + + &.inset li > a + border-radius: 3px + margin: 0 + + .pop-over-list.checkable + + .icon-check + display: none + position: absolute + top: 6px + right: 12px + + li.active a + padding-right: 28px + + .icon-check + display: block + + &.left-check + + .icon-check + right: auto + left: 10px + + li a + padding-right: 10px + padding-left: 30px + + li.active a + padding-right: 10px + + &.normal-weight li>a + font-weight: 400 + + &.navigable + + li > a:hover + background-color: transparent + color: #4d4d4d + + .sub-name, + .quiet + color: #8c8c8c + + .icon-sm + color: #a6a6a6 + + li.selected > a + background-color: #005377 + color: #fff + + .sub-name, + .quiet + color: #eee + + li.selected > a + + &.current + background-color: #005377 + + .unread-indicator + background: #fff + + .icon-sm + color: #fff + + &:active + background-color: #005377 + +.pop-over.miniprofile + + .header + border-bottom-color: transparent + height: 30px + position: absolute + right: 0 + top: 0 + width: 60px + z-index: 1 + + .header-title + display: none + + .pop-over-list + padding-top: 8px + +.mini-profile-info + margin-top: 8px + min-height: 56px + position: relative + + .member-large + position: absolute + top: 2px + left: 2px + + .info + margin: 0 0 0 64px + word-wrap: break-word + + h3 a + text-decoration: none + + &:hover + text-decoration: underline + +.pop-over.avdetail .header + border-bottom-color: transparent + height: 20px + position: absolute + top: 8px + left: 8px + right: 8px + z-index: 0 + +.pop-over.avdetail .header-title + display: none + +.pop-over.avdetail .content + text-align: center + +.pop-over.avdetail .mem-info + margin: 2px 24px 8px + position: relative + z-index: 1 + width: 222px + +.pop-over.avdetail .mem-info h3 a + text-decoration: none + +.pop-over.avdetail .mem-info h3 a:hover + text-decoration: underline + +.pop-over-label-list li, +.pop-over-member-list li + + &.disabled a + cursor:default + + &:not(.disabled):hover a + background-color: #005377 + color: #fff + + +.pop-over-label-list, +.pop-over-member-list, +.pop-over-emoji-list, +.pop-over-card-list + li + a + border-radius: 3px + display: block + height: 30px + line-height: 30px + overflow: hidden + position: relative + text-overflow: ellipsis + text-decoration: none + white-space: nowrap + padding: 4px + margin-bottom: 2px + + &.multi-line + line-height: 16px + + .member + margin-right: 8px + + .card-label + float: left + height: 30px + margin: 0 8px 0 0 + padding: 0 + width: 30px + + .option, + .icon-check + background-clip: content-box + background-origin: content-box + padding: 11px + position: absolute + top: 0 + right: 0 + + .sub-name + font-size: 12px + + + &:last-child a + margin-bottom: 0 + + &.disabled + opacity: .5 + + &.active a, + &.selected a + background: none + color: #4d4d4d + cursor: default + + .quiet + color: #8c8c8c + + &.email-invite + + .member + display: none + + a + padding: 0 10px + + &.selected a + background-color: #005377 + color: #fff + + .quiet + color: #eee + + .card-label + border-radius: 3px + + .icon-check + color: #fff + + &.active a .icon-check + display: block + + &.unconfirmed a.name + line-height: 16px + + &.options li + + &.selected a + padding-right: 28px + + .option + display: block + opacity: .5 + + &:hover + opacity: 1 + + &.disabled.selected a + padding-right: 0 + + .option + display: none + + + &.no-option.selected a + padding-right: 6px + + .option + display: none + + &.collapsed + + &.checkable li.active a + padding-right: 0 + + li + float: left + margin: 0 3px 3px 0 + + a + padding: 0 + margin: 0 + width: 30px + + .member + opacity: .8 + + .full-name + display: none + + &.selected a .member, + &.active.selected a .member + border-color: #005377 + opacity: .9 + + &.active a + + .member + border-color: #2e85b8 + opacity: 1 + + .icon-check + border-radius: 3px + background-color: #2e85b8 + bottom: 0 + color: #fff + display: block + padding: 0 + right: 0 + top: auto + + &.checkable li.active a + padding-right: 28px + + &.filtered li + display: none + + &.matches-filter + display: block + + &.limited li.exceeds-limit + display: none + +.pop-over-emoji-list li > a + padding: 2px 4px + + .emoji + margin: 0 6px + +.pop-over-card-list li > a + padding: 2px 4px + +.login-signup-popover + padding: 15px + + .form-tabs + display: none + + h1 + margin-bottom: 15px + + p + margin: 8px 0 + + .form-parts-container + position: relative + + .active-box + position: absolute + top: 0 + background: #e2e2e2 + border: 1px solid #c9c9c9 + border-radius: 3px + z-index: 1 + height: 100% + width: 49% + transition-property: all + transition-duration: .4s + opacity: 1 + + &.start + opacity: 0 + left: 25% + + .signup-form, + .login-form + position: relative + box-sizing: border-box + padding: 20px + width: 50% + z-index: 2 + opacity: .3 + transition-property: opacity + transition-duration: .2s + + .active + opacity: 1 + + + .js-signup-form-pos + left: 0 + + .login-form + position: absolute + top: 0 + + .login-form .icon-google + position: absolute + left: 5px + top: 3px + + .login-form .button.google + padding-left: 40px + margin: 0 0 15px 0 + + .js-login-form-pos + left: 50% diff --git a/client/components/main/popup.tpl.jade b/client/components/main/popup.tpl.jade new file mode 100644 index 00000000000..ba24db0a3ba --- /dev/null +++ b/client/components/main/popup.tpl.jade @@ -0,0 +1,13 @@ +.pop-over.clearfix( + class="{{#unless title}}miniprofile{{/unless}}" + class=currentBoard.colorClass + style="display:block; left:{{offset.left}}px; top:{{offset.top}}px;") + .header.clearfix + if hasPopupParent + a.back-btn.js-back-view + i.fa.fa-chevron-left + span.header-title= title + a.close-btn.js-close-popover + i.fa.fa-times + .content.clearfix + +Template.dynamic(template=popupName data=dataContext) diff --git a/client/components/main/rendered.js b/client/components/main/rendered.js new file mode 100644 index 00000000000..787e8225ed9 --- /dev/null +++ b/client/components/main/rendered.js @@ -0,0 +1,40 @@ +Template.editor.rendered = function() { + this.$('textarea').textcomplete([ + // Emojies + { + match: /\B:([\-+\w]*)$/, + search: function(term, callback) { + callback($.map(Emoji.values, function(emoji) { + return emoji.indexOf(term) === 0 ? emoji : null; + })); + }, + template: function(value) { + var image = ''; + return image + value; + }, + replace: function(value) { + return ':' + value + ':'; + }, + index: 1 + }, + + // User mentions + { + match: /\B@(\w*)$/, + search: function(term, callback) { + var currentBoard = Boards.findOne(Session.get('currentBoard')); + callback($.map(currentBoard.members, function(member) { + var username = Users.findOne(member.userId).username; + return username.indexOf(term) === 0 ? username : null; + })); + }, + template: function(value) { + return value; + }, + replace: function(username) { + return '@' + username + ' '; + }, + index: 1 + } + ]); +}; diff --git a/client/components/main/router.js b/client/components/main/router.js new file mode 100644 index 00000000000..bae832e8664 --- /dev/null +++ b/client/components/main/router.js @@ -0,0 +1,5 @@ +Router.route('/', { + name: 'Home', + redirectLoggedInUsers: true, + authenticated: true +}); diff --git a/client/components/main/spinner.styl b/client/components/main/spinner.styl new file mode 100644 index 00000000000..f4b8cc8667a --- /dev/null +++ b/client/components/main/spinner.styl @@ -0,0 +1,45 @@ +/* + * From https://github.com/tobiasahlin/SpinKit + * + * Usage: + * + *
+ *
+ *
+ *
+ *
+ *
+ *
+ * + */ + +.sk-spinner-wave { + + &.sk-spinner { + width: 50px; + height: 50px; + margin: auto; + margin-top: 30vh; + text-align: center; + font-size: 10px; + } + + div { + background-color: #333; + height: 100%; + width: 6px; + display: inline-block; + + animation: sk-waveStretchDelay 1.2s infinite ease-in-out; + } + + .sk-rect2 { animation-delay: -1.1s } + .sk-rect3 { animation-delay: -1.0s } + .sk-rect4 { animation-delay: -0.9s } + .sk-rect5 { animation-delay: -0.8s } +} + +@keyframes sk-waveStretchDelay { + 0%, 40%, 100% { transform: scaleY(0.4) } + 20% { transform: scaleY(1.0) } +} diff --git a/client/components/main/spinner.tpl.jade b/client/components/main/spinner.tpl.jade new file mode 100644 index 00000000000..9310a6e5b07 --- /dev/null +++ b/client/components/main/spinner.tpl.jade @@ -0,0 +1,6 @@ +.sk-spinner.sk-spinner-wave(class=currentBoard.colorClass) + .sk-rect1 + .sk-rect2 + .sk-rect3 + .sk-rect4 + .sk-rect5 diff --git a/client/components/main/templates.html b/client/components/main/templates.html new file mode 100644 index 00000000000..e9be0f93dce --- /dev/null +++ b/client/components/main/templates.html @@ -0,0 +1,18 @@ + + + + + + + diff --git a/client/components/modal/events.js b/client/components/modal/events.js new file mode 100644 index 00000000000..2943f841c25 --- /dev/null +++ b/client/components/modal/events.js @@ -0,0 +1,14 @@ +Template.modal.events({ + 'click .window-overlay': function(event) { + // We only want to catch the event if the user click on the .window-overlay + // div itself, not a child (ie, not the overlay window) + if (event.target !== event.currentTarget) + return; + Utils.goBoardId(this.card.board()._id); + event.preventDefault(); + }, + 'click .js-close-window': function(event) { + Utils.goBoardId(this.card.board()._id); + event.preventDefault(); + } +}); diff --git a/client/components/modal/helpers.js b/client/components/modal/helpers.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/client/components/modal/modal.tpl.jade b/client/components/modal/modal.tpl.jade new file mode 100644 index 00000000000..2f40ad75341 --- /dev/null +++ b/client/components/modal/modal.tpl.jade @@ -0,0 +1,5 @@ +.window-overlay.show + .window + .window-wrapper.clearfix + a.icon-lg.fa.fa-times.dialog-close-button.js-close-window(title="{{_ 'modal-close-title'}}") + +UI.dynamic(template=template) diff --git a/client/components/sidebar/events.js b/client/components/sidebar/events.js new file mode 100644 index 00000000000..1067421fc9f --- /dev/null +++ b/client/components/sidebar/events.js @@ -0,0 +1,93 @@ +Template.filterSidebar.events({ + 'click .js-toggle-label-filter': function(event) { + Filter.labelIds.toogle(this._id); + Filter.resetExceptions(); + event.preventDefault(); + }, + 'click .js-toogle-member-filter': function(event) { + Filter.members.toogle(this._id); + Filter.resetExceptions(); + event.preventDefault(); + }, + 'click .js-clear-all': function(event) { + Filter.reset(); + event.preventDefault(); + } +}); + +var getMemberIndex = function(board, searchId) { + for (var i = 0; i < board.members.length; i++) { + if (board.members[i].userId === searchId) + return i; + } + throw new Meteor.Error('Member not found'); +}; + +Template.memberPopup.events({ + 'click .js-filter-member': function() { + Filter.members.toogle(this.userId); + Popup.close(); + }, + 'click .js-change-role': Popup.open('changePermissions'), + 'click .js-remove-member': Popup.afterConfirm('removeMember', function() { + var currentBoard = Boards.findOne(Session.get('currentBoard')); + var memberIndex = getMemberIndex(currentBoard, this.userId); + var setQuery = {}; + setQuery[['members', memberIndex, 'isActive'].join('.')] = false; + Boards.update(currentBoard._id, { $set: setQuery }); + Popup.close(); + }), + 'click .js-leave-member': function() { + // @TODO + Popup.close(); + } +}); + +Template.membersWidget.events({ + 'click .js-open-manage-board-members': Popup.open('addMember'), + 'click .member': Popup.open('member') +}); + +Template.labelsWidget.events({ + 'click .js-label': Popup.open('editLabel'), + 'click .js-add-label': Popup.open('createLabel') +}); + +// Template.addMemberPopup.events({ +// 'click .pop-over-member-list li:not(.disabled)': function(event, t) { +// var userId = this._id; +// var boardId = t.data.board._id; +// var currentMembersIds = _.pluck(t.data.board.members, 'userId'); +// if (currentMembersIds.indexOf(userId) === -1) { +// Boards.update(boardId, { +// $push: { +// members: { +// userId: userId, +// isAdmin: false, +// isActive: true +// } +// } +// }); +// } else { +// var memberIndex = getMemberIndex(t.data.board, userId); +// var setQuery = {}; +// setQuery[['members', memberIndex, 'isActive'].join('.')] = true; +// Boards.update(boardId, { $set: setQuery }); +// } +// Popup.close(); +// } +// }); + +// Template.changePermissionsPopup.events({ +// 'click .js-set-admin, click .js-set-normal': function(event) { +// var currentBoard = Boards.findOne(Session.get('currentBoard')); +// var memberIndex = getMemberIndex(currentBoard, this.user._id); +// var isAdmin = $(event.currentTarget).hasClass('js-set-admin'); +// var setQuery = {}; +// setQuery[['members', memberIndex, 'isAdmin'].join('.')] = isAdmin; +// Boards.update(currentBoard._id, { +// $set: setQuery +// }); +// Popup.back(1); +// } +// }); diff --git a/client/components/sidebar/helpers.js b/client/components/sidebar/helpers.js new file mode 100644 index 00000000000..a76dad7f1d9 --- /dev/null +++ b/client/components/sidebar/helpers.js @@ -0,0 +1,51 @@ +var widgetTitles = { + filter: 'filter-cards', + background: 'change-background' +}; + +Template.boardSidebar.helpers({ + currentWidget: function() { + return Session.get('currentWidget') + 'Sidebar'; + }, + currentWidgetTitle: function() { + return TAPi18n.__(widgetTitles[Session.get('currentWidget')]); + } +}); + +// Template.addMemberPopup.helpers({ +// isBoardMember: function() { +// var user = Users.findOne(this._id); +// return user && user.isBoardMember(); +// } +// }); + +Template.memberPopup.helpers({ + user: function() { + return Users.findOne(this.userId); + }, + memberType: function() { + var type = Users.findOne(this.userId).isBoardAdmin() ? 'admin' : 'normal'; + return TAPi18n.__(type).toLowerCase(); + } +}); + +// Template.removeMemberPopup.helpers({ +// user: function() { +// return Users.findOne(this.userId) +// }, +// board: function() { +// return currentBoard(); +// } +// }); + +// Template.changePermissionsPopup.helpers({ +// isAdmin: function() { +// return this.user.isBoardAdmin(); +// }, +// isLastAdmin: function() { +// if (! this.user.isBoardAdmin()) +// return false; +// var nbAdmins = _.where(currentBoard().members, { isAdmin: true }).length; +// return nbAdmins === 1; +// } +// }); diff --git a/client/components/sidebar/infiniteScrolling.js b/client/components/sidebar/infiniteScrolling.js new file mode 100644 index 00000000000..df3b89015dd --- /dev/null +++ b/client/components/sidebar/infiniteScrolling.js @@ -0,0 +1,37 @@ +var peakAnticipation = 200; + +Mixins.InfiniteScrolling = BlazeComponent.extendComponent({ + onCreated: function() { + this._nextPeak = Infinity; + }, + + setNextPeak: function(v) { + this._nextPeak = v; + }, + + getNextPeak: function() { + return this._nextPeak; + }, + + resetNextPeak: function() { + this._nextPeak = Infinity; + }, + + // To be overwritten by consumers of this mixin + reachNextPeak: function() { + + }, + + events: function() { + return [{ + scroll: function(evt) { + var domElement = evt.currentTarget; + var altitude = domElement.scrollTop + domElement.offsetHeight; + altitude += peakAnticipation; + if (altitude >= this.callFirstWith(null, 'getNextPeak')) { + this.callFirstWith(null, 'reachNextPeak'); + } + } + }]; + } +}); diff --git a/client/components/sidebar/rendered.js b/client/components/sidebar/rendered.js new file mode 100644 index 00000000000..2b58bf337cc --- /dev/null +++ b/client/components/sidebar/rendered.js @@ -0,0 +1,21 @@ +Template.membersWidget.rendered = function() { + if (! Meteor.user().isBoardMember()) + return; + + _.each(['.js-member', '.js-label'], function(className) { + Utils.liveEvent('mouseover', function($this) { + $this.find(className).draggable({ + appendTo: 'body', + helper: 'clone', + revert: 'invalid', + revertDuration: 150, + snap: false, + snapMode: 'both', + start: function() { + Popup.close(); + } + }); + }); + }); +}; + diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js new file mode 100644 index 00000000000..3f0142d44e3 --- /dev/null +++ b/client/components/sidebar/sidebar.js @@ -0,0 +1,55 @@ +BlazeComponent.extendComponent({ + template: function() { + return 'boardSidebar'; + }, + + mixins: function() { + return [Mixins.InfiniteScrolling]; + }, + + onCreated: function() { + this._isOpen = new ReactiveVar(true); + }, + + isOpen: function() { + return this._isOpen.get(); + }, + + open: function() { + if (! this._isOpen.get()) { + this._isOpen.set(true); + } + }, + + hide: function() { + if (this._isOpen.get()) { + this._isOpen.set(false); + } + }, + + toogle: function() { + this._isOpen.set(! this._isOpen.get()); + }, + + calculateNextPeak: function() { + var altitude = this.find('.js-board-sidebar-content').scrollHeight; + this.callFirstWith(this, 'setNextPeak', altitude); + }, + + reachNextPeak: function() { + var activitiesComponent = this.componentChildren('activities')[0]; + activitiesComponent.loadNextPage(); + }, + + isTongueHidden: function() { + return this.isOpen() && Filter.isActive(); + }, + + events: function() { + // XXX Hacky, we need some kind of `super` + var mixinEvents = this.getMixin(Mixins.InfiniteScrolling).events(); + return mixinEvents.concat([{ + 'click .js-toogle-sidebar': this.toogle + }]); + } +}).register('boardSidebar'); diff --git a/client/components/sidebar/sidebar.styl b/client/components/sidebar/sidebar.styl new file mode 100644 index 00000000000..4b741dc7e7b --- /dev/null +++ b/client/components/sidebar/sidebar.styl @@ -0,0 +1,154 @@ +@import 'nib' + +.sidebar + .sidebar-content + padding: 10px 20px + background: white + box-shadow: -10px 0px 5px -10px darken(white, 30%) + z-index: 10 + position: absolute + top: 0 + bottom: 0 + right: 0 + left: 0 + overflow-x: hidden + overflow-y: auto + + h3 + color: darken(white, 50%) + + hr + margin: 8px 0 + +.board-sidebar + width: 248px + position: absolute + top: 0 + right: -@width + bottom: 0 + transition: top .1s, right .1s, width .1s + + &.is-open + right: 0 + +.board-widget-nav + border-radius: 3px + background: #dcdcdc + overflow: hidden + padding: 0 + position: relative + + .toggle-widget-nav + border-radius: 3px + color: #8c8c8c + margin: 0 + padding: 7px 10px + position: relative + cursor: pointer + + .toggle-menu-icon + position: absolute + top: 8px + right: 8px + + &:hover + background: #ccc + color: #4d4d4d + + .nav-list + display: block + opacity: 1 + max-height: 400px + + hr + margin: 2px 0 + color: #ccc + background: #ccc + + .nav-list-item + display: block + font-weight: 700 + line-height: 30px + overflow: hidden + padding: 0 8px 0 36px + position: relative + text-decoration: none + + .icon-type + left: 10px + position: absolute + top: 6px + + &:hover + background: #ccc + + .icon-type + color: #686868 + + .nav-list-sub-item + font-weight: 400 + color: #666 + + &:hover + color: #4d4d4d + + &.collapsed + + .nav-list + max-height: 0 + opacity: 0 + + hr + margin: 0 + + .toggle-widget-nav + color: #4d4d4d + + +.board-widget-title + display: block + min-height: 20px + margin-bottom: 6px + +.board-widget-content + position: relative + z-index: 1 + +.board-widget h4 + margin: 5px 0 + +.board-widget-activity + margin-right: -4px + +.sidebar-tongue + display: block + width: 30px + height: @width + left: -@width + position: absolute + top: 12px + z-index: 15 + background: white + border-radius: left 3px + box-shadow: -4px 0px 7px -4px darken(white, 30%) + color: darken(white, 50%) + transition: left .1s + + i.fa + margin: 9px + transition: transform 0.5s + + .board-sidebar.is-open & + left: -@width + 2px + + // XXX Bug: we should add a padding left + &:hover + left: -@width + 5px + + i.fa + transform: rotate(180deg) + + &.is-hidden, + .board-sidebar.is-open &.is-hidden + z-index: 0 + left: 5px diff --git a/client/components/sidebar/templates.html.old b/client/components/sidebar/templates.html.old new file mode 100644 index 00000000000..d8b063f0437 --- /dev/null +++ b/client/components/sidebar/templates.html.old @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/components/sidebar/templates.jade b/client/components/sidebar/templates.jade new file mode 100644 index 00000000000..23a1a87ef26 --- /dev/null +++ b/client/components/sidebar/templates.jade @@ -0,0 +1,103 @@ +template(name="boardSidebar") + .board-sidebar.sidebar(class="{{#if isOpen}}is-open{{/if}}") + a.sidebar-tongue.js-toogle-sidebar( + class="{{#if isTongueHidden}}is-hidden{{/if}}") + i.fa.fa-chevron-left + .sidebar-content.js-board-sidebar-content + //- XXX https://github.com/peerlibrary/meteor-blaze-components/issues/30 + if Filter.isActive + +filterSidebar + else + +homeSidebar + +template(name='homeSidebar') + +membersWidget + hr.clear + +labelsWidget + hr.clear + h3 + i.fa.fa-comments-o + | {{_ 'activities'}} + +activities(mode="board") + +template(name="filterSidebar") + ul.pop-over-label-list.checkable + each currentBoard.labels + li.item.matches-filter + a.name.js-toggle-label-filter + span.card-label(class="card-label-{{color}}") + span.full-name + if name + = name + else + span.quiet {{_ "label-default" color}} + if Filter.labelIds.isSelected _id}} + span.icon-sm.fa.fa-check + hr + ul.pop-over-member-list.checkable + each currentBoard.members + if isActive + with getUser userId + li.item.js-member-item( + class="{{#if Filter.members.isSelected _id}}active{{/if}}") + a.name.js-toogle-member-filter + +userAvatar(user=this size="small") + span.full-name + = profile.name + | ({{ username }}) + if Filter.members.isSelected _id + span.icon-sm.fa.fa-check + hr + a.js-clear-all(class="{{#unless Filter.isActive}}disabled{{/unless}}") + | {{_ 'filter-clear'}} + +template(name="membersWidget") + .board-widget.board-widget-members + h3 + i.fa.fa-user + | {{_ 'members'}} + .board-widget-content + each currentBoard.members + +userAvatar( + userId=this.userId + draggable=true + size="small" + showBadges=true) + unless isSandstorm + if currentUser.isBoardAdmin + a.js-open-manage-board-members + +template(name="labelsWidget") + .board-widget.board-widget-labels + h3 + i.fa.fa-tags + | {{_ 'labels'}} + .board-widget-content + each currentBoard.labels + a.card-label(class="card-label-{{color}}").js-label + span.card-label-name= name + a.card-label.js-add-label + i.fa.fa-plus + +template(name="memberPopup") + .board-member-menu: .mini-profile-info + +userAvatar(user=user) + .info + h3.bottom + a.js-profile(href="{{pathFor route='Profile' username=user.username}}") + = user.profile.name + p.quiet.bottom @#{user.username} + if currentUser.isBoardMember + ul.pop-over-list + li + a.js-filter-member Filter cards + if currentUser.isBoardAdmin + li + a.js-change-role + | {{_ 'change-permissions'}} + span.quiet (#{memberType}) + li + if currentUser.isBoardAdmin + a.js-remove-member {{_ 'remove-from-board'}} + else + a.js-leave-member {{_ 'leave-board'}} diff --git a/client/components/users/avatar.jade b/client/components/users/avatar.jade new file mode 100644 index 00000000000..70ef69e01ee --- /dev/null +++ b/client/components/users/avatar.jade @@ -0,0 +1,7 @@ +template(name="userAvatar") + .member(class="{{class}} {{# if draggable }}js-member{{else}}js-member-on-card-menu{{/if}}" + title="{{userData.profile.name}} ({{userData.username}})") + +avatar(user=userData size=size) + if showBadges + span.member-status(class="{{# if userData.profile.status}}active{{/if}}") + span.member-type(class=memberType) diff --git a/client/components/users/events.js b/client/components/users/events.js new file mode 100644 index 00000000000..14df9717e2f --- /dev/null +++ b/client/components/users/events.js @@ -0,0 +1,59 @@ +// XXX This should be handled by default (and in a better way) by useraccounts. +// See https://github.com/meteor-useraccounts/core/issues/384 +Template.atForm.onRendered(function() { + this.find('input').focus(); +}); + +Template.memberMenuPopup.events({ + 'click .js-language': Popup.open('setLanguage'), + 'click .js-logout': function(evt) { + evt.preventDefault(); + + Meteor.logout(function() { + Router.go('Home'); + }); + } +}); + +Template.setLanguagePopup.events({ + 'click .js-set-language': function(evt) { + Users.update(Meteor.userId(), { + $set: { + 'profile.language': this.tag + } + }); + evt.preventDefault(); + } +}); + +Template.profileEditForm.events({ + 'click .js-edit-profile': function() { + Session.set('ProfileEditForm', true); + }, + 'click .js-cancel-edit-profile': function() { + Session.set('ProfileEditForm', false); + }, + 'submit #ProfileEditForm': function(evt, t) { + var name = t.find('#name').value; + var bio = t.find('#bio').value; + + // trim and update + if ($.trim(name)) { + Users.update(this.profile()._id, { + $set: { + 'profile.name': name, + 'profile.bio': bio + } + }, function() { + + // update complete close profileEditForm + Session.set('ProfileEditForm', false); + }); + } + evt.preventDefault(); + } +}); + +Template.memberName.events({ + 'click .js-show-mem-menu': Popup.open('user') +}); diff --git a/client/components/users/form.styl b/client/components/users/form.styl new file mode 100644 index 00000000000..845c810dd67 --- /dev/null +++ b/client/components/users/form.styl @@ -0,0 +1,50 @@ +.at-form-landing-logo + width: 275px + margin: auto + margin-top: 50px + margin-top: 17vh + + img + width: 275px + + +.at-form + margin: auto + width: 275px + padding: 25px + margin-top: 20px + padding-bottom: 10px + background: #fff + border-radius: 3px + border: 1px solid #dbdbdb + border-bottom-color: #c2c2c2 + box-shadow: 0 1px 6px rgba(0, 0, 0, .3) + + .at-link + color: darken(#27AE60, 40%) + + label + margin-bottom: 3px + + input + width: 100% + + .at-title + background: #F7F7F7 + margin: -25px + padding: 15px 25px 5px + margin-bottom: 20px + border-bottom: 1px solid #dcdcdc + color: darken(white, 70%) + font-weight: bold + + .at-signup-link, + .at-signin-link, + .at-forgotPwd + font-size: 0.9em + margin-top: 15px + color: darken(white, 70%) + + .at-signUp, + .at-signIn + font-weight: bold diff --git a/client/components/users/headerButtons.jade b/client/components/users/headerButtons.jade new file mode 100644 index 00000000000..74c24ad5286 --- /dev/null +++ b/client/components/users/headerButtons.jade @@ -0,0 +1,27 @@ +template(name="headerUserBar") + #header-user-bar + if currentUser + a.js-open-header-member-menu + if currentUser.profile.name + = currentUser.profile.name + else + = currentUser.username + i.fa.fa-chevron-down + else + a(href="{{pathFor route='signUp'}}") Sign in + span.separator - + a(href="{{pathFor route='signIn'}}") Log in + +template(name="memberHeader") + a.header-member.js-open-header-member-menu + span= currentUser.profile.name + +userAvatar(user=currentUser size="small") + +template(name="memberMenuPopup") + ul.pop-over-list + li: a(href="{{pathFor route='Profile' username=currentUser.username}}") {{_ 'profile'}} + li: a.js-language {{_ 'language'}} + li: a(href = "{{pathFor route='Settings'}}") {{_ 'settings'}} + hr + ul.pop-over-list + li: a.js-logout {{_ 'log-out'}} diff --git a/client/components/users/headerButtons.js b/client/components/users/headerButtons.js new file mode 100644 index 00000000000..70594fb5813 --- /dev/null +++ b/client/components/users/headerButtons.js @@ -0,0 +1,5 @@ +Template.headerUserBar.events({ + 'click .js-sign-in': Popup.open('signup'), + 'click .js-log-in': Popup.open('login'), + 'click .js-open-header-member-menu': Popup.open('memberMenu') +}); diff --git a/client/components/users/helpers.js b/client/components/users/helpers.js new file mode 100644 index 00000000000..33867298c27 --- /dev/null +++ b/client/components/users/helpers.js @@ -0,0 +1,27 @@ +Template.userAvatar.helpers({ + userData: function() { + if (! this.user) { + this.user = Users.findOne(this.userId); + } + return this.user; + }, + memberType: function() { + var userId = this.userId || this.user._id; + var user = Users.findOne(userId); + return user && user.isBoardAdmin() ? 'admin' : 'normal'; + } +}); + +Template.setLanguagePopup.helpers({ + languages: function() { + return _.map(TAPi18n.getLanguages(), function(lang, tag) { + return { + tag: tag, + name: lang.name + }; + }); + }, + isCurrentLanguage: function() { + return this.tag === TAPi18n.getLanguage(); + } +}); diff --git a/client/components/users/member.styl b/client/components/users/member.styl new file mode 100644 index 00000000000..3dfdaa3718d --- /dev/null +++ b/client/components/users/member.styl @@ -0,0 +1,107 @@ +@import 'nib' + +avatar-radius = 50% + +.member + border-radius: 3px + display: block + float: left + height: 30px + width: @height + margin: 0 4px 4px 0 + position: relative + cursor: pointer + user-select: none + z-index: 1 + text-decoration: none + border-radius: avatar-radius + + .avatar + height: 100% + width: @height + display: flex + align-items: center + justify-content: center + overflow: hidden + border-radius: avatar-radius + + .avatar-initials + font-weight: bold + max-width: 100% + max-height: 100% + font-size: 14px + line-height: 200% + background-color: #dbdbdb + color: #444444 + + .avatar-image + max-width: 100% + max-height: 100% + + .member-status + background-color: #b3b3b3 + border: 1px solid #fff + border-radius: 50% + height: 8px + width: @height + position: absolute + right: 0px + bottom: 0px + border: 1px solid white + + &.active + background: #64c464 + border-color: #daf1da + + &.idle + background: #e4e467 + border-color: #f7f7d4 + + &.disconnected + background: #bdbdbd + border-color: #ededed + + &.extra-small + .avatar-initials + font-size: 9px + width: 18px + height: 18px + line-height: 18px + + .avatar-image + width: 18px + height: 18px + + &.small + width: 30px + height: 30px + + .avatar-initials + font-size: 12px + line-height: 30px + + &.large + height: 85px + line-height: 85px + width: 85px + + .avatar + width: 85px + height: 85px + + .avatar-initials + font-size: 16px + font-weight: 700 + line-height: 85px + width: 85px + +.atMention + background: #dbdbdb + border-radius: 3px + padding: 1px 4px + margin: -1px 0 + display: inline-block + + &.me + background: #cfdfe8 + diff --git a/client/components/users/router.js b/client/components/users/router.js new file mode 100644 index 00000000000..d59e174d263 --- /dev/null +++ b/client/components/users/router.js @@ -0,0 +1,29 @@ + +_.each(['signIn', 'signUp', 'resetPwd', + 'forgotPwd', 'enrollAccount', 'changePwd'], function(routeName) { + AccountsTemplates.configureRoute(routeName, { + layoutTemplate: 'userFormsLayout' + }); +}); + +Router.route('/profile/:username', { + name: 'Profile', + template: 'profile', + waitOn: function() { + return Meteor.subscribe('profile', this.params.username); + }, + data: function() { + var params = this.params; + return { + profile: function() { + return Users.findOne({ username: params.username }); + } + }; + } +}); + +Router.route('/settings', { + name: 'Settings', + template: 'settings', + layoutTemplate: 'AuthLayout' +}); diff --git a/client/components/users/templates.html b/client/components/users/templates.html new file mode 100644 index 00000000000..5783eebf284 --- /dev/null +++ b/client/components/users/templates.html @@ -0,0 +1,118 @@ + + + + + + + + + + + + diff --git a/client/config/accounts.js b/client/config/accounts.js new file mode 100644 index 00000000000..9e0d17d357c --- /dev/null +++ b/client/config/accounts.js @@ -0,0 +1,35 @@ +AccountsTemplates.configure({ + confirmPassword: false, + enablePasswordChange: true, + sendVerificationEmail: true, + showForgotPasswordLink: true +}); + +AccountsTemplates.removeField('password'); +AccountsTemplates.removeField('email'); +AccountsTemplates.addFields([ + { + _id: 'username', + type: 'text', + displayName: 'username', + required: true, + minLength: 5 + }, + { + _id: 'email', + type: 'email', + required: true, + displayName: 'email', + re: /.+@(.+){2,}\.(.+){2,}/, + errStr: 'Invalid email' + }, + { + _id: 'password', + type: 'password', + placeholder: { + signUp: 'At least six characters' + }, + required: true, + minLength: 6 + } +]); diff --git a/client/config/avatar.js b/client/config/avatar.js new file mode 100644 index 00000000000..fc4ba58b0cd --- /dev/null +++ b/client/config/avatar.js @@ -0,0 +1,3 @@ +Avatar.options = { + fallbackType: 'initials' +}; diff --git a/client/config/router.js b/client/config/router.js new file mode 100644 index 00000000000..c859013f5e8 --- /dev/null +++ b/client/config/router.js @@ -0,0 +1,28 @@ +Router.configure({ + loadingTemplate: 'spinner', + notFoundTemplate: 'notfound', + layoutTemplate: 'defaultLayout', + + onBeforeAction: function() { + var options = this.route.options; + + // Redirect logged in users to Boards view when they try to open Login or + // signup views. + if (Meteor.userId() && options.redirectLoggedInUsers) { + return this.redirect('Boards'); + } + + // Authenticated + if (! Meteor.userId() && options.authenticated) { + return this.redirect('atSignIn'); + } + + // Reset default sessions + Session.set('error', false); + Session.set('warning', false); + + Popup.close(); + + this.next(); + } +}); diff --git a/client/lib/emoji-values.js b/client/lib/emoji-values.js new file mode 100644 index 00000000000..1f07ac62da5 --- /dev/null +++ b/client/lib/emoji-values.js @@ -0,0 +1,152 @@ +Emoji.values = ['+1', '-1', '100', '1234', '8ball', 'a', 'ab', 'abc', 'abcd', +'accept', 'aerial_tramway', 'airplane', 'alarm_clock', 'alien', 'ambulance', +'anchor', 'angel', 'anger', 'angry', 'anguished', 'ant', 'apple', 'aquarius', +'aries', 'arrow_backward', 'arrow_double_down', 'arrow_double_up', 'arrow_down', +'arrow_down_small', 'arrow_forward', 'arrow_heading_down', 'arrow_heading_up', +'arrow_left', 'arrow_lower_left', 'arrow_lower_right', 'arrow_right', +'arrow_right_hook', 'arrow_up', 'arrow_up_down', 'arrow_up_small', +'arrow_upper_left', 'arrow_upper_right', 'arrows_clockwise', +'arrows_counterclockwise', 'art', 'articulated_lorry', 'astonished', 'atm', 'b', +'baby', 'baby_bottle', 'baby_chick', 'baby_symbol', 'baggage_claim', 'balloon', +'ballot_box_with_check', 'bamboo', 'banana', 'bangbang', 'bank', 'bar_chart', +'barber', 'baseball', 'basketball', 'bath', 'bathtub', 'battery', 'bear', 'bee', +'beer', 'beers', 'beetle', 'beginner', 'bell', 'bento', 'bicyclist', 'bike', +'bikini', 'bird', 'birthday', 'black_circle', 'black_joker', 'black_nib', +'black_square', 'black_square_button', 'blossom', 'blowfish', 'blue_book', +'blue_car', 'blue_heart', 'blush', 'boar', 'boat', 'bomb', 'book', 'bookmark', +'bookmark_tabs', 'books', 'boom', 'boot', 'bouquet', 'bow', 'bowling', 'bowtie', +'boy', 'bread', 'bride_with_veil', 'bridge_at_night', 'briefcase', +'broken_heart', 'bug', 'bulb', 'bullettrain_front', 'bullettrain_side', 'bus', +'busstop', 'bust_in_silhouette', 'busts_in_silhouette', 'cactus', 'cake', +'calendar', 'calling', 'camel', 'camera', 'cancer', 'candy', 'capital_abcd', +'capricorn', 'car', 'card_index', 'carousel_horse', 'cat', 'cat2', 'cd', +'chart', 'chart_with_downwards_trend', 'chart_with_upwards_trend', +'checkered_flag', 'cherries', 'cherry_blossom', 'chestnut', 'chicken', +'children_crossing', 'chocolate_bar', 'christmas_tree', 'church', 'cinema', +'circus_tent', 'city_sunrise', 'city_sunset', 'cl', 'clap', 'clapper', +'clipboard', 'clock1', 'clock10', 'clock1030', 'clock11', 'clock1130', +'clock12', 'clock1230', 'clock130', 'clock2', 'clock230', 'clock3', 'clock330', +'clock4', 'clock430', 'clock5', 'clock530', 'clock6', 'clock630', 'clock7', +'clock730', 'clock8', 'clock830', 'clock9', 'clock930', 'closed_book', +'closed_lock_with_key', 'closed_umbrella', 'cloud', 'clubs', 'cn', 'cocktail', +'coffee', 'cold_sweat', 'collision', 'computer', 'confetti_ball', 'confounded', +'confused', 'congratulations', 'construction', 'construction_worker', +'convenience_store', 'cookie', 'cool', 'cop', 'copyright', 'corn', 'couple', +'couple_with_heart', 'couplekiss', 'cow', 'cow2', 'credit_card', 'crocodile', +'crossed_flags', 'crown', 'cry', 'crying_cat_face', 'crystal_ball', 'cupid', +'curly_loop', 'currency_exchange', 'curry', 'custard', 'customs', 'cyclone', +'dancer', 'dancers', 'dango', 'dart', 'dash', 'date', 'de', 'deciduous_tree', +'department_store', 'diamond_shape_with_a_dot_inside', 'diamonds', +'disappointed', 'disappointed_relieved', 'dizzy', 'dizzy_face', 'do_not_litter', +'dog', 'dog2', 'dollar', 'dolls', 'dolphin', 'donut', 'door', 'doughnut', +'dragon', 'dragon_face', 'dress', 'dromedary_camel', 'droplet', 'dvd', 'e-mail', +'ear', 'ear_of_rice', 'earth_africa', 'earth_americas', 'earth_asia', 'egg', +'eggplant', 'eight', 'eight_pointed_black_star', 'eight_spoked_asterisk', +'electric_plug', 'elephant', 'email', 'end', 'envelope', 'es', 'euro', +'european_castle', 'european_post_office', 'evergreen_tree', 'exclamation', +'expressionless', 'eyeglasses', 'eyes', 'facepunch', 'factory', 'fallen_leaf', +'family', 'fast_forward', 'fax', 'fearful', 'feelsgood', 'feet', 'ferris_wheel', +'file_folder', 'finnadie', 'fire', 'fire_engine', 'fireworks', +'first_quarter_moon', 'first_quarter_moon_with_face', 'fish', 'fish_cake', +'fishing_pole_and_fish', 'fist', 'five', 'flags', 'flashlight', 'floppy_disk', +'flower_playing_cards', 'flushed', 'foggy', 'football', 'fork_and_knife', +'fountain', 'four', 'four_leaf_clover', 'fr', 'free', 'fried_shrimp', 'fries', +'frog', 'frowning', 'fu', 'fuelpump', 'full_moon', 'full_moon_with_face', +'game_die', 'gb', 'gem', 'gemini', 'ghost', 'gift', 'gift_heart', 'girl', +'globe_with_meridians', 'goat', 'goberserk', 'godmode', 'golf', 'grapes', +'green_apple', 'green_book', 'green_heart', 'grey_exclamation', 'grey_question', +'grimacing', 'grin', 'grinning', 'guardsman', 'guitar', 'gun', 'haircut', +'hamburger', 'hammer', 'hamster', 'hand', 'handbag', 'hankey', 'hash', +'hatched_chick', 'hatching_chick', 'headphones', 'hear_no_evil', 'heart', +'heart_decoration', 'heart_eyes', 'heart_eyes_cat', 'heartbeat', 'heartpulse', +'hearts', 'heavy_check_mark', 'heavy_division_sign', 'heavy_dollar_sign', +'heavy_exclamation_mark', 'heavy_minus_sign', 'heavy_multiplication_x', +'heavy_plus_sign', 'helicopter', 'herb', 'hibiscus', 'high_brightness', +'high_heel', 'hocho', 'honey_pot', 'honeybee', 'horse', 'horse_racing', +'hospital', 'hotel', 'hotsprings', 'hourglass', 'hourglass_flowing_sand', +'house', 'house_with_garden', 'hurtrealbad', 'hushed', 'ice_cream', 'icecream', +'id', 'ideograph_advantage', 'imp', 'inbox_tray', 'incoming_envelope', +'information_desk_person', 'information_source', 'innocent', 'interrobang', +'iphone', 'it', 'izakaya_lantern', 'jack_o_lantern', 'japan', 'japanese_castle', +'japanese_goblin', 'japanese_ogre', 'jeans', 'joy', 'joy_cat', 'jp', 'key', +'keycap_ten', 'kimono', 'kiss', 'kissing', 'kissing_cat', 'kissing_closed_eyes', +'kissing_face', 'kissing_heart', 'kissing_smiling_eyes', 'koala', 'koko', 'kr', +'large_blue_circle', 'large_blue_diamond', 'large_orange_diamond', +'last_quarter_moon', 'last_quarter_moon_with_face', 'laughing', 'leaves', +'ledger', 'left_luggage', 'left_right_arrow', 'leftwards_arrow_with_hook', +'lemon', 'leo', 'leopard', 'libra', 'light_rail', 'link', 'lips', 'lipstick', +'lock', 'lock_with_ink_pen', 'lollipop', 'loop', 'loudspeaker', 'love_hotel', +'love_letter', 'low_brightness', 'm', 'mag', 'mag_right', 'mahjong', 'mailbox', +'mailbox_closed', 'mailbox_with_mail', 'mailbox_with_no_mail', 'man', +'man_with_gua_pi_mao', 'man_with_turban', 'mans_shoe', 'maple_leaf', 'mask', +'massage', 'meat_on_bone', 'mega', 'melon', 'memo', 'mens', 'metal', 'metro', +'microphone', 'microscope', 'milky_way', 'minibus', 'minidisc', +'mobile_phone_off', 'money_with_wings', 'moneybag', 'monkey', 'monkey_face', +'monorail', 'moon', 'mortar_board', 'mount_fuji', 'mountain_bicyclist', +'mountain_cableway', 'mountain_railway', 'mouse', 'mouse2', 'movie_camera', +'moyai', 'muscle', 'mushroom', 'musical_keyboard', 'musical_note', +'musical_score', 'mute', 'nail_care', 'name_badge', 'neckbeard', 'necktie', +'negative_squared_cross_mark', 'neutral_face', 'new', 'new_moon', +'new_moon_with_face', 'newspaper', 'ng', 'nine', 'no_bell', 'no_bicycles', +'no_entry', 'no_entry_sign', 'no_good', 'no_mobile_phones', 'no_mouth', +'no_pedestrians', 'no_smoking', 'non-potable_water', 'nose', 'notebook', +'notebook_with_decorative_cover', 'notes', 'nut_and_bolt', 'o', 'o2', 'ocean', +'octocat', 'octopus', 'oden', 'office', 'ok', 'ok_hand', 'ok_woman', +'older_man', 'older_woman', 'on', 'oncoming_automobile', 'oncoming_bus', +'oncoming_police_car', 'oncoming_taxi', 'one', 'open_file_folder', 'open_hands', +'open_mouth', 'ophiuchus', 'orange_book', 'outbox_tray', 'ox', 'page_facing_up', +'page_with_curl', 'pager', 'palm_tree', 'panda_face', 'paperclip', 'parking', +'part_alternation_mark', 'partly_sunny', 'passport_control', 'paw_prints', +'peach', 'pear', 'pencil', 'pencil2', 'penguin', 'pensive', 'performing_arts', +'persevere', 'person_frowning', 'person_with_blond_hair', +'person_with_pouting_face', 'phone', 'pig', 'pig2', 'pig_nose', 'pill', +'pineapple', 'pisces', 'pizza', 'plus1', 'point_down', 'point_left', +'point_right', 'point_up', 'point_up_2', 'police_car', 'poodle', 'poop', +'post_office', 'postal_horn', 'postbox', 'potable_water', 'pouch', +'poultry_leg', 'pound', 'pouting_cat', 'pray', 'princess', 'punch', +'purple_heart', 'purse', 'pushpin', 'put_litter_in_its_place', 'question', +'rabbit', 'rabbit2', 'racehorse', 'radio', 'radio_button', 'rage', 'rage1', +'rage2', 'rage3', 'rage4', 'railway_car', 'rainbow', 'raised_hand', +'raised_hands', 'raising_hand', 'ram', 'ramen', 'rat', 'recycle', 'red_car', +'red_circle', 'registered', 'relaxed', 'relieved', 'repeat', 'repeat_one', +'restroom', 'revolving_hearts', 'rewind', 'ribbon', 'rice', 'rice_ball', +'rice_cracker', 'rice_scene', 'ring', 'rocket', 'roller_coaster', 'rooster', +'rose', 'rotating_light', 'round_pushpin', 'rowboat', 'ru', 'rugby_football', +'runner', 'running', 'running_shirt_with_sash', 'sa', 'sagittarius', 'sailboat', +'sake', 'sandal', 'santa', 'satellite', 'satisfied', 'saxophone', 'school', +'school_satchel', 'scissors', 'scorpius', 'scream', 'scream_cat', 'scroll', +'seat', 'secret', 'see_no_evil', 'seedling', 'seven', 'shaved_ice', 'sheep', +'shell', 'ship', 'shipit', 'shirt', 'shit', 'shoe', 'shower', 'signal_strength', +'six', 'six_pointed_star', 'ski', 'skull', 'sleeping', 'sleepy', 'slot_machine', +'small_blue_diamond', 'small_orange_diamond', 'small_red_triangle', +'small_red_triangle_down', 'smile', 'smile_cat', 'smiley', 'smiley_cat', +'smiling_imp', 'smirk', 'smirk_cat', 'smoking', 'snail', 'snake', 'snowboarder', +'snowflake', 'snowman', 'sob', 'soccer', 'soon', 'sos', 'sound', +'space_invader', 'spades', 'spaghetti', 'sparkler', 'sparkles', +'sparkling_heart', 'speak_no_evil', 'speaker', 'speech_balloon', 'speedboat', +'squirrel', 'star', 'star2', 'stars', 'station', 'statue_of_liberty', +'steam_locomotive', 'stew', 'straight_ruler', 'strawberry', 'stuck_out_tongue', +'stuck_out_tongue_closed_eyes', 'stuck_out_tongue_winking_eye', 'sun_with_face', +'sunflower', 'sunglasses', 'sunny', 'sunrise', 'sunrise_over_mountains', +'surfer', 'sushi', 'suspect', 'suspension_railway', 'sweat', 'sweat_drops', +'sweat_smile', 'sweet_potato', 'swimmer', 'symbols', 'syringe', 'tada', +'tanabata_tree', 'tangerine', 'taurus', 'taxi', 'tea', 'telephone', +'telephone_receiver', 'telescope', 'tennis', 'tent', 'thought_balloon', 'three', +'thumbsdown', 'thumbsup', 'ticket', 'tiger', 'tiger2', 'tired_face', 'tm', +'toilet', 'tokyo_tower', 'tomato', 'tongue', 'top', 'tophat', 'tractor', +'traffic_light', 'train', 'train2', 'tram', 'triangular_flag_on_post', +'triangular_ruler', 'trident', 'triumph', 'trolleybus', 'trollface', 'trophy', +'tropical_drink', 'tropical_fish', 'truck', 'trumpet', 'tshirt', 'tulip', +'turtle', 'tv', 'twisted_rightwards_arrows', 'two', 'two_hearts', +'two_men_holding_hands', 'two_women_holding_hands', 'u5272', 'u5408', 'u55b6', +'u6307', 'u6708', 'u6709', 'u6e80', 'u7121', 'u7533', 'u7981', 'u7a7a', 'uk', +'umbrella', 'unamused', 'underage', 'unlock', 'up', 'us', 'v', +'vertical_traffic_light', 'vhs', 'vibration_mode', 'video_camera', 'video_game', +'violin', 'virgo', 'volcano', 'vs', 'walking', 'waning_crescent_moon', +'waning_gibbous_moon', 'warning', 'watch', 'water_buffalo', 'watermelon', +'wave', 'wavy_dash', 'waxing_crescent_moon', 'waxing_gibbous_moon', 'wc', +'weary', 'wedding', 'whale', 'whale2', 'wheelchair', 'white_check_mark', +'white_circle', 'white_flower', 'white_square', 'white_square_button', +'wind_chime', 'wine_glass', 'wink', 'wolf', 'woman', 'womans_clothes', +'womans_hat', 'womens', 'worried', 'wrench', 'x', 'yellow_heart', 'yen', 'yum', +'zap', 'zero', 'zzz']; diff --git a/client/lib/filter.js b/client/lib/filter.js new file mode 100644 index 00000000000..507a2bb7013 --- /dev/null +++ b/client/lib/filter.js @@ -0,0 +1,133 @@ +// Filtered view manager +// We define local filter objects for each different type of field (SetFilter, +// RangeFilter, dateFilter, etc.). We then define a global `Filter` object whose +// goal is to filter complete documents by using the local filters for each +// fields. + +// Use a "set" filter for a field that is a set of documents uniquely +// identified. For instance `{ labels: ['labelA', 'labelC', 'labelD'] }`. +var SetFilter = function() { + this._dep = new Tracker.Dependency(); + this._selectedElements = []; +}; + +_.extend(SetFilter.prototype, { + isSelected: function(val) { + this._dep.depend(); + return this._selectedElements.indexOf(val) > -1; + }, + + add: function(val) { + if (this.indexOfVal(val) === -1) { + this._selectedElements.push(val); + this._dep.changed(); + } + }, + + remove: function(val) { + var indexOfVal = this._indexOfVal(val); + if (this.indexOfVal(val) !== -1) { + this._selectedElements.splice(indexOfVal, 1); + this._dep.changed(); + } + }, + + toogle: function(val) { + var indexOfVal = this._indexOfVal(val); + if (indexOfVal === -1) { + this._selectedElements.push(val); + } else { + this._selectedElements.splice(indexOfVal, 1); + } + + this._dep.changed(); + }, + + reset: function() { + this._selectedElements = []; + this._dep.changed(); + }, + + _indexOfVal: function(val) { + return this._selectedElements.indexOf(val); + }, + + _isActive: function() { + this._dep.depend(); + return this._selectedElements.length !== 0; + }, + + _getMongoSelector: function() { + this._dep.depend(); + return { $in: this._selectedElements }; + } +}); + +// The global Filter object. +// XXX It would be possible to re-write this object more elegantly, and removing +// the need to provide a list of `_fields`. We also should move methods into the +// object prototype. +Filter = { + // XXX I would like to rename this field into `labels` to be consistent with + // the rest of the schema, but we need to set some migrations architecture + // before changing the schema. + labelIds: new SetFilter(), + members: new SetFilter(), + + _fields: ['labelIds', 'members'], + + // We don't filter cards that have been added after the last filter change. To + // implement this we keep the id of these cards in this `_exceptions` fields + // and use a `$or` condition in the mongo selector we return. + _exceptions: [], + _exceptionsDep: new Tracker.Dependency(), + + isActive: function() { + var self = this; + return _.any(self._fields, function(fieldName) { + return self[fieldName]._isActive(); + }); + }, + + getMongoSelector: function() { + var self = this; + + if (! self.isActive()) + return {}; + + var filterSelector = {}; + _.forEach(self._fields, function(fieldName) { + var filter = self[fieldName]; + if (filter._isActive()) + filterSelector[fieldName] = filter._getMongoSelector(); + }); + + var exceptionsSelector = {_id: {$in: this._exceptions}}; + this._exceptionsDep.depend(); + + return {$or: [filterSelector, exceptionsSelector]}; + }, + + reset: function() { + var self = this; + _.forEach(self._fields, function(fieldName) { + var filter = self[fieldName]; + filter.reset(); + }); + self.resetExceptions(); + }, + + addException: function(_id) { + if (this.isActive()) { + this._exceptions.push(_id); + this._exceptionsDep.changed(); + } + }, + + resetExceptions: function() { + this._exceptions = []; + this._exceptionsDep.changed(); + } +}; + +Blaze.registerHelper('Filter', Filter); diff --git a/client/lib/i18n.js b/client/lib/i18n.js new file mode 100644 index 00000000000..7d7e3ebb489 --- /dev/null +++ b/client/lib/i18n.js @@ -0,0 +1,22 @@ +// We save the user language preference in the user profile, and use that to set +// the language reactively. If the user is not connected we use the language +// information provided by the browser, and default to english. + +Tracker.autorun(function() { + var language; + var currentUser = Meteor.user(); + if (currentUser) { + language = currentUser.profile && currentUser.profile.language; + } else { + language = navigator.language || navigator.userLanguage; + } + + if (language) { + + TAPi18n.setLanguage(language); + + // XXX + var shortLanguage = language.split('-')[0]; + T9n.setLanguage(shortLanguage); + } +}); diff --git a/client/lib/keyboard.js b/client/lib/keyboard.js new file mode 100644 index 00000000000..c1267938cd8 --- /dev/null +++ b/client/lib/keyboard.js @@ -0,0 +1,55 @@ +// XXX Pressing `?` should display a list of all shortcuts available. +// +// XXX There is no reason to define these shortcuts globally, they should be +// attached to a template (most of them will go in the `board` template). + +// Pressing `Escape` should close the last opened “element” and only the last +// one -- curently we handle popups and the card detailed view of the sidebar. +Mousetrap.bind('esc', function() { + if (currentlyOpenedForm.get() !== null) { + currentlyOpenedForm.get().close(); + + } else if (Popup.isOpen()) { + Popup.back(); + + // XXX We should have a higher level API + } else if (Session.get('currentCard')) { + Utils.goBoardId(Session.get('currentBoard')); + } +}); + +Mousetrap.bind('w', function() { + if (! Session.get('currentCard')) { + Sidebar.toogle(); + } else { + Utils.goBoardId(Session.get('currentBoard')); + Sidebar.hide(); + } +}); + +Mousetrap.bind('q', function() { + var currentBoardId = Session.get('currentBoard'); + var currentUserId = Meteor.userId(); + if (currentBoardId && currentUserId) { + Filter.members.toogle(currentUserId); + } +}); + +Mousetrap.bind('x', function() { + if (Filter.isActive()) { + Filter.reset(); + } +}); + +Mousetrap.bind(['down', 'up'], function(evt, key) { + if (! Session.get('currentCard')) { + return; + } + + var nextFunc = (key === 'down' ? 'next' : 'prev'); + var nextCard = $('.js-minicard.is-selected')[nextFunc]('.js-minicard').get(0); + if (nextCard) { + var nextCardId = Blaze.getData(nextCard)._id; + Utils.goCardId(nextCardId); + } +}); diff --git a/client/lib/mixins.js b/client/lib/mixins.js new file mode 100644 index 00000000000..8d16be5393a --- /dev/null +++ b/client/lib/mixins.js @@ -0,0 +1 @@ +Mixins = {}; diff --git a/client/lib/popup.js b/client/lib/popup.js new file mode 100644 index 00000000000..dd2a43b0141 --- /dev/null +++ b/client/lib/popup.js @@ -0,0 +1,200 @@ +// A simple tracker dependency that we invalidate every time the window is +// resized. This is used to reactively re-calculate the popup position in case +// of a window resize. +var windowResizeDep = new Tracker.Dependency(); +$(window).on('resize', function() { windowResizeDep.changed(); }); + +Popup = { + /// This function returns a callback that can be used in an event map: + /// + /// Template.tplName.events({ + /// 'click .elementClass': Popup.open("popupName") + /// }); + /// + /// The popup inherit the data context of its parent. + open: function(name) { + var self = this; + var popupName = name + 'Popup'; + + return function(evt) { + // If a popup is already openened, clicking again on the opener element + // should close it -- and interupt the current `open` function. + if (self.isOpen() && + self._getTopStack().openerElement === evt.currentTarget) { + return self.close(); + } + + // We determine the `openerElement` (the DOM element that is being clicked + // and the one we take in reference to position the popup) from the event + // if the popup has no parent, or from the parent `openerElement` if it + // has one. This allows us to position a sub-popup exactly at the same + // position than its parent. + var openerElement; + if (self._hasPopupParent()) { + openerElement = self._getTopStack().openerElement; + } else { + self._stack = []; + openerElement = evt.currentTarget; + } + + // We modify the event to prevent the popup being closed when the event + // bubble up to the document element. + evt.originalEvent.clickInPopup = true; + evt.preventDefault(); + + // We push our popup data to the stack. The top of the stack is always + // used as the data source for our current popup. + self._stack.push({ + __isPopup: true, + popupName: popupName, + hasPopupParent: self._hasPopupParent(), + title: self._getTitle(popupName), + openerElement: openerElement, + offset: self._getOffset(openerElement), + dataContext: this.currentData && this.currentData() || this + }); + + // If there are no popup currently opened we use the Blaze API to render + // one into the DOM. We use a reactive function as the data parameter that + // just return the top element on the stack and depends on our internal + // dependency that is being invalidated every time the top element of the + // stack has changed and we want to update the popup. + // + // Otherwise if there is already a popup open we just need to invalidate + // our internal dependency, and since we just changed the top element of + // our internal stack, the popup will be updated with the new data. + if (! self.isOpen()) { + self.current = Blaze.renderWithData(self.template, function() { + self._dep.depend(); + return self._stack[self._stack.length - 1]; + }, document.body); + + } else { + self._dep.changed(); + } + }; + }, + + /// This function returns a callback that can be used in an event map: + /// + /// Template.tplName.events({ + /// 'click .elementClass': Popup.afterConfirm("popupName", function() { + /// // What to do after the user has confirmed the action + /// }) + /// }); + afterConfirm: function(name, action) { + var self = this; + + return function(evt, tpl) { + var context = this; + context.__afterConfirmAction = action; + self.open(name).call(context, evt, tpl); + }; + }, + + /// The public reactive state of the popup. + isOpen: function() { + this._dep.changed(); + return !! this.current; + }, + + /// In case the popup was opened from a parent popup we can get back to it + /// with this `Popup.back()` function. You can go back several steps at once + /// by providing a number to this function, e.g. `Popup.back(2)`. In this case + /// intermediate popup won't even be rendered on the DOM. If the number of + /// steps back is greater than the popup stack size, the popup will be closed. + back: function(n) { + n = n || 1; + var self = this; + if (self._stack.length > n) { + _.times(n, function() { self._stack.pop(); }); + self._dep.changed(); + } else { + self.close(); + } + }, + + /// Close the current opened popup. + close: function() { + if (this.isOpen()) { + Blaze.remove(this.current); + this.current = null; + this._stack = []; + } + }, + + // The template we use for every popup + template: Template.popup, + + // We only want to display one popup at a time and we keep the view object in + // this `Popup._current` variable. If there is no popup currently opened the + // value is `null`. + _current: null, + + // It's possible to open a sub-popup B from a popup A. In that case we keep + // the data of popup A so we can return back to it. Every time we open a new + // popup the stack grows, every time we go back the stack decrease, and if we + // close the popup the stack is reseted to the empty stack []. + _stack: [], + + // We invalidate this internal dependency every time the top of the stack has + // changed and we want to render a popup with the new top-stack data. + _dep: new Tracker.Dependency(), + + // An utility fonction that returns the top element of the internal stack + _getTopStack: function() { + return this._stack[this._stack.length - 1]; + }, + + // We use the blaze API to determine if the current popup has been opened from + // a parent popup. The number we give to the `Template.parentData` has been + // determined experimentally and is susceptible to change if you modify the + // `Popup.template` + _hasPopupParent: function() { + var tryParentData = Template.parentData(3); + return !! (tryParentData && tryParentData.__isPopup); + }, + + // We automatically calculate the popup offset from the reference element + // position and dimensions. We also reactively use the window dimensions to + // ensure that the popup is always visible on the screen. + _getOffset: function(element) { + var $element = $(element); + return function() { + windowResizeDep.depend(); + var offset = $element.offset(); + var popupWidth = 300 + 15; + return { + left: Math.min(offset.left, $(window).width() - popupWidth), + top: offset.top + $element.outerHeight() + }; + }; + }, + + // We get the title from the translation files. Instead of returning the + // result, we return a function that compute the result and since `TAPi18n.__` + // is a reactive data source, the title will be changed reactively. + _getTitle: function(popupName) { + return function() { + var translationKey = popupName + '-title'; + + // XXX There is no public API to check if there is an available + // translation for a given key. So we try to translate the key and if the + // translation output equals the key input we deduce that no translation + // was available and returns `false`. There is a (small) risk a false + // positives. + var title = TAPi18n.__(translationKey); + return title !== translationKey ? title : false; + }; + } +}; + +// We automatically close a potential opened popup on any left click on the +// document. To avoid closing it unexpectedly we modify the bubbled event in +// case the click event happen in the popup or in a button that open a popup. +$(document).on('click', function(evt) { + if (evt.which === 1 && ! (evt.originalEvent && + evt.originalEvent.clickInPopup)) { + Popup.close(); + } +}); diff --git a/client/lib/utils.js b/client/lib/utils.js new file mode 100644 index 00000000000..9e92e9998d6 --- /dev/null +++ b/client/lib/utils.js @@ -0,0 +1,96 @@ +Utils = { + error: function(err) { + Session.set('error', (err && err.message || false)); + }, + + // scroll + Scroll: function(selector) { + var $el = $(selector); + return { + top: function(px, add) { + var t = $el.scrollTop(); + $el.animate({ scrollTop: (add ? (t + px) : px) }); + }, + left: function(px, add) { + var l = $el.scrollLeft(); + $el.animate({ scrollLeft: (add ? (l + px) : px) }); + } + }; + }, + + Warning: { + get: function() { + return Session.get('warning'); + }, + open: function(desc) { + Session.set('warning', { desc: desc }); + }, + close: function() { + Session.set('warning', false); + } + }, + + // XXX We should remove these two methods + goBoardId: function(_id) { + var board = Boards.findOne(_id); + return board && Router.go('Board', { + _id: board._id, + slug: board.slug + }); + }, + + goCardId: function(_id) { + var card = Cards.findOne(_id); + var board = Boards.findOne(card.boardId); + return board && Router.go('Card', { + cardId: card._id, + boardId: board._id, + slug: board.slug + }); + }, + + liveEvent: function(events, callback) { + $(document).on(events, function() { + callback($(this)); + }); + }, + + capitalize: function(string) { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + + getLabelIndex: function(boardId, labelId) { + var board = Boards.findOne(boardId); + var labels = {}; + _.each(board.labels, function(a, b) { + labels[a._id] = b; + }); + return { + index: labels[labelId], + key: function(key) { + return 'labels.' + labels[labelId] + '.' + key; + } + }; + }, + + // Determine the new sort index + getSortIndex: function(prevCardDomElement, nextCardDomElement) { + // If we drop the card to an empty column + if (! prevCardDomElement && ! nextCardDomElement) { + return 0; + // If we drop the card in the first position + } else if (! prevCardDomElement) { + return Blaze.getData(nextCardDomElement).sort - 1; + // If we drop the card in the last position + } else if (! nextCardDomElement) { + return Blaze.getData(prevCardDomElement).sort + 1; + } + // In the general case take the average of the previous and next element + // sort indexes. + else { + var prevSortIndex = Blaze.getData(prevCardDomElement).sort; + var nextSortIndex = Blaze.getData(nextCardDomElement).sort; + return (prevSortIndex + nextSortIndex) / 2; + } + } +}; diff --git a/client/styles/cheat.styl b/client/styles/cheat.styl new file mode 100644 index 00000000000..9d881b44cd4 --- /dev/null +++ b/client/styles/cheat.styl @@ -0,0 +1,79 @@ +@import 'nib' + +.clear + clear: both + +.clearfix + clearfix() + +.hide + display: none + +.show + display: block + +.bold + font-weight: 700 + +.center + text-align: center + +.left + float: left + +.right + float: right + +.first + margin-left: 0 + padding-left: 0 + +.last + margin-right: 0 + padding-right: 0 + +.top + margin-top: 0 + padding-top: 0 + +.bottom + margin-bottom: 0 + padding-bottom: 0 + +.relative + position: relative + +.block + display: block + +.inline + display: inline + +.inline-block + display: inline-block + +.pointer + cursor: pointer + +.ellip + overflow: hidden + text-overflow: ellipsis + white-space: nowrap + +.underline + text-decoration: underline + +.lowercase + text-transform: lowercase + +.invisible + visibility: hidden + +.wrapword + word-wrap: break-word + +.grab + cursor: grab + +.grabbing + cursor: grabbing diff --git a/client/styles/fancy-scrollbar.styl b/client/styles/fancy-scrollbar.styl new file mode 100644 index 00000000000..c7a30018df0 --- /dev/null +++ b/client/styles/fancy-scrollbar.styl @@ -0,0 +1,45 @@ +.fancy-scrollbar + -webkit-overflow-scrolling: touch + + .fancy-scrollbar::-webkit-scrollbar + height: 9px + width: 9px + + &::-webkit-scrollbar-button:start:decrement, + &::-webkit-scrollbar-button:end:increment + background: transparent + display: none + + &::-webkit-scrollbar-track-piece + background: #dbdbdb + + &:vertical:start + border-top-left-radius: 5px + border-top-right-radius: 5px + border-bottom-right-radius: 0 + border-bottom-left-radius: 0 + + &:vertical:end + border-top-left-radius: 0 + border-top-right-radius: 0 + border-bottom-right-radius: 5px + border-bottom-left-radius: 5px + + &:horizontal:start + border-top-left-radius: 5px + border-top-right-radius: 0 + border-bottom-right-radius: 0 + border-bottom-left-radius: 5px + + &:horizontal:end + border-top-left-radius: 0 + border-top-right-radius: 5px + border-bottom-right-radius: 5px + border-bottom-left-radius: 0 + + &::-webkit-scrollbar-thumb:vertical, + &::-webkit-scrollbar-thumb:horizontal + background: #c2c2c2 + border-radius: 5px + display: block + height: 50px diff --git a/client/styles/main.styl b/client/styles/main.styl new file mode 100644 index 00000000000..0f12e35e2d3 --- /dev/null +++ b/client/styles/main.styl @@ -0,0 +1,814 @@ +@import 'nib' + +html, body, input, select, textarea, button + font: 14px "Helvetica Neue", Arial, Helvetica, sans-serif + line-height: 18px + color: #4d4d4d + +html + font-size: 100% + -webkit-text-size-adjust: 100% + +p + margin: 0 + +ol, +ul + list-style: none + margin: 0 + padding: 0 + +blockquote, q + quotes: none + + &:before, + &:after + content: none + +ins + text-decoration: none + +del + text-decoration: line-through + +table + border-collapse: collapse + border-spacing: 0 + width: 100% + +hr + height: 1px + border: 0 + border: none + width: 100% + background: #dbdbdb + color: #dbdbdb + margin: 15px 0 + padding: 0 + +article, +aside, +figure, +footer, +header, +hgroup, +nav, +section + display: block + +caption, th, td + text-align: left + font-weight: 400 + +a img + border: none + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +nav, +section, +summary + display: block + +html + max-height: 100% + +body + background: darken(white, 10%) + margin: 0 + position: relative + z-index: 0 + overflow-y: auto + +#surface + display: flex + flex-direction: column + min-height: 100vh + +#content + position: relative + flex: 1 + +div::selection + background: transparent + +h1 + font-size: 22px + line-height: 1.2em + margin: 0 0 10px + +h2 + font-size: 18px + line-height: 1.2em + margin: 0 0 9px + +h3, h4, h5, h6 + font-size: 16px + line-height: 1.25em + margin: 0 0 6px + +.quiet, .quiet a + color: #8c8c8c + +.error, .error a + color: #eb3800 + +.warning + background: #f0ecdb + border-radius: 3px + color: #aa8f09 + padding: 6px 8px + + a + color: #aa8f09 + +a + color: #444 + cursor: pointer + text-decoration: none + + &:hover + color: #111 + + &.disabled, + &.disabled:hover + color: #8c8c8c + cursor: default + text-decoration: none + +table, p + margin-bottom: 8px + +pre + margin: 15px 0 + white-space: pre + max-height: 516px + +pre, +code, +tt + font-family: bitstream vera sans mono, andale mono, lucida console, monospace + line-height: 1.25em + +blockquote + margin: 8px 0 8px 8px + border-left: 1px solid #ccc + color: #666 + padding: 0 0 0 8px + +table, td, th + vertical-align: top + border-top: 1px solid #ccc + border-left: 1px solid #ccc + +td, th + padding: 5px + border-right: 1px solid #ccc + border-bottom: 1px solid #ccc + +th + font-weight: 700 + +thead + background: #fff + background: linear-gradient(to bottom, #fff 0, #f0f0f0 100%) + +tbody + background-color: #fff + +dl, dt + margin-bottom: 8px + +dd + margin: 0 0 16px 24px + +.emoji + height: 18px + width: 18px + vertical-align: text-bottom + +.edit + display: none + position: relative + +.editable .current + cursor: pointer + +.editable.editing + cursor: auto + +.edits-warning, .edits-error + display: none + clear: both + +.editing .edit + display: block + float: left + padding-bottom: 9px + z-index: 100 + width: 100% + +.editing .edits-warning + display: none!important + +.editing .edit .field, +.editing .edit .field:active + background: rgba(0, 0, 0, .03) + box-shadow: inset 0 1px 6px rgba(0, 0, 0, .1) + border-color: rgba(0, 0, 0, .15) + margin-bottom: 4px + +.edit-heavy .field + font-size: 15px + font-weight: 700 + line-height: 18px + + +.board-backgrounds-list + + .board-background-select + box-sizing: border-box + display: block + float: left + width: 50% + padding-top: 12px + position: relative + z-index: 1 + + &:nth-child(-n + 2) + padding-top: 0 + + &:nth-child(2n) + padding-left: 6px + + &:nth-child(2n+1) + padding-right: 6px + + .background-box + border-radius: 3px + background-size: cover + display: block + height: 74px + position: relative + width: 100% + cursor: pointer + display: flex + align-items: center + justify-content: center + + i.fa-check + font-size: 25px + color: white + +.new-comment + position: relative + margin: 0 0 20px 38px + + .member + opacity: .7 + position: absolute + top: 1px + left: -38px + + .helper + bottom: 0 + display: none + position: absolute + right: 9px + + &.focus + + .member + opacity: 1 + + .helper + display: inline-block + + .new-comment-input + min-height: 108px + color: #4d4d4d + cursor: auto + overflow: hidden + word-wrap: break-word + + .too-long + margin-top: 8px + +.new-comment-input + background-color: #fff + border: 0 + box-shadow: 0 1px 2px rgba(0, 0, 0, .23) + color: #8c8c8c + height: 36px + margin: 4px 4px 6px 0 + padding: 9px 11px + width: 100% + + &:hover, + &:focus + background-color: #fff + box-shadow: 0 1px 3px rgba(0, 0, 0, .33) + border: 0 + cursor: pointer + + &:focus + cursor: auto + +.editing-members + float: right + + .edit-in-progress + display: inline-block + border: 1px solid #ccc + background: #ddd + margin: 0 4px + border-radius: 2px + + .inline-member + cursor: default + + .inline-member-av + width: 18px + height: 18px + margin: 0 0 -4px 0 + + .initials + margin-left: 3px + + .icon + animation: pulsate 1s ease-in alternate + animation-iteration-count: infinite + +@keyframes pulsate + 0% + opacity: 1 + + to + opacity: .4 + +.list-voters.compact .voter + position: relative + min-height: 36px + + .member + left: 0 + position: absolute + top: 0 + + .title + display: block + line-height: 30px + left: 0 + overflow: hidden + padding-left: 38px + position: absolute + text-overflow: ellipsis + top: 0 + white-space: nowrap + width: 230px + +.list-voters .title + display: none + +.card-composer + padding-bottom: 8px + +.cc-controls + margin-top: 1px + + input[type="submit"] + float: left + margin-top: 0 + padding: 5px 18px + + .icon-lg + float: left + + .cc-opt + float: right + +.minicard-placeholder, +.minicard.placeholder + background: silver + border: none + min-height: 18px + + .hook + height: 18px + position: absolute + right: 0 + top: 0 + width: 18px + +.chrome .minicard.ui-sortable-helper, +.safari .minicard.ui-sortable-helper + box-shadow: -2px 2px 6px rgba(0, 0, 0, .2) + +input[type="text"].attachment-add-link-input + float: left + margin: 0 0 8px + width: 80% + +input[type="submit"].attachment-add-link-submit + float: left + margin: 0 0 8px 4px + padding: 6px 12px + width: 18% + +.card-detail-badge + background-color: #dbdbdb + border-radius: 3px + color: #737373 + cursor: default + display: block + height: 20px + line-height: 20px + margin: 0 4px 4px 0 + padding: 5px 10px + text-align: center + text-decoration: none + + &:hover + color: #737373 + + &.badge-state-clickable + text-decoration: underline + +.badge-state-clickable:hover + color: #262626 + cursor: pointer + text-decoration: underline + +.card-detail-badge-aging:first-letter + text-transform: uppercase + +.badge + color: #8c8c8c + float: left + height: 18px + margin: 0 3px 3px 0 + padding: 0 4px 0 0 + position: relative + text-decoration: none + +.badge-icon + float: left + +.badge-text + float: left + font-size: 12px + +.badge-state-image-only + padding: 0 + + .badge-icon + margin-right: 0 + +.badge-state-clickable + cursor: pointer + + .badge-text + text-decoration: underline + +.badge-state-complete + background-color: #4aba12 + border-radius: 3px + color: #fff + + .badge-icon + color: #fff + +.badge-state-unread-notification + background-color: #990f0f + border-radius: 3px + color: #fff + + .badge-icon + color: #fff + +.badge-state-voted + background-color: #dbdbdb + border-radius: 3px + color: #8c8c8c + + .badge-icon + color: #999 + +.badge-state-due-soon, .badge-state-due-soon:hover + background-color: #e6bf00 + border-radius: 3px + color: #fff + + .badge-icon + color: #fff + +.badge-state-due-now, .badge-state-due-now:hover + background-color: #990f0f + border-radius: 3px + color: #fff + + .badge-icon + color: #fff + +.badge-state-due-past, .badge-state-due-past:hover + background-color: #ad8585 + border-radius: 3px + color: #fff + + .badge-icon + color: #fff + +.checklist-list:empty + display: none + +.checklist + margin-bottom: 16px + +.checklist.placeholder + background: #dcdcdc + border-radius: 3px + +.checklist.ui-sortable-helper + background: rgba(240, 240, 240, .85) + border-radius: 3px + + .checklist-title, + .current, + .window-module-title + cursor: grabbing + + .icon-menu + visibility: hidden + +.checklist-items-list + min-height: 2px + +.checklist-item + clear: both + margin: 0 0 6px + padding: 0 0 4px 38px + position: relative + transform-origin: left bottom + transition-property: transform, opacity, height, padding, margin + transition-duration: .14s + transition-timing-function: ease-in + + &.placeholder + background: #dcdcdc + border-radius: 3px + margin: -5px -5px 5px 5px + padding: 5px 0 + + &.ui-sortable-helper + background: rgba(240, 240, 240, .85) + border-radius: 3px + margin: -3px -3px -3px 7px + padding: 3px 3px 3px 33px + + .checklist-item-checkbox + top: 2px + left: 2px + +.hide-completed-items .checklist-item-fade-out + height: 0 + margin: 0 + opacity: 0 + padding: 0 + transform: rotate(-5deg) translateX(-10px) translateY(-10px) + +.checklist-item-checkbox + background: #fff + border-radius: 3px + box-shadow: 0 2px 3px rgba(0, 0, 0, .1) + border: 1px solid #ccc + border-bottom-color: #b3b3b3 + font-weight: 700 + position: absolute + left: 6px + line-height: 18px + overflow: hidden + text-align: center + text-indent: 100% + top: -2px + height: 18px + width: 18px + white-space: nowrap + + &.enabled:hover + background-color: #f0f0f0 + border-color: #ccc + box-shadow: 0 1px 2px rgba(0, 0, 0, .1) + color: #8c8c8c + cursor: pointer + text-indent: 0 + + &.enabled:active + background-color: #e3e3e3 + border-color: #ccc + box-shadow: inset 0 3px 6px rgba(0, 0, 0, .1) + color: #4d4d4d + text-indent: 0 + +.checklist-item-details-text + min-height: 18px + margin-bottom: 0 + + &.enabled:hover + color: #4d4d4d + cursor: pointer + + &:empty + content: "No name" + color: #8c8c8c + +.checklist-item-state-complete + + .checklist-item-details-text + color: #8c8c8c + font-style: italic + text-decoration: line-through + + img + opacity: .3 + + .checklist-item-checkbox + background-color: #f0f0f0 + border-color: #dbdbdb + border-bottom-color: #ccc + box-shadow: none + text-indent: 0 + + &.enabled:hover + background-color: #e6e6e6 + border-color: #ccc + box-shadow: none + + &.enabled:active + background-color: #dbdbdb + box-shadow: inset 0 3px 6px rgba(0, 0, 0, .1) + +.hide-completed-items .checklist-item-state-complete + display: none + +.checklist-new-item-text, +.checklist-new-item-text:hover + background: transparent + border-color: transparent + box-shadow: none + color: #8c8c8c + cursor: pointer + margin-bottom: 4px + max-height: 32px + overflow: hidden + resize: none + text-decoration: none + + .checklist-new-item.focus & + background: #fff + border-color: #2b7cab + box-shadow: 0 0 3px #2b7cab + color: #4d4d4d + cursor: text + max-height: none + resize: vertical + +.checklist-progress + margin-bottom: 12px + position: relative + +.checklist-progress-percentage + color: #8c8c8c + font-size: 11px + line-height: 10px + position: absolute + left: 0 + top: -1px + text-align: center + width: 38px + +.checklist-progress-bar + background: #dbdbdb + border-radius: 3px + clear: both + height: 8px + margin: 0 0 0 38px + overflow: hidden + position: relative + +.checklist-progress-bar-current + background: #479fd1 + background: linear-gradient(to bottom, #479fd1 0, #2288c3 100%) + bottom: 0 + left: 0 + position: absolute + top: 0 + transition: width .14s ease-in, background .14s ease-in + +.checklist-progress-bar-current-complete + background: #24a828 + +.checklist-completed-text + display: block + margin: 8px 0 0 38px + +.checklist .edit + clear: both + margin-top: -5px + +.explorer .av-btn + background: url(about:blank) + +.atMention + background: #dbdbdb + border-radius: 3px + padding: 1px 4px + margin: -1px 0 + display: inline-block + + &.me + background: #cfdfe8 + +.helper + background-color: #e6e6e6 + border-radius: 3px + color: #8c8c8c + font-size: 13px + line-height: 15px + margin: 4px 0 0 + padding: 6px 8px + width: auto + + a + color: #8c8c8c + + &:hover + color: #666 + +.empty-list, .empty + background: #e6e6e6 + border: 1px dashed #ccc + border-radius: 3px + color: #8c8c8c + display: block + padding: 6px + text-align: center + +.empty-list + border-radius: 6px + padding: 25px 6px + +.search-results-page-contents .empty-list + margin: 12px 0 0 52px + +.window-module .empty-list + margin: 8px 0 0 38px + +.loading + margin: 19px auto + text-align: center + +.big-message + display: block + margin: 75px auto + text-align: center + max-width: 600px + + h1 + font-size: 26px + margin-bottom: 24px + + p + font-size: 18px + line-height: 22px + + &.with-picture + margin-top: 35px + + h1 + margin-top: 20px + + .callout + margin: 20px 0 + +.callout + background: #e3e3e3 + border-radius: 5px + padding: 20px + + ol + text-align: left + list-style-type: decimal + margin-left: 25px + font-size: 16px + + li + margin: 10px 0 + +.gutter + margin-left: 38px diff --git a/client/styles/temp.styl b/client/styles/temp.styl new file mode 100644 index 00000000000..9dab78025df --- /dev/null +++ b/client/styles/temp.styl @@ -0,0 +1,110 @@ +/** + * We should merge these declarations in the appropriate stylus files. + */ + +.dn { + display:none; +} + +.header-btn-btn { + padding-left:23px!important; +} + +.bgnone { + background:none!important; +} + +.tac { + text-align:center; + + h1 { + font-size: 2em; + } +} + +.tdn { + text-decoration:none; +} + +.header-member { + min-width:105px!important; + text-align:center; +} + +.primarys { + font-size:20px; + line-height: 1.44em; + padding: .6em 1.3em!important; + border-radius: 3px!important; + box-shadow: 0 2px 0 #4d4d4d!important; +} + +.layout-twothirds-center { + display: block; + max-width: 585px; + margin: 0 auto; + position: relative; + font-size:20px; + line-height: 100px; +} + +#WindowTitleEdit .single-line, .single-line2 { + overflow: hidden; + word-wrap: break-word; + resize: none; + height: 60px; +} + +.single-line2 { + overflow: hidden; + word-wrap: break-word; + resize: none; + height: 108px; +} + +#header-search { + float: left; + margin: 1px 8px 0 0; + position: relative; + z-index: 1; + + label { + display:none; + } + input[type="text"] { + background:rgba(255,255,255,0.5); + border-top-left-radius:3px; + border-top-right-radius:0; + border-bottom-right-radius:0; + border-bottom-left-radius:3px; + border:none; + float:left; + font-size:13px; + height:29px; + min-height:29px; + line-height:19px; + width:160px; + margin:0; + + &:hover{ + background:rgba(255,255,255,0.7); + } + + &:focus{ + background:#e8ebee; + -webkit-box-shadow:none; + box-shadow:none + } + } + + .header-btn{ + border-top-left-radius:0; + border-top-right-radius:3px; + border-bottom-right-radius:3px; + border-bottom-left-radius:0 + } + + input[type="submit"]{ + display:none + } +} diff --git a/collections/activities.js b/collections/activities.js new file mode 100644 index 00000000000..1e24cf7ce1a --- /dev/null +++ b/collections/activities.js @@ -0,0 +1,51 @@ +// Activities don't need a schema because they are always set from the a trusted +// environment - the server - and there is no risk that a user change the logic +// we use with this collection. Moreover using a schema for this collection +// would be difficult (different activities have different fields) and wouldn't +// bring any direct advantage. +// +// XXX The activities API is not so nice and need some functionalities. For +// instance if a user archive a card, and un-archive it a few seconds later we +// should remove both activities assuming it was an error the user decided to +// revert. +Activities = new Mongo.Collection('activities'); + +Activities.helpers({ + board: function() { + return Boards.findOne(this.boardId); + }, + user: function() { + return Users.findOne(this.userId); + }, + member: function() { + return Users.findOne(this.memberId); + }, + list: function() { + return Lists.findOne(this.listId); + }, + oldList: function() { + return Lists.findOne(this.oldListId); + }, + card: function() { + return Cards.findOne(this.cardId); + }, + comment: function() { + return CardComments.findOne(this.commentId); + }, + attachment: function() { + return Attachments.findOne(this.attachmentId); + } +}); + +Activities.before.insert(function(userId, doc) { + doc.createdAt = new Date(); +}); + +// For efficiency create an index on the date of creation. +if (Meteor.isServer) { + Meteor.startup(function() { + Activities._collection._ensureIndex({ + createdAt: -1 + }); + }); +} diff --git a/collections/attachments.js b/collections/attachments.js new file mode 100644 index 00000000000..c8fe6b18468 --- /dev/null +++ b/collections/attachments.js @@ -0,0 +1,79 @@ +Attachments = new FS.Collection('attachments', { + stores: [ + + // XXX Add a new store for cover thumbnails so we don't load big images in + // the general board view + new FS.Store.GridFS('attachments') + ] +}); + +if (Meteor.isServer) { + Attachments.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + // We authorize the attachment download either: + // - if the board is public, everyone (even unconnected) can download it + // - if the board is private, only board members can download it + // + // XXX We have a bug with the `userId` verification: + // + // https://github.com/CollectionFS/Meteor-CollectionFS/issues/449 + // + download: function(userId, doc) { + var query = { + $or: [ + { 'members.userId': userId }, + { permission: 'public' } + ] + }; + return !! Boards.findOne(doc.boardId, query); + }, + + fetch: ['boardId'] + }); +} + +// XXX Enforce a schema for the Attachments CollectionFS + +Attachments.files.before.insert(function(userId, doc) { + var file = new FS.File(doc); + doc.userId = userId; + + // If the uploaded document is not an image we need to enforce browser + // download instead of execution. This is particularly important for HTML + // files that the browser will just execute if we don't serve them with the + // appropriate `application/octet-stream` MIME header which can lead to user + // data leaks. I imagine other formats (like PDF) can also be attack vectors. + // See https://github.com/libreboard/libreboard/issues/99 + // XXX Should we use `beforeWrite` option of CollectionFS instead of + // collection-hooks? + if (! file.isImage()) { + file.original.type = 'application/octet-stream'; + } +}); + +if (Meteor.isServer) { + Attachments.files.after.insert(function(userId, doc) { + Activities.insert({ + type: 'card', + activityType: 'addAttachment', + attachmentId: doc._id, + boardId: doc.boardId, + cardId: doc.cardId, + userId: userId + }); + }); + + Attachments.files.after.remove(function(userId, doc) { + Activities.remove({ + attachmentId: doc._id + }); + }); +} diff --git a/collections/boards.js b/collections/boards.js new file mode 100644 index 00000000000..e406b10c6c3 --- /dev/null +++ b/collections/boards.js @@ -0,0 +1,251 @@ +Boards = new Mongo.Collection('boards'); + +Boards.attachSchema(new SimpleSchema({ + title: { + type: String + }, + slug: { + type: String + }, + archived: { + type: Boolean + }, + createdAt: { + type: Date, + denyUpdate: true + }, + // XXX Inconsistent field naming + modifiedAt: { + type: Date, + denyInsert: true, + optional: true + }, + // De-normalized number of users that have starred this board + stars: { + type: Number + }, + // De-normalized label system + 'labels.$._id': { + // We don't specify that this field must be unique in the board because that + // will cause performance penalties and is not necessary since this field is + // always set on the server. + // XXX Actually if we create a new label, the `_id` is set on the client + // without being overwritten by the server, could it be a problem? + type: String + }, + 'labels.$.name': { + type: String, + optional: true + }, + 'labels.$.color': { + type: String, + allowedValues: [ + 'green', 'yellow', 'orange', 'red', 'purple', + 'blue', 'sky', 'lime', 'pink', 'black' + ] + }, + // XXX We might want to maintain more informations under the member sub- + // documents like de-normalized meta-data (the date the member joined the + // board, the number of contributions, etc.). + 'members.$.userId': { + type: String + }, + 'members.$.isAdmin': { + type: Boolean + }, + 'members.$.isActive': { + type: Boolean + }, + permission: { + type: String, + allowedValues: ['public', 'private'] + }, + color: { + type: String, + allowedValues: ['nephritis', 'pomegranate', 'belize', + 'wisteria', 'midnight', 'pumpkin'] + } +})); + +if (Meteor.isServer) { + Boards.allow({ + insert: Meteor.userId, + update: allowIsBoardAdmin, + remove: allowIsBoardAdmin, + fetch: ['members'] + }); + + // The number of users that have starred this board is managed by trusted code + // and the user is not allowed to update it + Boards.deny({ + update: function(userId, board, fieldNames) { + return _.contains(fieldNames, 'stars'); + }, + fetch: [] + }); + + // We can't remove a member if it is the last administrator + Boards.deny({ + update: function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return false; + + // We only care in case of a $pull operation, ie remove a member + if (! _.isObject(modifier.$pull && modifier.$pull.members)) + return false; + + // If there is more than one admin, it's ok to remove anyone + var nbAdmins = _.filter(doc.members, function(member) { + return member.isAdmin; + }).length; + if (nbAdmins > 1) + return false; + + // If all the previous conditions where verified, we can't remove + // a user if it's an admin + var removedMemberId = modifier.$pull.members.userId; + return !! _.findWhere(doc.members, { + userId: removedMemberId, + isAdmin: true + }); + }, + fetch: ['members'] + }); +} + +Boards.helpers({ + isPublic: function() { + return this.permission === 'public'; + }, + lists: function() { + return Lists.find({ boardId: this._id, archived: false }, + { sort: { sort: 1 }}); + }, + activities: function() { + return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }}); + }, + absoluteUrl: function() { + return Router.path('Board', { boardId: this._id, slug: this.slug }); + }, + colorClass: function() { + return 'board-color-' + this.color; + } +}); + +Boards.before.insert(function(userId, doc) { + // XXX We need to improve slug management. Only the id should be necessary + // to identify a board in the code. + // XXX If the board title is updated, the slug should also be updated. + // In some cases (Chinese and Japanese for instance) the `getSlug` function + // return an empty string. This is causes bugs in our application so we set + // a default slug in this case. + doc.slug = getSlug(doc.title) || 'board'; + doc.createdAt = new Date(); + doc.archived = false; + doc.members = [{ + userId: userId, + isAdmin: true, + isActive: true + }]; + doc.stars = 0; + doc.color = Boards.simpleSchema()._schema.color.allowedValues[0]; + + // Handle labels + var colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; + var defaultLabelsColors = _.clone(colors).splice(0, 6); + doc.labels = []; + _.each(defaultLabelsColors, function(val) { + doc.labels.push({ + _id: Random.id(6), + name: '', + color: val + }); + }); + + // We randomly chose one of the default background colors for the board + if (Meteor.isClient) { + doc.background = { + type: 'color', + color: Random.choice(Boards.simpleSchema()._schema.color.allowedValues) + }; + } +}); + +Boards.before.update(function(userId, doc, fieldNames, modifier) { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = new Date(); +}); + +if (Meteor.isServer) { + // Let MongoDB ensure that a member is not included twice in the same board + Meteor.startup(function() { + Boards._collection._ensureIndex({ + _id: 1, + 'members.userId': 1 + }, { unique: true }); + }); + + // Genesis: the first activity of the newly created board + Boards.after.insert(function(userId, doc) { + Activities.insert({ + type: 'board', + activityTypeId: doc._id, + activityType: 'createBoard', + boardId: doc._id, + userId: userId + }); + }); + + // If the user remove one label from a board, we cant to remove reference of + // this label in any card of this board. + Boards.after.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'labels') || + ! modifier.$pull || + ! modifier.$pull.labels || + ! modifier.$pull.labels._id) + return; + + var removedLabelId = modifier.$pull.labels._id; + Cards.update( + { boardId: doc._id }, + { + $pull: { + labels: removedLabelId + } + }, + { multi: true } + ); + }); + + // Add a new activity if we add or remove a member to the board + Boards.after.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return; + + var memberId; + + // Say hello to the new member + if (modifier.$push && modifier.$push.members) { + memberId = modifier.$push.members.userId; + Activities.insert({ + type: 'member', + activityType: 'addBoardMember', + boardId: doc._id, + userId: userId, + memberId: memberId + }); + } + + // Say goodbye to the former member + if (modifier.$pull && modifier.$pull.members) { + memberId = modifier.$pull.members.userId; + Activities.insert({ + type: 'member', + activityType: 'removeBoardMember', + boardId: doc._id, + userId: userId, + memberId: memberId + }); + } + }); +} diff --git a/collections/cards.js b/collections/cards.js new file mode 100644 index 00000000000..538b6af4126 --- /dev/null +++ b/collections/cards.js @@ -0,0 +1,287 @@ +Cards = new Mongo.Collection('cards'); +CardComments = new Mongo.Collection('card_comments'); + +// XXX To improve pub/sub performances a card document should include a +// de-normalized number of comments so we don't have to publish the whole list +// of comments just to display the number of them in the board view. +Cards.attachSchema(new SimpleSchema({ + title: { + type: String + }, + archived: { + type: Boolean + }, + listId: { + type: String + }, + // The system could work without this `boardId` information (we could deduce + // the board identifier from the card), but it would make the system more + // difficult to manage and less efficient. + boardId: { + type: String + }, + coverId: { + type: String, + optional: true + }, + createdAt: { + type: Date, + denyUpdate: true + }, + dateLastActivity: { + type: Date + }, + description: { + type: String, + optional: true + }, + labelIds: { + type: [String], + optional: true + }, + members: { + type: [String], + optional: true + }, + // XXX Should probably be called `authorId`. Is it even needed since we have + // the `members` field? + userId: { + type: String + }, + sort: { + type: Number, + decimal: true + } +})); + +CardComments.attachSchema(new SimpleSchema({ + boardId: { + type: String + }, + cardId: { + type: String + }, + // XXX Rename in `content`? `text` is a bit vague... + text: { + type: String + }, + // XXX We probably don't need this information here, since we already have it + // in the associated comment creation activity + createdAt: { + type: Date, + denyUpdate: false + }, + // XXX Should probably be called `authorId` + userId: { + type: String + } +})); + +if (Meteor.isServer) { + Cards.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'] + }); + + CardComments.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return userId === doc.userId; + }, + remove: function(userId, doc) { + return userId === doc.userId; + }, + fetch: ['userId', 'boardId'] + }); +} + +Cards.helpers({ + list: function() { + return Lists.findOne(this.listId); + }, + board: function() { + return Boards.findOne(this.boardId); + }, + labels: function() { + var self = this; + var boardLabels = self.board().labels; + var cardLabels = _.filter(boardLabels, function(label) { + return _.contains(self.labelIds, label._id); + }); + return cardLabels; + }, + user: function() { + return Users.findOne(this.userId); + }, + activities: function() { + return Activities.find({ type: 'card', cardId: this._id }, + { sort: { createdAt: -1 }}); + }, + comments: function() { + return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }}); + }, + attachments: function() { + return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }}); + }, + cover: function() { + return Attachments.findOne(this.coverId); + }, + absoluteUrl: function() { + var board = this.board(); + return Router.path('Card', { + boardId: board._id, + slug: board.slug, + cardId: this._id + }); + }, + rootUrl: function() { + return Meteor.absoluteUrl(this.absoluteUrl().replace('/', '')); + } +}); + +CardComments.helpers({ + user: function() { + return Users.findOne(this.userId); + } +}); + +CardComments.hookOptions.after.update = { fetchPrevious: false }; +Cards.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.dateLastActivity = new Date(); + + // defaults + doc.archived = false; + + // userId native set. + if (! doc.userId) + doc.userId = userId; +}); + +CardComments.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.userId = userId; +}); + +if (Meteor.isServer) { + Cards.after.insert(function(userId, doc) { + Activities.insert({ + type: 'card', + activityType: 'createCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + }); + + // New activity for card (un)archivage + Cards.after.update(function(userId, doc, fieldNames) { + if (_.contains(fieldNames, 'archived')) { + if (doc.archived) { + Activities.insert({ + type: 'card', + activityType: 'archivedCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + } else { + Activities.insert({ + type: 'card', + activityType: 'restoredCard', + boardId: doc.boardId, + listId: doc.listId, + cardId: doc._id, + userId: userId + }); + } + } + }); + + // New activity for card moves + Cards.after.update(function(userId, doc, fieldNames) { + var oldListId = this.previous.listId; + if (_.contains(fieldNames, 'listId') && doc.listId !== oldListId) { + Activities.insert({ + type: 'card', + activityType: 'moveCard', + listId: doc.listId, + oldListId: oldListId, + boardId: doc.boardId, + cardId: doc._id, + userId: userId + }); + } + }); + + // Add a new activity if we add or remove a member to the card + Cards.before.update(function(userId, doc, fieldNames, modifier) { + if (! _.contains(fieldNames, 'members')) + return; + var memberId; + // Say hello to the new member + if (modifier.$addToSet && modifier.$addToSet.members) { + memberId = modifier.$addToSet.members; + if (! _.contains(doc.members, memberId)) { + Activities.insert({ + type: 'card', + activityType: 'joinMember', + boardId: doc.boardId, + cardId: doc._id, + userId: userId, + memberId: memberId + }); + } + } + + // Say goodbye to the former member + if (modifier.$pull && modifier.$pull.members) { + memberId = modifier.$pull.members; + Activities.insert({ + type: 'card', + activityType: 'unjoinMember', + boardId: doc.boardId, + cardId: doc._id, + userId: userId, + memberId: memberId + }); + } + }); + + // Remove all activities associated with a card if we remove the card + Cards.after.remove(function(userId, doc) { + Activities.remove({ + cardId: doc._id + }); + }); + + CardComments.after.insert(function(userId, doc) { + Activities.insert({ + type: 'comment', + activityType: 'addComment', + boardId: doc.boardId, + cardId: doc.cardId, + commentId: doc._id, + userId: userId + }); + }); + + CardComments.after.remove(function(userId, doc) { + var activity = Activities.findOne({ commentId: doc._id }); + if (activity) { + Activities.remove(activity._id); + } + }); +} diff --git a/collections/lists.js b/collections/lists.js new file mode 100644 index 00000000000..196477ec1b4 --- /dev/null +++ b/collections/lists.js @@ -0,0 +1,94 @@ +Lists = new Mongo.Collection('lists'); + +Lists.attachSchema(new SimpleSchema({ + title: { + type: String + }, + archived: { + type: Boolean + }, + boardId: { + type: String + }, + createdAt: { + type: Date, + denyUpdate: true + }, + sort: { + type: Number, + decimal: true, + // XXX We should probably provide a default + optional: true + }, + updatedAt: { + type: Date, + denyInsert: true, + optional: true + } +})); + +if (Meteor.isServer) { + Lists.allow({ + insert: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove: function(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'] + }); +} + +Lists.helpers({ + cards: function() { + return Cards.find(_.extend(Filter.getMongoSelector(), { + listId: this._id, + archived: false + }), { sort: ['sort'] }); + }, + board: function() { + return Boards.findOne(this.boardId); + } +}); + +// HOOKS +Lists.hookOptions.after.update = { fetchPrevious: false }; + +Lists.before.insert(function(userId, doc) { + doc.createdAt = new Date(); + doc.archived = false; + if (! doc.userId) + doc.userId = userId; +}); + +Lists.before.update(function(userId, doc, fieldNames, modifier) { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = new Date(); +}); + +if (Meteor.isServer) { + Lists.after.insert(function(userId, doc) { + Activities.insert({ + type: 'list', + activityType: 'createList', + boardId: doc.boardId, + listId: doc._id, + userId: userId + }); + }); + + Lists.after.update(function(userId, doc) { + if (doc.archived) { + Activities.insert({ + type: 'list', + activityType: 'archivedList', + listId: doc._id, + boardId: doc.boardId, + userId: userId + }); + } + }); +} diff --git a/collections/users.js b/collections/users.js new file mode 100644 index 00000000000..1dcccf12025 --- /dev/null +++ b/collections/users.js @@ -0,0 +1,106 @@ +Users = Meteor.users; + +// Search a user in the complete server database by its name or username. This +// is used for instance to add a new user to a board. +var searchInFields = ['username', 'profile.name']; +Users.initEasySearch(searchInFields, { + use: 'mongo-db', + returnFields: searchInFields +}); + +Users.helpers({ + boards: function() { + return Boards.find({ userId: this._id }); + }, + starredBoards: function() { + var starredBoardIds = this.profile.starredBoards || []; + return Boards.find({_id: {$in: starredBoardIds}}); + }, + hasStarred: function(boardId) { + var starredBoardIds = this.profile.starredBoards || []; + return _.contains(starredBoardIds, boardId); + }, + isBoardMember: function() { + var board = Boards.findOne(Session.get('currentBoard')); + return board && _.contains(_.pluck(board.members, 'userId'), this._id) && + _.where(board.members, {userId: this._id})[0].isActive; + }, + isBoardAdmin: function() { + var board = Boards.findOne(Session.get('currentBoard')); + if (this.isBoardMember(board)) + return _.where(board.members, {userId: this._id})[0].isAdmin; + } +}); + +Users.before.insert(function(userId, doc) { + doc.profile = {}; + + // connect profile.status default + doc.profile.status = 'offline'; + + // slugify to username + //doc.username = getSlug(doc.profile.name, ''); +}); + +if (Meteor.isServer) { + // Each board document contains the de-normalized number of users that have + // starred it. If the user star or unstar a board, we need to update this + // counter. + // We need to run this code on the server only, otherwise the incrementation + // will be done twice. + Users.after.update(function(userId, user, fieldNames) { + // The `starredBoards` list is hosted on the `profile` field. If this + // field hasn't been modificated we don't need to run this hook. + if (! _.contains(fieldNames, 'profile')) + return; + + // To calculate a diff of board starred ids, we get both the previous + // and the newly board ids list + var getStarredBoardsIds = function(doc) { + return doc.profile && doc.profile.starredBoards; + }; + var oldIds = getStarredBoardsIds(this.previous); + var newIds = getStarredBoardsIds(user); + + // The _.difference(a, b) method returns the values from a that are not in + // b. We use it to find deleted and newly inserted ids by using it in one + // direction and then in the other. + var incrementBoards = function(boardsIds, inc) { + _.forEach(boardsIds, function(boardId) { + Boards.update(boardId, {$inc: {stars: inc}}); + }); + }; + incrementBoards(_.difference(oldIds, newIds), -1); + incrementBoards(_.difference(newIds, oldIds), +1); + }); + + // XXX i18n + Users.after.insert(function(userId, doc) { + var ExampleBoard = { + title: 'Welcome Board', + userId: doc._id, + permission: 'private' + }; + + // Insert the Welcome Board + Boards.insert(ExampleBoard, function(err, boardId) { + + _.forEach(['Basics', 'Advanced'], function(title) { + var list = { + title: title, + boardId: boardId, + userId: ExampleBoard.userId, + + // XXX Not certain this is a bug, but we except these fields get + // inserted by the Lists.before.insert collection-hook. Since this + // hook is not called in this case, we have to dublicate the logic and + // set them here. + archived: false, + createdAt: new Date() + }; + + Lists.insert(list); + }); + }); + }); +} diff --git a/i18n/de.i18n.json b/i18n/de.i18n.json new file mode 100644 index 00000000000..675d4ee7aa2 --- /dev/null +++ b/i18n/de.i18n.json @@ -0,0 +1,175 @@ +{ + "account-details": "Account Details", + "actions": "Aktionen", + "activity": "Aktivität", + "activity-archived": "archived %s", + "activity-created": "created %s", + "activity-added": "added %s to %s", + "activity-excluded": "excluded %s from %s", + "activity-moved": "moved %s from %s to %s", + "activity-sent": "sent %s to %s", + "activity-joined": "joined %s", + "activity-unjoined": "unjoinded %s", + "activity-removed": "removed %s from %s", + "activity-attached": "attached %s to %s", + "activity-on": "on %s", + "this-board": "this board", + "this-card": "this card", + "add": "Hinzufügen", + "add-board": "Neues Bord erstellen", + "add-card": "Karte hinzufügen…", + "add-list": "Liste hinzufügen", + "add-members": "Nutzer hinzufügen", + "add-attachment": "Add an attachment…", + "added": "Hinzugefügt", + "attached": "attached", + "admin": "Admin", + "admin-desc": "Kann Karten anschauen und bearbeiten, Nutzer entfernen und Bordeinstellungen ändern.", + "already-have-account-question": "Hast du schon einen Account?", + "archive": "Archiv", + "archive-all": "Alles archivieren", + "archive-list": "Diese Liste archivieren", + "archive-title": "Karte vom Bord entfernen.", + "archived-items": "Archivierte Einträge", + "back": "Zurück", + "bio": "Biographie", + "board-list-btn-title": "Liste der Bords anschauen", + "board-not-found": "Bord nicht gefunden", + "board-public-info": "Dieses Board wird öffentlich sein.", + "boards": "Bords", + "bucket-example": "Zum Beispiel \"Eimerliste\"…", + "cancel": "Abbrechen", + "card-archived": "Diese Karte wurd archiviert.", + "card-comments-title": "Diese Karte hat %s Kommentare.", + "card-delete-notice": "Löschen ist irreversiebel. Alle Aktionen, die mit dieser Karte zu tun haben werden ebenfalls gelöscht.", + "card-delete-pop": "Alle Aktionen werden vom Aktivitäts Feed entfernt und du kannst die Karte nicht mehr wiederherstellen. Es gibt kein zurück. Du kannst die Karte statdessen archivieren, um sie vom Bord zu entfernen und die Aktivitäten zu erhalten.", + "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.", + "change-avatar": "Profilbild ändern", + "change-background": "Hintergrund ändern", + "change-email": "Email Adresse ändern", + "change-name-initials-bio": "Name, Initialen oder Biographie ändern", + "change-password": "Passwort ändern", + "change-permissions": "Berechtigungen ändern…", + "close": "Schließen", + "close-board": "Bord schließen", + "close-board-pop": "Du kannst das Bord wiederherstellen, indem du auf den \"Bords\" Menüeintrag im der Kopfleiste klickst, auf \"Zeige geschlössene Bords an\" auswählst, dein Bord suchst und auf \"Wiederherstellen\" klickst.", + "close-sidebar-title": "Schließe Seitenleiste.", + "comment": "Kommentar", + "comment-placeholder": "Schreibe einen Kommentar…", + "create": "Erstellen", + "create-account": "Account erstellen", + "create-new-account": "Neuen Account erstellen", + "delete": "Löschen", + "delete-title": "Lösche die Karte und ihren Verlauf. Dies kann nicht rückgängig gemacht werden.", + "description": "Beschreibung", + "edit": "Bearbeiten", + "edit-description": "Beschreibung bearbeiten…", + "edit-profile": "Profil bearbeiten", + "email": "Maildresse", + "email-or-username": "Mailadresse oder Nutzername", + "email-placeholder": "z.B: doc@frankenstein.com", + "filter-cards": "Karten filtern", + "filter-clear": "Filter entfernen", + "filter-on": "Filter sind eingeschaltet.", + "filter-on-desc": "Du filterst die Karten auf diesem Bord. Klicke hier, um die Filter zu bearbeiten.", + "fullname": "Voller Name", + "gloabal-search": "Globale Suche", + "header-logo-title": "Zurück zur Bord Seite.", + "home": "Home", + "home-button": "Melde dich an -- Kostenlos!", + "home-login": "Oder logge dich ein.", + "in-list": "in der Liste", + "info": "Informationen", + "joined": "beigetreten", + "labels": "Labels", + "labels-title": "Label für diese Karte ändern.", + "label-create": "Neues Label erstellen.", + "label-delete-pop": "Es gibt kein zurück. Das Label wird von allen Karten entfernt und seine Historie gelöscht.", + "label-default": "%s Label (Default)", + "attachments": "Attachments", + "attachment": "Attachment", + "last-admin-desc": "Du kannst die Rolle nicht ändern, es muss mindestens einen Admin geben.", + "language": "Sprache", + "leave-board": "Bord verlassen…", + "link-card": "Link zu dieser Karte", + "list-move-cards": "Verschiebe alle Karten in dieser Liste…", + "list-archive-cards": "Archiviere alle Karten in dieser Liste…", + "list-archive-cards-pop": "Dies entfernt alle Karten in der Liste vom Bord. Um archivierte Karten anzusehen und zurück zum Bord zu bringen, klicke \"Menü\" > \"Archivierte Items\". ", + "log-in": "Einloggen", + "log-out": "Ausloggen", + "members": "Nutzer", + "members-title": "Füge Nutzer des Bords hinzu oder entferne sie von der Karte.", + "menu": "Menü", + "modal-close-title": "Schließe das Dialogfenster.", + "my-boards": "Meine Bords", + "name": "Name", + "name-placeholder": "zum Beispiel Dr. Frankenstein", + "new-here-question": "Neu hier?", + "normal": "Normal", + "normal-desc": "Kann Karten anschauen und bearbeiten, aber keine Einstellungen ändern.", + "no-boards": "Keine Bords.", + "no-results": "Keine Ergebnisse", + "notifications-title": "Benachrichtigungen", + "optional": "optional", + "page-maybe-private": "Diese Seite könnte privat sein. Vielleicht kannst du sie sehen, wenn du dich einloggst.", + "page-not-found": "Seite nicht gefunden.", + "password": "Passwort", + "password-placeholder": "z.B: ••••••••••••••••", + "private": "Privat", + "private-desc": "Dieses Bord ist privat. Nur Nutzer, die zu dem Bord gehören, können es anschauen und bearbeiten. ", + "profile": "Profil", + "public": "Öffentlich", + "public-desc": "Dieses Bord ist öffentlich. Es ist für jeden, der den Link kennt, sichtbar und taucht in Suchmaschienen wie Google auf. Nur Nutzer, die zum Bord hinzugefügt wurden können es bearbeiten.", + "remove-from-board": "Vom Bord entfernen…", + "remove-member": "Nutzer entfernen", + "remove-member-from-card": "Von Karte entfernen", + "remove-member-pop": "Entferne __name__ (__username__) von __boardTitle__? Das Mitglied wird von allen Karten auf diesem Board entfernt werden. Er wird eine Benachrichtigung erhalten.", + "add-cover": "Add Cover", + "remove-cover": "Remove Cover", + "rename": "Umbenennen", + "save": "Speichern", + "search": "Suchen", + "computer": "Computer", + "download": "Download", + "search-member-desc": "Suche nach einer Person in LibreBord nach Name oder Mailadresse, oder lade jemanden über seine Mailadresse ein.", + "search-title": "Suche nach Bords, Karten, Nutzern und Organisationen.", + "select-color": "Wähle eine Farbe aus", + "send-to-board": "An Bord senden", + "send-to-board-title": "Schicke die Karte zu zum Bord.", + "settings": "Einstellungen", + "share-and-more": "Teilen und mehr…", + "share-and-more-title": "Mehr Optionen teilen, drucken, exportieren und löschen.", + "show-sidebar": "Zeige Seitenleiste", + "sign-up": "Anmelden", + "star-board-title": "Klicke, um das Bord zu besternen. Es erscheint dann oben in deiner Bordliste.", + "starred-boards": "Bords mit Stern", + "starred-boards-description": "Besternte Bords erscheinen oben in deine Bordliste.", + "click-to-star": "Klicker, um das Bord zu besternen.", + "click-to-unstar": "Klicke, um den Stern zu entfernen.", + "subscribe": "Abbonieren", + "team": "Team", + "title": "Titel", + "user-profile-not-found": "Nutzer Profil nicht gefunden.", + "username": "Nutzername", + "warning-signup": "Kostenlos anmelden", + "cardLabelsPopup-title": "Labels", + "cardMembersPopup-title": "Nutzer", + "cardMorePopup-title": "Mehr", + "cardDeletePopup-title": "Karte entfernen?", + "boardChangeTitlePopup-title": "Bord umbenennen", + "boardChangePermissionPopup-title": "Ändere Sichbarkeit", + "addMemberPopup-title": "Nutzer", + "closeBoardPopup-title": "Schliese Bord?", + "removeMemberPopup-title": "Entferne Nutzer?", + "createBoardPopup-title": "Erstelle ein Bord", + "listActionPopup-title": "Liste von Aktionen", + "editLabelPopup-title": "Ändere Label", + "listMoveCardsPopup-title": "Verschiebe alle Karten in der Liste", + "listArchiveCardsPopup-title": "Alle Karten in der Liste archivieren?", + "createLabelPopup-title": "Label erstellen", + "deleteLabelPopup-title": "Entferne Label?", + "changePermissionsPopup-title": "Ändere Erlaubnisse", + "setLanguagePopup-title": "Ändere Sprache", + "cardAttachmentsPopup-title": "Attach From…", + "attachmentDeletePopup-title": "Delete Attachment?" +} \ No newline at end of file diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json new file mode 100644 index 00000000000..e1c90273fbd --- /dev/null +++ b/i18n/en.i18n.json @@ -0,0 +1,182 @@ +{ + "account-details": "Account Details", + "actions": "Actions", + "activity": "Activity", + "activity-archived": "archived %s", + "activity-created": "created %s", + "activity-added": "added %s to %s", + "activity-excluded": "excluded %s from %s", + "activity-moved": "moved %s from %s to %s", + "activity-sent": "sent %s to %s", + "activity-joined": "joined %s", + "activity-unjoined": "unjoinded %s", + "activity-removed": "removed %s from %s", + "activity-attached": "attached %s to %s", + "activity-on": "on %s", + "this-board": "this board", + "this-card": "this card", + "add": "Add", + "add-board": "Add a new board", + "add-card": "Add a card", + "add-list": "Add a list", + "add-members": "Add Members…", + "add-attachment": "Add an attachment…", + "added": "Added", + "attached": "attached", + "admin": "Admin", + "admin-desc": "Can view and edit cards, remove members, and change settings for the board.", + "already-have-account-question": "Already have an account?", + "archive": "Archive", + "archive-all": "Archive All", + "archive-list": "Archive this list", + "archive-title": "Remove the card from the board.", + "archived-items": "Archived Items", + "back": "Back", + "bio": "Bio", + "boardMenu-title": "Board Menu", + "board-list-btn-title": "View list of boards", + "board-not-found": "Board not found", + "board-public-info": "This board will be public.", + "boards": "Boards", + "bucket-example": "Like “Bucket List” for example…", + "cancel": "Cancel", + "card-archived": "This card is archived.", + "card-comments-title": "This card has %s comment.", + "card-delete-notice": "Deleting is permanent. You will lose all actions associated with this card.", + "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo. You can archive a card to remove it from the board and preserve the activity.", + "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.", + "change-avatar": "Change Avatar", + "change-background": "Change background", + "change-email": "Change Email", + "change-name-initials-bio": "Change Name, Initials, or Bio", + "change-password": "Change Password", + "change-permissions": "Change permissions…", + "current": "current", + "close": "Close", + "close-board": "Close Board…", + "close-board-pop": "You can re-open the board by clicking the “Boards” menu from the header, selecting “View Closed Boards”, finding the board and clicking “Re-open”.", + "close-sidebar-title": "Close the board sidebar.", + "comment": "Comment", + "comment-placeholder": "Write a comment…", + "create": "Create", + "signupPopup-title": "Create an Account", + "create-new-account": "Create a new account", + "delete": "Delete", + "delete-title": "Delete the card and all history associated with it. It can’t be retrieved.", + "description": "Description", + "edit": "Edit", + "edit-description": "Edit the description…", + "edit-profile": "Edit profile", + "email": "Email", + "email-or-username": "Email or username", + "email-placeholder": "e.g., doc@frankenstein.com", + "filter-cards": "Filter Cards", + "filter-clear": "Clear filter.", + "filter-on": "Filtering is on.", + "filter-on-desc": "You are filtering cards on this board. Click here to edit filter.", + "fullname": "Full Name", + "gloabal-search": "Global Search", + "header-logo-title": "Go back to your boards page.", + "home": "Home", + "home-button": "Sign Up—It’s Free!", + "home-login": "Or log in ", + "in-list": "in list", + "info": "Infos", + "joined": "joined", + "labels": "Labels", + "labels-title": "Change the labels for the card.", + "label-create": "Create a new label", + "label-delete-pop": "There is no undo. This will remove this label from all cards and destroy its history.", + "label-default": "%s label (default)", + "attachments": "Attachments", + "attachment": "Attachment", + "last-admin-desc": "You can’t change roles because there must be at least one admin.", + "language": "Language", + "leave-board": "Leave Board…", + "link-card": "Link to this card", + "list-move-cards": "Move All Cards in This List…", + "list-archive-cards": "Archive All Cards in This List…", + "list-archive-cards-pop": "This will remove all the cards in this list from the board. To view archived cards and bring them back to the board, click “Menu” > “Archived Items”.", + "log-in": "Log In", + "loginPopup-title": "Log In", + "log-out": "Log Out", + "members": "Members", + "members-title": "Add or remove members of the board from the card.", + "menu": "Menu", + "modal-close-title": "Close this dialog window.", + "my-boards": "My Boards", + "name": "Name", + "name": "Name", + "name-placeholder": "e.g., Dr. Frankenstein", + "new-here-question": "New here?", + "normal": "Normal", + "normal-desc": "Can view and edit cards. Can't change settings.", + "no-boards": "No boards.", + "no-results": "No results", + "notifications-title": "Notifications", + "optional": "optional", + "page-maybe-private": "This page may be private. You may be able to view it by logging in.", + "page-not-found": "Page not found.", + "password": "Password", + "password-placeholder": "e.g., ••••••••••••••••", + "private": "Private", + "private-desc": "This board is private. Only people added to the board can view and edit it.", + "profile": "Profile", + "public": "Public", + "public-desc": "This board is public. It's visible to anyone with the link and will show up in search engines like Google. Only people added to the board can edit.", + "remove-from-board": "Remove from Board…", + "remove-member": "Remove Member", + "remove-member-from-card": "Remove from Card", + "remove-member-pop": "Remove __name__ (__username__) from __boardTitle__? The member will be removed from all cards on this board. They will receive a notification.", + "add-cover": "Add Cover", + "remove-cover": "Remove Cover", + "rename": "Rename", + "rename-board": "Rename Board", + "save": "Save", + "search": "Search", + "computer": "Computer", + "download": "Download", + "search-member-desc": "Search for a person in LibreBoard by name or email address, or enter an email address to invite someone new.", + "search-title": "Search for boards, cards, members, and organizations.", + "select-color": "Select a color", + "send-to-board": "Send to board", + "send-to-board-title": "Send the card back to the board.", + "settings": "Settings", + "share-and-more": "Share and more…", + "share-and-more-title": "More options share, print, export, and delete.", + "show-sidebar": "Show sidebar", + "sign-up": "Sign Up", + "star-board-title": "Click to star this board. It will show up at top of your boards list.", + "starred-boards": "Starred Boards", + "starred-boards-description": "Starred boards show up at the top of your boards list.", + "click-to-star": "Click to star this board.", + "click-to-unstar": "Click to unstar this board.", + "subscribe": "Subscribe", + "team": "Team", + "title": "Title", + "user-profile-not-found": "User Profile not found.", + "username": "Username", + "warning-signup": "Sign up for free", + "cardLabelsPopup-title": "Labels", + "cardMembersPopup-title": "Members", + "cardMorePopup-title": "More", + "cardDeletePopup-title": "Delete Card?", + "boardMenuPopup-title": "Board Menu", + "boardChangeTitlePopup-title": "Rename Board", + "boardChangePermissionPopup-title": "Change Visibility", + "addMemberPopup-title": "Members", + "closeBoardPopup-title": "Close Board?", + "removeMemberPopup-title": "Remove Member?", + "createBoardPopup-title": "Create Board", + "listActionPopup-title": "List Actions", + "editLabelPopup-title": "Change Label", + "listMoveCardsPopup-title": "Move All Cards in List", + "boardChangeColorPopup-title": "Change Board Background", + "listArchiveCardsPopup-title": "Archive All Cards in this List?", + "createLabelPopup-title": "Create Label", + "deleteLabelPopup-title": "Delete Label?", + "changePermissionsPopup-title": "Change Permissions", + "setLanguagePopup-title": "Change Language", + "cardAttachmentsPopup-title": "Attach From…", + "attachmentDeletePopup-title": "Delete Attachment?" +} diff --git a/i18n/fr.i18n.json b/i18n/fr.i18n.json new file mode 100644 index 00000000000..a34360b49e5 --- /dev/null +++ b/i18n/fr.i18n.json @@ -0,0 +1,175 @@ +{ + "account-details": "Détails du compte", + "actions": "Actions", + "activity": "Activité", + "activity-archived": "a archivé %s", + "activity-created": "créé %s", + "activity-added": "a ajouté %s à %s", + "activity-excluded": "a exclu %s de %s", + "activity-moved": "a déplacé %s depuis %s vers %s", + "activity-sent": "a envoyé %s vers %s", + "activity-joined": "a rejoint %s", + "activity-unjoined": "a quitté %s", + "activity-removed": "a supprimé %s vers %s", + "activity-attached": "a attaché %s à %s", + "activity-on": "sur %s", + "this-board": "ce tableau", + "this-card": "cette carte", + "add": "Ajouter", + "add-board": "Ajouter un nouveau tableau", + "add-card": "Ajouter une carte…", + "add-list": "Ajouter une liste…", + "add-members": "Ajouter des membres…", + "add-attachment": "Ajouter une pièce jointe…", + "added": "Ajouté", + "attached": "joint", + "admin": "Admin", + "admin-desc": "Peut voir et éditer les cartes, supprimer des membres, et changer les paramètres du tableau.", + "already-have-account-question": "Vous avez déjà un compte?", + "archive": "Archiver", + "archive-all": "Tout archiver", + "archive-list": "Archiver cette liste", + "archive-title": "Retirer cette carte du tableau.", + "archived-items": "Éléments archivés", + "back": "Retour", + "bio": "Bio", + "board-list-btn-title": "Voir la liste des tableaux", + "board-not-found": "Tableau non trouvé", + "board-public-info": "Ce tableau sera public.", + "boards": "Tableaux", + "bucket-example": "par exemple « liste de courses »", + "cancel": "Annuler", + "card-archived": "Cette carte est archivée.", + "card-comments-title": "Cette carte a %s commentaires.", + "card-delete-notice": "La suppression est permanente. Vous perdrez toutes les actions associées à cette carte.", + "card-delete-pop": "Cette action est irréversible. Tous les commentaires et les activités associés à cette carte seront supprimés et il ne sera pas possible de ré-ouvrir la carte. Vous pouvez aussi archiver la carte pour l'enlever du tableau tout en préservant les activités.", + "attachment-delete-pop": "La suppression d'une pièce jointe est définitive. Elle ne peut être annulée.", + "change-avatar": "Changer l'avatar", + "change-background": "Changer le fond", + "change-email": "Changer l'email", + "change-name-initials-bio": "Change le nom, les initiales, la bio", + "change-password": "Changer le mot de passe", + "change-permissions": "Changer les permissions", + "close": "Fermer", + "close-board": "Clôturer le tableau…", + "close-board-pop": "Vous pouvez ré-ouvrir le tableau en cliquant sur le menu « Tableau » dans la barre d'en-tête, puis en sélection « Voir les tableaux fermés », en trouvant le tableau désiré puis en cliquant sur « Ré-ouvrir ».", + "close-sidebar-title": "Fermer le menu de tableau.", + "comment": "Commentaire", + "comment-placeholder": "Écrire un commentaire…", + "create": "Créer", + "create-account": "Créer un compe", + "create-new-account": "Créer un nouveau compte", + "delete": "Supprimer", + "delete-title": "Supprimer la carte et son historique d'activité. Cette action est irréversible.", + "description": "Description", + "edit": "Éditer", + "edit-description": "Éditer la description…", + "edit-profile": "Éditer le profil", + "email": "Email", + "email-or-username": "Email ou nom d'utilisateur", + "email-placeholder": "exemple, doc@frankenstein.com", + "filter-cards": "Filter Cards", + "filter-clear": "Clear filter.", + "filter-on": "Filtering is on.", + "filter-on-desc": "You are filtering cards on this board. Click here to edit filter.", + "fullname": "Nom complet", + "gloabal-search": "Recherche globale", + "header-logo-title": "Retourner à la page des tableaux", + "home": "Accueil", + "home-button": "Inscrivez vous — C'est gratuit !", + "home-login": "Ou connectez vous", + "in-list": "dans la liste", + "info": "Infos", + "joined": "a joint", + "labels": "Étiquettes", + "labels-title": "Modifier les étiquettes de la carte.", + "label-create": "Créer une nouvelle étiquette", + "label-delete-pop": "Cette action est irréversible. Elle supprimera cette étiquette de toutes les cartes ainsi que l'historique associé.", + "label-default": "%s label (default)", + "attachments": "Pièces jointes", + "attachment": "Pièce jointe", + "last-admin-desc": "Vous ne pouvez pas changer les rôles car il doit y avoir au moins un admin.", + "language": "Langage ", + "leave-board": "Quitter le tableau…", + "link-card": "Lier cette carte", + "list-move-cards": "Déplacer les cartes de cette liste…", + "list-archive-cards": "Archiver les cartes de cette liste…", + "list-archive-cards-pop": "Cela archivera toutes les cartes de cette liste. Pour voir les cartes archivées et les ramener vers le tableau, cliquez sur le « Menu » puis sur « Éléments archivés ».", + "log-in": "Connexion", + "log-out": "Déconnexion", + "members": "Membres", + "members-title": "Ajouter ou supprimer des membres à la carte.", + "menu": "Menu", + "modal-close-title": "Fermer cette boite de dialogue.", + "my-boards": "Mes tableaux", + "name": "Nom", + "name-placeholder": "exemple, Dr. Frankenstein", + "new-here-question": "Nouveau ici ?", + "normal": "Normal", + "normal-desc": "Peut voir et éditer les cartes. Ne peut pas changer les paramètres.", + "no-boards": "Pas de tableaux.", + "no-results": "Pas de résultats", + "notifications-title": "Notifications", + "optional": "optionnel", + "page-maybe-private": "Cette page est peut-être privée. Vous pourrez peut-être la voir en vous connectant.", + "page-not-found": "Page non trouvé", + "password": "Mot de passe", + "password-placeholder": "exemple, ••••••••••••••••", + "private": "Privé", + "private-desc": "Ce tableau est privé. Seul les membres peuvent y accéder.", + "profile": "Profil", + "public": "Public", + "public-desc": "Ce tableau est public. Il est visible par toutes les personnes possédant le lien et visible dans les moteurs de recherche tels que Google. Seuls les membres peuvent l'éditer.", + "remove-from-board": "Supprimer du tableau…", + "remove-member": "Supprimer le membre", + "remove-member-from-card": "Supprimer de la carte", + "remove-member-pop": "Supprimer __name__ (__username__) de __boardTitle__ ? Ce membre sera supprimé de toutes les cartes du tableau et recevra une notification.", + "add-cover": "Ajouter la couverture", + "remove-cover": "Enlever la couverture", + "rename": "Renommer", + "save": "Sauvegarder", + "search": "Chercher", + "computer": "Ordinateur", + "download": "Télécharger", + "search-member-desc": "Chercher un utilisateur de LibreBoard par nom ou par son adresse email ou entrez une adrese email pour inviter un nouvel utilisateur.", + "search-title": "Chercher des tableaux, cartes, membres, et organisations.", + "select-color": "Choisissez une couleur", + "send-to-board": "Envoyer vers le tableau", + "send-to-board-title": "Renvoyer cette carte vers le tableau.", + "settings": "Paramètres", + "share-and-more": "Partage et plus…", + "share-and-more-title": "Plus d'options partager, imprimer, exporter, et supprimer.", + "show-sidebar": "Afficher le menu", + "sign-up": "Inscription", + "star-board-title": "Cliquer pour ajouter ce tableau aux favoris. Il sera affiché en haut de votre liste de tableaux.", + "starred-boards": "Tableaux favoris", + "starred-boards-description": "Les tableaux favoris s'affichent en haut de votre liste de tableaux.", + "click-to-star": "Cliquez pour ajouter ce tableau aux favoris.", + "click-to-unstar": "Cliquez pour retirer ce tableau des favoris.", + "subscribe": "Suivre", + "team": "Équipe", + "title": "Titre", + "user-profile-not-found": "Profil utilisateur non trouvé.", + "username": "Nom d'utilisateur", + "warning-signup": "Inscription gratuite", + "cardLabelsPopup-title": "Étiquettes", + "cardMembersPopup-title": "Membres", + "cardMorePopup-title": "Plus", + "cardDeletePopup-title": "Supprimer la carte ?", + "boardChangeTitlePopup-title": "Renommer le tableau", + "boardChangePermissionPopup-title": "Changer la visibilité", + "addMemberPopup-title": "Membres", + "closeBoardPopup-title": "Fermer le tableau ?", + "removeMemberPopup-title": "Supprimer le membre ?", + "createBoardPopup-title": "Créer un tableau", + "listActionPopup-title": "Liste des actions", + "editLabelPopup-title": "Changer l'étiquette", + "listMoveCardsPopup-title": "Déplacer les cartes de la liste", + "listArchiveCardsPopup-title": "Archiver les cartes de la liste ?", + "createLabelPopup-title": "Créer un étiquette", + "deleteLabelPopup-title": "Supprimer l'étiquette ?", + "changePermissionsPopup-title": "Changer les permissions", + "setLanguagePopup-title": "Changer la langue", + "cardAttachmentsPopup-title": "Joindre depuis…", + "attachmentDeletePopup-title": "Supprimer la pièce jointe ?" +} \ No newline at end of file diff --git a/i18n/ja.i18n.json b/i18n/ja.i18n.json new file mode 100644 index 00000000000..d914f118c70 --- /dev/null +++ b/i18n/ja.i18n.json @@ -0,0 +1,175 @@ +{ + "account-details": "アカウント詳細", + "actions": "操作", + "activity": "アクティビティ", + "activity-archived": "archived %s", + "activity-created": "created %s", + "activity-added": "added %s to %s", + "activity-excluded": "excluded %s from %s", + "activity-moved": "moved %s from %s to %s", + "activity-sent": "sent %s to %s", + "activity-joined": "joined %s", + "activity-unjoined": "unjoinded %s", + "activity-removed": "removed %s from %s", + "activity-attached": "attached %s to %s", + "activity-on": "on %s", + "this-board": "this board", + "this-card": "this card", + "add": "追加", + "add-board": "ボード追加", + "add-card": "カード追加...", + "add-list": "リスト追加...", + "add-members": "メンバー追加...", + "add-attachment": "Add an attachment…", + "added": "追加しました", + "attached": "attached", + "admin": "管理", + "admin-desc": "Can view and edit cards, remove members, and change settings for the board.", + "already-have-account-question": "すでにアカウントをお持ちですか?", + "archive": "アーカイブ", + "archive-all": "すべてをアーカイブ", + "archive-list": "このリストをアーカイブ", + "archive-title": "ボードからカードを取り除く", + "archived-items": "アーカイブされたアイテム", + "back": "戻る", + "bio": "自己紹介", + "board-list-btn-title": "ボード一覧を見る", + "board-not-found": "ボードが見つかりません", + "board-public-info": "ボードは公開されます。", + "boards": "ボード", + "bucket-example": "例:Bucket List", + "cancel": "キャンセル", + "card-archived": "カードはアーカイブされました。", + "card-comments-title": "%s 件のコメントがあります。", + "card-delete-notice": "Deleting is permanent. You will lose all actions associated with this card.", + "card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo. You can archive a card to remove it from the board and preserve the activity.", + "attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.", + "change-avatar": "アバターの変更", + "change-background": "Change background", + "change-email": "メールアドレスの変更", + "change-name-initials-bio": "名前、イニシャル、自己紹介の変更", + "change-password": "パスワードの変更", + "change-permissions": "権限の変更...", + "close": "閉じる", + "close-board": "ボードを閉じる", + "close-board-pop": "ヘッダーの\"ボード\"メニューから\"閉じたボードを見る\"を選択し、そこでボードを選択して、\"ボードの再開\"をクリックすると、ボードを再度利用できるようになります。", + "close-sidebar-title": "サイドバーを閉じる", + "comment": "コメント", + "comment-placeholder": "コメントする", + "create": "作成", + "create-account": "アカウント作成", + "create-new-account": "新規アカウント作成", + "delete": "削除", + "delete-title": "Delete the card and all history associated with it. It can’t be retrieved.", + "description": "詳細", + "edit": "編集", + "edit-description": "詳細を編集する", + "edit-profile": "プロフィール編集", + "email": "メールアドレス", + "email-or-username": "メールアドレスまたはユーザー名", + "email-placeholder": "例:doc@frankenstein.com", + "filter-cards": "カードをフィルターする", + "filter-clear": "フィルター解除", + "filter-on": "フィルターが有効です。", + "filter-on-desc": "このボードのカードをフィルターしています。フィルターを編集するにはこちらをクリックしてください。", + "fullname": "フルネーム", + "gloabal-search": "Global Search", + "header-logo-title": "自分のボードページに戻る。", + "home": "ホーム", + "home-button": "サインアップー無料!", + "home-login": "またはログイン", + "in-list": "in list", + "info": "Infos", + "joined": "joined", + "labels": "ラベル", + "labels-title": "カードのラベルを変更する", + "label-create": "ラベル作成", + "label-delete-pop": "Undoはできません。このラベルはすべてのカードから外され履歴からも見えなくなります。", + "label-default": "%s label (default)", + "attachments": "Attachments", + "attachment": "Attachment", + "last-admin-desc": "最低でも1人以上の管理者が必要なためロールを変更できません。", + "language": "言語", + "leave-board": "ボードから移動...", + "link-card": "このカードへのリンク", + "list-move-cards": "このリスト内の全カードを移動...", + "list-archive-cards": "このリスト内の全カードをアーカイブ...", + "list-archive-cards-pop": "This will remove all the cards in this list from the board. To view archived cards and bring them back to the board, click “Menu” > “Archived Items”.", + "log-in": "ログイン", + "log-out": "ログアウト", + "members": "メンバー", + "members-title": "Add or remove members of the board from the card.", + "menu": "メニュー", + "modal-close-title": "ダイアログを閉じる", + "my-boards": "自分のボード", + "name": "名前", + "name-placeholder": "例:Dr.フランケンシュタイン", + "new-here-question": "初めてですか?", + "normal": "Normal", + "normal-desc": "Can view and edit cards. Can't change settings.", + "no-boards": "ボードがありません。", + "no-results": "該当するものはありません", + "notifications-title": "通知", + "optional": "任意", + "page-maybe-private": "このページはプライベートです。ログインして見てください。", + "page-not-found": "ページが見つかりません。", + "password": "パスワード", + "password-placeholder": "例: ••••••••••••••••", + "private": "プライベート", + "private-desc": "このボードはプライベートです。ボードメンバーのみが閲覧・編集可能です。", + "profile": "プロフィール", + "public": "公開", + "public-desc": "このボードはパブリックです。リンクを知っていれば誰でもアクセス可能でGoogleのような検索エンジンの結果に表示されます。このボードに追加されている人だけがカード追加が可能です。", + "remove-from-board": "ボードから取り除く...", + "remove-member": "メンバーを外す", + "remove-member-from-card": "カードから取り除く", + "remove-member-pop": "Remove __name__ (__username__) from __boardTitle__? The member will be removed from all cards on this board. They will receive a notification.", + "add-cover": "Add Cover", + "remove-cover": "Remove Cover", + "rename": "名前変更", + "save": "保存", + "search": "検索", + "computer": "Computer", + "download": "Download", + "search-member-desc": "Search for a person in LibreBoard by name or email address, or enter an email address to invite someone new.", + "search-title": "ボード、カード、メンバー、組織の検索", + "select-color": "色を選択", + "send-to-board": "ボードへ送る", + "send-to-board-title": "Send the card back to the board.", + "settings": "設定", + "share-and-more": "共有、その他", + "share-and-more-title": "共有、印刷、エクスポートおよび削除などのオプション", + "show-sidebar": "サイドバーを表示", + "sign-up": "サインアップ", + "star-board-title": "ボードにスターをつけると自分のボード一覧のトップに表示されます。", + "starred-boards": "スターのついたボード", + "starred-boards-description": "スターのついたボードはボードリストの先頭に表示されます。", + "click-to-star": "ボードにスターをつける", + "click-to-unstar": "ボードからスターを外す", + "subscribe": "購読", + "team": "チーム", + "title": "タイトル", + "user-profile-not-found": "プロフィールが見つかりません。", + "username": "ユーザー名", + "warning-signup": "無料でサインアップ", + "cardLabelsPopup-title": "ラベル", + "cardMembersPopup-title": "メンバー", + "cardMorePopup-title": "More", + "cardDeletePopup-title": "カードを削除しますか?", + "boardChangeTitlePopup-title": "ボード名の変更", + "boardChangePermissionPopup-title": "公開範囲の変更", + "addMemberPopup-title": "メンバー", + "closeBoardPopup-title": "ボードを閉じますか?", + "removeMemberPopup-title": "メンバーを外しますか?", + "createBoardPopup-title": "ボードの作成", + "listActionPopup-title": "操作一覧", + "editLabelPopup-title": "ラベルの変更", + "listMoveCardsPopup-title": "リスト内のすべてのカードを移動する", + "listArchiveCardsPopup-title": "このリスト内の善カードをアーカイブしますか?", + "createLabelPopup-title": "ラベルの作成", + "deleteLabelPopup-title": "ラベルを削除しますか?", + "changePermissionsPopup-title": "パーミッションの変更", + "setLanguagePopup-title": "言語の変更", + "cardAttachmentsPopup-title": "Attach From…", + "attachmentDeletePopup-title": "Delete Attachment?" +} \ No newline at end of file diff --git a/i18n/pt-BR.i18n.json b/i18n/pt-BR.i18n.json new file mode 100644 index 00000000000..128e18c13d8 --- /dev/null +++ b/i18n/pt-BR.i18n.json @@ -0,0 +1,175 @@ +{ + "account-details": "Detalhes da Conta", + "actions": "Ações", + "activity": "Atividade", + "activity-archived": "arquivou %s", + "activity-created": "criou %s", + "activity-added": "adicionou %s a %s", + "activity-excluded": "excluiu %s de %s", + "activity-moved": "moveu %s de %s para %s", + "activity-sent": "enviou %s de %s", + "activity-joined": "juntou-se a %s", + "activity-unjoined": "deixou %s", + "activity-removed": "removeu %s de %s", + "activity-attached": "anexou %s a %s", + "activity-on": "em %s", + "this-board": "este quadro", + "this-card": "este cartão", + "add": "Novo", + "add-board": "Criar um quadro novo", + "add-card": "Criar um cartão…", + "add-list": "Criar uma lista…", + "add-members": "Adicionar membros…", + "add-attachment": "Adicionar anexos…", + "added": "Criado", + "attached": "anexado", + "admin": "Administrador", + "admin-desc": "Pode ver e editar cartões, remover membros e alterar configurações do quadro.", + "already-have-account-question": "Já possui uma conta?", + "archive": "Arquivar", + "archive-all": "Arquivar Tudo", + "archive-list": "Arquivar esta lista", + "archive-title": "Remover cartão do quadro.", + "archived-items": "Itens Arquivados", + "back": "Voltar", + "bio": "Biografia", + "board-list-btn-title": "Ver lista de quadros", + "board-not-found": "Quadro não encontrado", + "board-public-info": "Este quadro será público.", + "boards": "Quadros", + "bucket-example": "Curtir “Lista de Balde”, por exemplo…", + "cancel": "Cancelar", + "card-archived": "Este cartão está arquivado.", + "card-comments-title": "Este cartão possui %s comentários.", + "card-delete-notice": "A exclusão será permanente. Você perderá todas as ações associadas a este cartão.", + "card-delete-pop": "Todas as ações serão excluídas da lista de atividades e o cartão não poderá ser reaberto. Você pode arquivá-lo para removê-lo do quadro preservando sua atividade.", + "attachment-delete-pop": "Excluir um anexo é permanente. Não será possível recuperá-lo.", + "change-avatar": "Alterar Avatar", + "change-background": "Alterar plano de fundo", + "change-email": "Alterar E-mail", + "change-name-initials-bio": "Alterar Nome, Iniciais ou Biografia", + "change-password": "Alterar Senha", + "change-permissions": "Alterar permissões…", + "close": "Fechar", + "close-board": "Fechar Quadro…", + "close-board-pop": "Você pode reabrir um quadro clicando em “Quadros” no menu no cabeçalho, selecionando “Exibir Quadros Fechados”, encontrando-o e clicando em “Reabrir”.", + "close-sidebar-title": "Fechar barra lateral.", + "comment": "Comentário", + "comment-placeholder": "Comentar…", + "create": "Criar", + "create-account": "Criar uma Conta", + "create-new-account": "Criar uma nova conta", + "delete": "Excluir", + "delete-title": "Excluir cartão e todo o seu histórico. Não será possível recuperá-lo.", + "description": "Descrição", + "edit": "Editar", + "edit-description": "Editar a descrição…", + "edit-profile": "Editar perfil", + "email": "E-mail", + "email-or-username": "E-mail ou nome de usuário", + "email-placeholder": "ex.: dr@frankenstein.com", + "filter-cards": "Filtrar Cartões", + "filter-clear": "Limpar filtro.", + "filter-on": "Filtro ativado.", + "filter-on-desc": "Você está filtrando cartões neste quadro. Clique aqui para editar o filtro.", + "fullname": "Nome Completo", + "gloabal-search": "Busca Global", + "header-logo-title": "Voltar para a lista de quadros.", + "home": "Início", + "home-button": "Cadastre-se. É gratuito!", + "home-login": "Ou entre", + "in-list": "na lista", + "info": "Informações", + "joined": "juntou-se", + "labels": "Etiquetas", + "labels-title": "Alterar etiquetas do cartão.", + "label-create": "Criar uma nova etiqueta", + "label-delete-pop": "Não será possível recuperá-la. A etiqueta será removida de todos os cartões e seu histórico será destruído.", + "label-default": "%s etiqueta (padrão)", + "attachments": "Anexos", + "attachment": "Anexo", + "last-admin-desc": "Você não pode alterar funções porque deve existir pelo menos um administrador.", + "language": "Idioma", + "leave-board": "Deixar Quadro…", + "link-card": "Vincular a este cartão", + "list-move-cards": "Mover Todos Os Cartões nesta Lista…", + "list-archive-cards": "Arquivar Todos Os Cartões nesta Lista…", + "list-archive-cards-pop": "Isto removerá todos os cartões desta lista do quadro. Para visualizar os cartões arquivados e trazê-los de volta para o quadro, clique em “Menu” > “Itens Arquivados”.", + "log-in": "Entrar", + "log-out": "Sair", + "members": "Membros", + "members-title": "Acrescentar ou remover membros do quadro deste cartão.", + "menu": "Menu", + "modal-close-title": "Fechar esta janela.", + "my-boards": "Meus Quadros", + "name": "Nome", + "name-placeholder": "ex.: Dr. Frankenstein", + "new-here-question": "Novo aqui?", + "normal": "Normal", + "normal-desc": "Pode ver e editar cartões. Não pode alterar configurações.", + "no-boards": "Nenhum quadro.", + "no-results": "Nenhum resultado.", + "notifications-title": "Notificações", + "optional": "opcional", + "page-maybe-private": "Esta página pode ser privada. Você poderá vê-la se estiver logado.", + "page-not-found": "Página não encontrada.", + "password": "Senha", + "password-placeholder": "ex.: ••••••••••••••••", + "private": "Privado", + "private-desc": "Este quadro é privado. Apenas seus membros podem acessar e editá-lo.", + "profile": "Perfil", + "public": "Público", + "public-desc": "Este quadro é público. Ele é visível a qualquer pessoa com o link e será exibido em mecanismos de busca como o Google. Apenas seus membros podem editá-lo.", + "remove-from-board": "Remover do Quadro…", + "remove-member": "Remover Membro", + "remove-member-from-card": "Remover do Cartão", + "remove-member-pop": "Remover __name__ (__username__) de __boardTitle__? O membro será removido de todos os cartões neste quadro e será notificado.", + "add-cover": "Add Cover", + "remove-cover": "Remover Capa", + "rename": "Renomear", + "save": "Salvar", + "search": "Buscar", + "computer": "Computador", + "download": "Baixar", + "search-member-desc": "Busque uma pessoa no LibreBoard por nome ou e-mail, ou digite um e-mail para convidar alguém.", + "search-title": "Busque quadros, cartões, membros e organizações.", + "select-color": "Selecionar uma cor", + "send-to-board": "Enviar para o quadro", + "send-to-board-title": "Enviar cartão de volta para o quadro.", + "settings": "Configurações", + "share-and-more": "Compartilhar e mais…", + "share-and-more-title": "Mais opções: compartilhar, imprimir, exportar e excluir.", + "show-sidebar": "Exibir barra lateral", + "sign-up": "Cadastre-se", + "star-board-title": "Clique para marcar este quadro como favorito. Ele aparecerá no topo na lista dos seus quadros.", + "starred-boards": "Quadros Favoritos", + "starred-boards-description": "Quadros favoritos aparecem no topo da lista dos seus quadros.", + "click-to-star": "Marcar quadro como favorito.", + "click-to-unstar": "Remover quadro dos favoritos.", + "subscribe": "Acompanhar", + "team": "Equipe", + "title": "Título", + "user-profile-not-found": "Perfil de usuário não encontrado.", + "username": "Nome de usuário", + "warning-signup": "Cadastre-se gratuitamente", + "cardLabelsPopup-title": "Etiquetas", + "cardMembersPopup-title": "Membros", + "cardMorePopup-title": "Mais", + "cardDeletePopup-title": "Excluir Cartão?", + "boardChangeTitlePopup-title": "Renomear Quadro", + "boardChangePermissionPopup-title": "Alterar Visibilidade", + "addMemberPopup-title": "Membros", + "closeBoardPopup-title": "Fechar Quadro?", + "removeMemberPopup-title": "Remover Membro?", + "createBoardPopup-title": "Criar Quadro", + "listActionPopup-title": "Listar Ações", + "editLabelPopup-title": "Alterar Etiqueta", + "listMoveCardsPopup-title": "Mover Todos Os Cartões Nesta Lista", + "listArchiveCardsPopup-title": "Arquivar Todos Os Cartões Nesta Lista?", + "createLabelPopup-title": "Criar Etiqueta", + "deleteLabelPopup-title": "Excluir Etiqueta?", + "changePermissionsPopup-title": "Alterar Permissões", + "setLanguagePopup-title": "Alterar Idioma", + "cardAttachmentsPopup-title": "Anexar de…", + "attachmentDeletePopup-title": "Excluir Anexo?" +} \ No newline at end of file diff --git a/i18n/tr.i18n.json b/i18n/tr.i18n.json new file mode 100644 index 00000000000..fabf7ac016f --- /dev/null +++ b/i18n/tr.i18n.json @@ -0,0 +1,175 @@ +{ + "account-details": "Hesap Ayrıntıları", + "actions": "İşlemler", + "activity": "Etkinlik", + "activity-archived": "%s arşivledi", + "activity-created": "%s oluşturdu", + "activity-added": "added %s to %s", + "activity-excluded": "excluded %s from %s", + "activity-moved": "moved %s from %s to %s", + "activity-sent": "sent %s to %s", + "activity-joined": "joined %s", + "activity-unjoined": "unjoinded %s", + "activity-removed": "removed %s from %s", + "activity-attached": "attached %s to %s", + "activity-on": "on %s", + "this-board": "bu pano", + "this-card": "bu kart", + "add": "Ekle", + "add-board": "Yeni bir pano ekle", + "add-card": "Bir kart ekle...", + "add-list": "Bir liste ekle...", + "add-members": "Üye Ekle...", + "add-attachment": "Bir ek dosya ekle...", + "added": "Eklendi", + "attached": "dosya eklendi", + "admin": "Yönetici", + "admin-desc": "Kartları görüntüler ve düzenler, üyeleri çıkarır ve pano ayarlarını değiştirir.", + "already-have-account-question": "Bir hesabın mı var?", + "archive": "Arşiv", + "archive-all": "Tümünü Arşivle", + "archive-list": "Bu listeyi arşivle", + "archive-title": "Panodan bu kartı kaldır.", + "archived-items": "Arşivlenmiş Öğeler", + "back": "Geri", + "bio": "Biyografi", + "board-list-btn-title": "Pano listesini görüntüle", + "board-not-found": "Pano bulunamadı", + "board-public-info": "Bu pano genele açılacaktır.", + "boards": "Panolar", + "bucket-example": "Örnek olarak “Yapılacaklar Listesi” gibi…", + "cancel": "İptal", + "card-archived": "Bu kart arşivlendi.", + "card-comments-title": "This card has %s comment.", + "card-delete-notice": "Silme işlemi kalıcıdır. Bu kartla ilişkili tüm eylemleri kaybedersiniz.", + "card-delete-pop": "Tüm eylemler etkinlik beslemesinden kaldırılacaktır ve kartı yeniden açmak mümkün olmayacaktır. Geri dönüşü yok. Panodan çıkarmak ve etkinlik kayıtlarını korumak için kartı arşivleyebilirsin.", + "attachment-delete-pop": "Ek dosya silme işlemi kalıcıdır. Geri dönüşü yok", + "change-avatar": "Avatar Değiştir", + "change-background": "Arkaplan rengi değiştir", + "change-email": "E-posta Değiştir", + "change-name-initials-bio": "Ad Soyad, Kullanıcı Adı veya Biyografi Değiştir", + "change-password": "Parola Değiştir", + "change-permissions": "Yetkileri değiştir...", + "close": "Kapat", + "close-board": "Panoyu Kapat...", + "close-board-pop": "You can re-open the board by clicking the “Boards” menu from the header, selecting “View Closed Boards”, finding the board and clicking “Re-open”.", + "close-sidebar-title": "Pano kenar çubuğunu kapat.", + "comment": "Yorum Gönder", + "comment-placeholder": "Bir yorum yaz...", + "create": "Oluştur", + "create-account": "Bir Hesap Oluştur", + "create-new-account": "Yeni bir hesap oluştur", + "delete": "Sil", + "delete-title": "\n", + "description": "Açıklama", + "edit": "Düzenle", + "edit-description": "Açıklamayı düzenle...", + "edit-profile": "Bilgilerini düzenle", + "email": "E-posta", + "email-or-username": "E-posta veya kullanıcı adı", + "email-placeholder": "örn., doc@frankenstein.com", + "filter-cards": "Kartları Süz", + "filter-clear": "Süzgeci kaldır.", + "filter-on": "Süzgeç açık.", + "filter-on-desc": "Bu panodaki kartları süzüyorsunuz. Süzgeci düzenlemek için tıklayın.", + "fullname": "Ad Soyad", + "gloabal-search": "Global Search", + "header-logo-title": "Panolar sayfanıza geri dön.", + "home": "Home", + "home-button": "Kaydol—Ücretsiz!", + "home-login": "Veya oturum aç", + "in-list": ", listesinde", + "info": "Infos", + "joined": "joined", + "labels": "Etiketler", + "labels-title": "Change the labels for the card.", + "label-create": "Yeni bir etiket oluştur", + "label-delete-pop": "Geri dönüşü yok. Tüm kartlardan bu etiket kaldırılacaktır ve geçmişini yok edecektir.", + "label-default": "%s etiket (varsayılan)", + "attachments": "Ek Dosyalar", + "attachment": "Ek Dosya", + "last-admin-desc": "Rolleri değiştiremezsiniz çünkü burada en az bir yönetici olmalıdır.", + "language": "Dil", + "leave-board": "Panodan Ayrıl...", + "link-card": "Bu kartın bağlantısı", + "list-move-cards": "Bu Listedeki Tüm Kartları Taşı...", + "list-archive-cards": "Bu Listedeki Tüm Kartlar Arşivle...", + "list-archive-cards-pop": "This will remove all the cards in this list from the board. To view archived cards and bring them back to the board, click “Menu” > “Archived Items”.", + "log-in": "Oturum Aç", + "log-out": "Oturum Kapat", + "members": "Üyeler", + "members-title": "Add or remove members of the board from the card.", + "menu": "Menü", + "modal-close-title": "Bu iletişim penceresini kapatın.", + "my-boards": "Panolarım", + "name": "Adı", + "name-placeholder": "örn., Dr. Frankenstein", + "new-here-question": "Burada yeni misin?", + "normal": "Normal", + "normal-desc": "Kartları görüntüler ve düzenler. Ayarları değiştiremez.", + "no-boards": "Pano yok.", + "no-results": "Sonuç yok", + "notifications-title": "Bildirim", + "optional": "isteğe bağlı", + "page-maybe-private": "Bu sayfa özel olabilir. Oturum açarak görülebilir.", + "page-not-found": "Sayda bulunamadı.", + "password": "Parola", + "password-placeholder": "örn., ••••••••••••••••", + "private": "Özel", + "private-desc": "Bu pano özel. Sadece panoya ekli kişiler görüntüleyebilir ve düzenleyebilir.", + "profile": "Kullanıcı Sayfası", + "public": "Genel", + "public-desc": "Bu pano geneldir. Bağlantı adresi ile herhangi bir kimseye görünür ve Google gibi arama motorlarında gösterilecektir. Panoyu, sadece eklenen kişiler düzenleyebilir.", + "remove-from-board": "Panodan çıkar...", + "remove-member": "Üyeyi Çıkar", + "remove-member-from-card": "Karttan Çıkar", + "remove-member-pop": "__boardTitle__ panosundan __name__ (__username__) çıkarılsın mı? Üye, bu panodaki tüm kartlardan çıkarılacak ve bir bildirim alacak.", + "add-cover": "Add Cover", + "remove-cover": "Remove Cover", + "rename": "Ad değiştir", + "save": "Kaydet", + "search": "Search", + "computer": "Bilgisayar", + "download": "İndir", + "search-member-desc": "LibreBoard'da, bir kişiyi adı veya e-posta adresi ile arayın ya da yeni birini davet etmek için bir e-posta adresi girin.", + "search-title": "Pano, kart, üye ve örgütleri ara.", + "select-color": "Bir renk seç", + "send-to-board": "Panoya gönder", + "send-to-board-title": "Kartı, panoya geri gönder.", + "settings": "Ayarlar", + "share-and-more": "Paylaş ve daha...", + "share-and-more-title": "Birçok seçenek; paylaş, bastır, dışarı aktar ve sil.", + "show-sidebar": "Kenar çubuğunu göster", + "sign-up": "Kaydol", + "star-board-title": "Bu panoyu yıldızlamak için tıkla. Pano listesinin en üstünde gösterilir.", + "starred-boards": "Yıldızlı Panolar", + "starred-boards-description": "Yıldızlanmış panolar, pano listenin en üstünde gösterilir.", + "click-to-star": "Bu panoyu yıldızlamak için tıkla.", + "click-to-unstar": "Bu panunun yıldızını kaldırmak için tıkla.", + "subscribe": "Subscribe", + "team": "Takım", + "title": "Başlık", + "user-profile-not-found": "Kullanıcı Sayfası bulunamadı.", + "username": "Kullanıcı adı", + "warning-signup": "Ücretsiz Kaydol", + "cardLabelsPopup-title": "Etiketler", + "cardMembersPopup-title": "Üyeler", + "cardMorePopup-title": "More", + "cardDeletePopup-title": "Kart Silinsin mi?", + "boardChangeTitlePopup-title": "Pano Adı Değiştirme", + "boardChangePermissionPopup-title": "Görünebilirliği Değiştir", + "addMemberPopup-title": "Üyeler", + "closeBoardPopup-title": "Pano Kapatılsın mı?", + "removeMemberPopup-title": "Üyeyi Çıkarmak mı?", + "createBoardPopup-title": "Pano Oluşturma", + "listActionPopup-title": "Liste İşlemleri", + "editLabelPopup-title": "Etiket Değiştirme", + "listMoveCardsPopup-title": "Listedeki Tüm Kartları Taşıma", + "listArchiveCardsPopup-title": "Bu Listedeki Tüm Kartlar Taşınsın mı?", + "createLabelPopup-title": "Etiket Oluşturma", + "deleteLabelPopup-title": "Etiket Silinsin mi?", + "changePermissionsPopup-title": "Yetkileri Değiştirme", + "setLanguagePopup-title": "Dil Değiştir", + "cardAttachmentsPopup-title": "Şuradan Ekle...", + "attachmentDeletePopup-title": "Ek Dosya Silinsin Mi?" +} \ No newline at end of file diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..cba08bedd06fcc7f15d41ee8d776fe506e9795e5 GIT binary patch literal 16160 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 eI=saLU`