summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMaxime Quandalle <maxime@quandalle.com>2015-05-12 19:20:58 +0200
committerMaxime Quandalle <maxime@quandalle.com>2015-05-12 19:33:50 +0200
commit2dbea30842ec63a68055245fe26633bb7913daf3 (patch)
treee9143893a3d3bf4ad34dd3a97d6f3466561c8756
downloadwekan-2dbea30842ec63a68055245fe26633bb7913daf3.tar.gz
wekan-2dbea30842ec63a68055245fe26633bb7913daf3.tar.bz2
wekan-2dbea30842ec63a68055245fe26633bb7913daf3.zip
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
-rw-r--r--.gitignore5
-rw-r--r--.jscsrc77
-rw-r--r--.jshintrc82
-rw-r--r--.meteor/.finished-upgraders8
-rw-r--r--.meteor/.gitignore1
-rw-r--r--.meteor/.id7
-rw-r--r--.meteor/cordova-plugins1
-rw-r--r--.meteor/packages53
-rw-r--r--.meteor/platforms2
-rw-r--r--.meteor/release1
-rw-r--r--.meteor/versions120
-rw-r--r--.travis.yml7
-rw-r--r--Contributing.md57
-rw-r--r--Dockerfile9
-rw-r--r--LICENSE21
-rw-r--r--README.md25
-rw-r--r--client/components/activities/activities.jade8
-rw-r--r--client/components/activities/activities.js77
-rw-r--r--client/components/activities/comments.jade0
-rw-r--r--client/components/activities/comments.js0
-rw-r--r--client/components/activities/events.js30
-rw-r--r--client/components/activities/templates.html154
-rw-r--r--client/components/boards/body.jade33
-rw-r--r--client/components/boards/body.js70
-rw-r--r--client/components/boards/body.styl54
-rw-r--r--client/components/boards/colors.styl34
-rw-r--r--client/components/boards/events.js96
-rw-r--r--client/components/boards/header.jade87
-rw-r--r--client/components/boards/header.js7
-rw-r--r--client/components/boards/header.styl137
-rw-r--r--client/components/boards/helpers.js45
-rw-r--r--client/components/boards/list.jade14
-rw-r--r--client/components/boards/list.styl85
-rw-r--r--client/components/boards/router.js34
-rw-r--r--client/components/cards/details.jade47
-rw-r--r--client/components/cards/details.js103
-rw-r--r--client/components/cards/details.styl161
-rw-r--r--client/components/cards/events.js285
-rw-r--r--client/components/cards/helpers.js48
-rw-r--r--client/components/cards/labels.styl183
-rw-r--r--client/components/cards/minicard.styl136
-rw-r--r--client/components/cards/popups.jade12
-rw-r--r--client/components/cards/router.js15
-rw-r--r--client/components/cards/templates.html336
-rw-r--r--client/components/forms/cachedValue.js22
-rw-r--r--client/components/forms/forms.styl636
-rw-r--r--client/components/forms/inlinedform.jade6
-rw-r--r--client/components/forms/inlinedform.js93
-rw-r--r--client/components/lists/body.jade50
-rw-r--r--client/components/lists/body.js73
-rw-r--r--client/components/lists/events.js16
-rw-r--r--client/components/lists/header.jade13
-rw-r--r--client/components/lists/header.js25
-rw-r--r--client/components/lists/main.jade5
-rw-r--r--client/components/lists/main.js81
-rw-r--r--client/components/lists/main.styl136
-rw-r--r--client/components/lists/menu.jade28
-rw-r--r--client/components/lists/menu.js46
-rw-r--r--client/components/main/events.js8
-rw-r--r--client/components/main/header.jade40
-rw-r--r--client/components/main/header.js10
-rw-r--r--client/components/main/header.styl266
-rw-r--r--client/components/main/helpers.js63
-rw-r--r--client/components/main/layouts.jade17
-rw-r--r--client/components/main/popup.js16
-rw-r--r--client/components/main/popup.styl585
-rw-r--r--client/components/main/popup.tpl.jade13
-rw-r--r--client/components/main/rendered.js40
-rw-r--r--client/components/main/router.js5
-rw-r--r--client/components/main/spinner.styl45
-rw-r--r--client/components/main/spinner.tpl.jade6
-rw-r--r--client/components/main/templates.html18
-rw-r--r--client/components/modal/events.js14
-rw-r--r--client/components/modal/helpers.js0
-rw-r--r--client/components/modal/modal.tpl.jade5
-rw-r--r--client/components/sidebar/events.js93
-rw-r--r--client/components/sidebar/helpers.js51
-rw-r--r--client/components/sidebar/infiniteScrolling.js37
-rw-r--r--client/components/sidebar/rendered.js21
-rw-r--r--client/components/sidebar/sidebar.js55
-rw-r--r--client/components/sidebar/sidebar.styl154
-rw-r--r--client/components/sidebar/templates.html.old307
-rw-r--r--client/components/sidebar/templates.jade103
-rw-r--r--client/components/users/avatar.jade7
-rw-r--r--client/components/users/events.js59
-rw-r--r--client/components/users/form.styl50
-rw-r--r--client/components/users/headerButtons.jade27
-rw-r--r--client/components/users/headerButtons.js5
-rw-r--r--client/components/users/helpers.js27
-rw-r--r--client/components/users/member.styl107
-rw-r--r--client/components/users/router.js29
-rw-r--r--client/components/users/templates.html118
-rw-r--r--client/config/accounts.js35
-rw-r--r--client/config/avatar.js3
-rw-r--r--client/config/router.js28
-rw-r--r--client/lib/emoji-values.js152
-rw-r--r--client/lib/filter.js133
-rw-r--r--client/lib/i18n.js22
-rw-r--r--client/lib/keyboard.js55
-rw-r--r--client/lib/mixins.js1
-rw-r--r--client/lib/popup.js200
-rw-r--r--client/lib/utils.js96
-rw-r--r--client/styles/cheat.styl79
-rw-r--r--client/styles/fancy-scrollbar.styl45
-rw-r--r--client/styles/main.styl814
-rw-r--r--client/styles/temp.styl110
-rw-r--r--collections/activities.js51
-rw-r--r--collections/attachments.js79
-rw-r--r--collections/boards.js251
-rw-r--r--collections/cards.js287
-rw-r--r--collections/lists.js94
-rw-r--r--collections/users.js106
-rw-r--r--i18n/de.i18n.json175
-rw-r--r--i18n/en.i18n.json182
-rw-r--r--i18n/fr.i18n.json175
-rw-r--r--i18n/ja.i18n.json175
-rw-r--r--i18n/pt-BR.i18n.json175
-rw-r--r--i18n/tr.i18n.json175
-rw-r--r--public/favicon.pngbin0 -> 16160 bytes
-rw-r--r--public/logo.pngbin0 -> 13517 bytes
-rw-r--r--sandstorm-pkgdef.capnp61
-rw-r--r--sandstorm.js94
-rw-r--r--server/lib/utils.js8
-rw-r--r--server/migrations.js113
-rw-r--r--server/publications/activities.js24
-rw-r--r--server/publications/boards.js121
-rw-r--r--server/publications/cards.js4
-rw-r--r--server/publications/users.js0
128 files changed, 10521 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..f86ef4fc
--- /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 00000000..ce061dbe
--- /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 00000000..bcb1f698
--- /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 00000000..8a761038
--- /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 00000000..40830374
--- /dev/null
+++ b/.meteor/.gitignore
@@ -0,0 +1 @@
+local
diff --git a/.meteor/.id b/.meteor/.id
new file mode 100644
index 00000000..0556ccf7
--- /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 00000000..8b137891
--- /dev/null
+++ b/.meteor/cordova-plugins
@@ -0,0 +1 @@
+
diff --git a/.meteor/packages b/.meteor/packages
new file mode 100644
index 00000000..5968f052
--- /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 00000000..efeba1b5
--- /dev/null
+++ b/.meteor/platforms
@@ -0,0 +1,2 @@
+server
+browser
diff --git a/.meteor/release b/.meteor/release
new file mode 100644
index 00000000..dab6b552
--- /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 00000000..5710788b
--- /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 00000000..a2ceb090
--- /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 00000000..cfd67216
--- /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.
+
+<https://github.com/libreboard/libreboard>
+
+## 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 00000000..858fc07b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM meteorhacks/meteord
+MAINTAINER Maxime Quandalle <maxime@quandalle.com>
+
+# 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 00000000..2ae84cf5
--- /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 00000000..17464962
--- /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 00000000..1c6b9faf
--- /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 00000000..c806e87b
--- /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 00000000..e69de29b
--- /dev/null
+++ b/client/components/activities/comments.jade
diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/client/components/activities/comments.js
diff --git a/client/components/activities/events.js b/client/components/activities/events.js
new file mode 100644
index 00000000..ea98e65f
--- /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 00000000..8d3ff763
--- /dev/null
+++ b/client/components/activities/templates.html
@@ -0,0 +1,154 @@
+<template name="boardActivities">
+ {{# each currentBoard.activities }}
+ <div class="phenom phenom-action clearfix phenom-other">
+ {{> userAvatar user=user size="extra-small" class="creator js-show-mem-menu" }}
+ <div class="phenom-desc">
+ {{ > memberName user=user }}
+
+ {{# if $eq activityType 'createBoard' }}
+ {{_ 'activity-created' boardLabel}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'createList' }}
+ {{_ 'activity-added' list.title boardLabel}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'archivedList' }}
+ {{_ 'activity-archived' list.title}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'createCard' }}
+ {{{_ 'activity-added' cardLink boardLabel}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'archivedCard' }}
+ {{{_ 'activity-archived' cardLink}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'restoredCard' }}
+ {{{_ 'activity-sent' cardLink boardLabel}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'moveCard' }}
+ {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'addBoardMember' }}
+ {{{_ 'activity-added' memberLink boardLabel}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'removeBoardMember' }}
+ {{{_ 'activity-excluded' memberLink boardLabel}}}.
+ {{ /if }}
+
+ {{# if $eq activityType 'joinMember' }}
+ {{# if $eq currentUser._id member._id }}
+ {{{_ 'activity-joined' cardLink}}}.
+ {{ else }}
+ {{{_ 'activity-added' memberLink cardLink}}}.
+ {{/if}}
+ {{ /if }}
+
+ {{# if $eq activityType 'unjoinMember' }}
+ {{# if $eq currentUser._id member._id }}
+ {{{_ 'activity-unjoined' cardLink}}}.
+ {{ else }}
+ {{{_ 'activity-removed' memberLink cardLink}}}.
+ {{/if}}
+ {{ /if }}
+
+ {{# if $eq activityType 'addComment' }}
+ <div class="phenom-desc">
+ {{{_ 'activity-on' cardLink}}}
+ <div class="action-comment markeddown">
+ <a href="{{ card.absoluteUrl }}" class="current-comment show tdn">
+ <p>{{#viewer}}{{ comment.text }}{{/viewer}}</p>
+ </a>
+ </div>
+ </div>
+ {{ /if }}
+
+ {{# if $eq activityType 'addAttachment' }}
+ <div class="phenom-desc">
+ {{{_ 'activity-attached' attachmentLink cardLink}}}.
+ </div>
+ {{ /if }}
+ </div>
+ <p class="phenom-meta quiet">
+ <span class="date js-hide-on-sending">
+ {{ moment createdAt }}
+ </span>
+ </p>
+ </div>
+ {{ /each }}
+</template>
+
+<template name="cardActivities">
+ {{# each currentCard.comments }}
+ <div class="phenom phenom-action clearfix phenom-comment">
+ {{> userAvatar user=user size="small" class="creator js-show-mem-menu" }}
+ <form>
+ <div class="phenom-desc">
+ {{ > memberName user=user }}
+ <div class="action-comment markeddown">
+ <div class="current-comment">
+ {{#viewer}}{{ text }}{{/viewer}}
+ </div>
+ <textarea class="js-text" tabindex="1">{{ text }}</textarea>
+ </div>
+ </div>
+ <div class="edit-controls clearfix">
+ <input type="submit" class="primary confirm js-save-edit" value="{{_ 'save'}}" tabindex="2">
+ </div>
+ </form>
+ <p class="phenom-meta quiet">
+ <span class="date js-hide-on-sending">{{ moment createdAt }}</span>
+ {{# if currentUser }}
+ <span class="js-hide-on-sending">
+ - <a href="#" class="js-edit-action">{{_ "edit"}}</a>
+ - <a href="#" class="js-confirm-delete-action">{{_ "delete"}}</a>
+ </span>
+ {{/ if }}
+ </p>
+ </div>
+ {{/each}}
+
+ {{# each currentCard.activities }}
+ <div class="phenom phenom-action clearfix phenom-other">
+ {{> userAvatar user=user size="extra-small" class="creator js-show-mem-menu" }}
+ {{ > memberName user=user }}
+ {{# if $eq activityType 'createCard' }}
+ {{_ 'activity-added' cardLabel list.title}}.
+ {{ /if }}
+ {{# if $eq activityType 'joinMember' }}
+ {{# if $eq currentUser._id member._id }}
+ {{_ 'activity-joined' cardLabel}}.
+ {{ else }}
+ {{{_ 'activity-added' cardLabel memberLink}}}.
+ {{/if}}
+ {{/if}}
+ {{# if $eq activityType 'unjoinMember' }}
+ {{# if $eq currentUser._id member._id }}
+ {{_ 'activity-unjoined' cardLabel}}.
+ {{ else }}
+ {{{_ 'activity-removed' cardLabel memberLink}}}.
+ {{/if}}
+ {{ /if }}
+ {{# if $eq activityType 'archivedCard' }}
+ {{_ 'activity-archived' cardLabel}}.
+ {{ /if }}
+ {{# if $eq activityType 'restoredCard' }}
+ {{_ 'activity-sent' cardLabel boardLabel}}.
+ {{/ if }}
+ {{# if $eq activityType 'moveCard' }}
+ {{_ 'activity-moved' cardLabel oldList.title list.title}}.
+ {{/ if }}
+ {{# if $eq activityType 'addAttachment' }}
+ {{{_ 'activity-attached' attachmentLink cardLabel}}}.
+ {{# if attachment.isImage }}
+ <img src="{{ attachment.url }}" class="attachment-image-preview">
+ {{/if}}
+ {{/ if}}
+ </div>
+ {{/each}}
+</template>
diff --git a/client/components/boards/body.jade b/client/components/boards/body.jade
new file mode 100644
index 00000000..5406ee2f
--- /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 00000000..2b4baf53
--- /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 00000000..cb351e46
--- /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 00000000..1db44845
--- /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 00000000..6f9d7fc6
--- /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 00000000..189cdac4
--- /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 00000000..7d02df48
--- /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 00000000..44c38a4b
--- /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 00000000..05be987d
--- /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 00000000..3a8fecd2
--- /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 00000000..c068dbb0
--- /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 00000000..6845b7f2
--- /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 00000000..0de59297
--- /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 00000000..a4fe89a3
--- /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 00000000..faf15d79
--- /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 00000000..9c270e8d
--- /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 00000000..708b1b56
--- /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 00000000..27058b21
--- /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 00000000..a78cd46f
--- /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 00000000..0b5aa4c0
--- /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
+ | (<span class="username">{{ user.username }}</span>)
+ 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 00000000..48bb9a95
--- /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 00000000..4c65e429
--- /dev/null
+++ b/client/components/cards/templates.html
@@ -0,0 +1,336 @@
+<template name="cardModal">
+ {{ > modal template='cardDetailWindow' card=this board=this.board }}
+</template>
+
+<template name="cardMemberPopup">
+ <div class="board-member-menu">
+ <div class="mini-profile-info">
+ {{> userAvatar user=user }}
+ <div class="info">
+ <h3 class="bottom" style="margin-right: 40px;">
+ <a class="js-profile" href="{{ pathFor route='Profile' username=user.username }}">{{ user.profile.name }}</a>
+ </h3>
+ <p class="quiet bottom">@{{ user.username }}</p>
+ </div>
+ </div>
+ {{# if currentUser.isBoardMember }}
+ <ul class="pop-over-list">
+ <li><a class="js-remove-member">{{_ 'remove-member-from-card'}}</a></li>
+ </ul>
+ {{/ if }}
+ </div>
+</template>
+
+<template name="cardMorePopup">
+ <p class="quiet bottom">
+ <span class="clearfix">
+ <span>{{_ 'link-card'}}</span>
+ <span class="icon-sm fa {{#if card.board.isPublic}}fa-globe{{else}}fa-lock{{/if}}"></span>
+ <input class="js-url js-autoselect inline-input" type="text" readonly="readonly" value="{{ card.rootUrl }}">
+ </span>
+ {{_ 'added'}} <span class="date" title="{{ card.createdAt }}">{{ moment card.createdAt 'LLL' }}</span> -
+ <a class="js-delete" href="#" title="{{_ 'card-delete-notice'}}">{{_ 'delete'}}</a>
+ </p>
+</template>
+
+<template name="cardLabelsPopup">
+ <div>
+ {{! <input id="labelSearch" name="search" class="js-autofocus js-label-search" placeholder="Search labels…" value="" type="text"> }}
+ <ul class="edit-labels-pop-over js-labels-list">
+ {{# each card.board.labels }}
+ <li>
+ <a href="#" class="card-label-edit-button icon-sm fa fa-pencil js-edit-label"></a>
+ <span class="card-label card-label-selectable card-label-{{color}} js-select-label {{# if isLabelSelected ../card._id }}active{{/ if }}">
+ {{name}}
+ {{# if currentUser.isBoardAdmin }}
+ <span class="card-label-selectable-icon icon-sm fa fa-check light"></span>
+ {{/ if }}
+ </span>
+ </li>
+ {{/ each}}
+ </ul>
+ <a class="quiet-button full js-add-label">{{_ 'label-create'}}</a>
+ </div>
+</template>
+
+<template name="cardAttachmentsPopup">
+ <div>
+ <ul class="pop-over-list">
+ <li>
+ <input type="file" name="file" class="js-attach-file hide" multiple>
+ <a class="js-computer-upload" href="#">
+ {{_ 'computer'}}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
+
+<template name="formLabel">
+ <div class="colors clearfix">
+ <label for="labelName">{{_ 'name'}}</label>
+ <input id="labelName" type="text" name="name" class="js-label-name" value='{{ name }}' autofocus>
+ <label>{{_ "select-color"}}</label>
+ {{# each labels }}
+ <span class="card-label card-label--selectable card-label-{{ color }} palette-color js-palette-color">
+ <span class="card-label-color-select-icon icon-sm fa fa-check light js-palette-select {{#if $neq color ../color}}hide{{/if}}"></span>
+ </span>
+ {{/each}}
+ </div>
+</template>
+
+<template name="createLabelPopup">
+ <form class="create-label">
+ {{#with color=defaultColor}}
+ {{> formLabel}}
+ {{/with}}
+ <input type="submit" class="primary wide left" value="{{_ 'create'}}">
+ </form>
+</template>
+
+<template name="editLabelPopup">
+ <form class="edit-label">
+ {{> formLabel}}
+ <input type="submit" class="primary wide left" value="{{_ 'save'}}">
+ <span class="right">
+ <input type="submit" value="{{_ 'delete'}}" class="negate js-delete-label">
+ </span>
+ </form>
+</template>
+
+<template name="deleteLabelPopup">
+ <p>{{_ "label-delete-pop"}}</p>
+ <input type="submit" class="js-confirm negate full" value="{{_ 'delete'}}">
+</template>
+
+<template name="cardDeletePopup">
+ <p>{{_ "card-delete-pop"}}</p>
+ <input type="submit" class="js-confirm negate full" value="{{_ 'delete'}}">
+</template>
+
+<template name="attachmentDeletePopup">
+ <p>{{_ "attachment-delete-pop"}}</p>
+ <input type="submit" class="js-confirm negate full" value="{{_ 'delete'}}">
+</template>
+
+<template name="cardDetailSidebarOld">
+ <div class="card-detail-window clearfix">
+ {{# if card.cover }}
+ <div class="window-cover js-card-cover-box js-open-card-cover-in-viewer has-cover" style="background-image: url({{ card.cover.url }}); background-color: rgb(119, 119, 119); background-size: contain;">
+ </div>
+ {{ /if }}
+ {{ #if card.archived }}
+ <div class="window-archive-banner js-archive-banner">
+ <span class="icon-lg fa fa-archive window-archive-banner-icon"></span>
+ <p class="window-archive-banner-text">{{_ "card-archived"}}</p>
+ </div>
+ {{ /if }}
+ <div class="window-header clearfix">
+ <span class="window-header-icon icon-lg fa fa-calendar-o"></span>
+ <div class="window-title card-detail-title non-empty inline {{# if currentUser.isBoardMember }}editable{{/ if }}">
+ <h2 class="window-title-text current hide-on-edit js-card-title">{{ card.title }}</h2>
+ <div class="edit edit-heavy">
+ <form id="WindowTitleEdit">
+ <textarea type="text" class="field single-line" id="title">{{ card.title }}</textarea>
+ <div class="edit-controls clearfix">
+ <input type="submit" class="primary confirm js-title-save-edit" value="{{_ 'save'}}">
+ <a href="#" class="icon-lg fa fa-times dark-hover cancel js-cancel-edit"></a>
+ </div>
+ </form>
+ </div>
+ <div class="quiet hide-on-edit window-header-inline-content js-current-list">
+ <p class="inline-block bottom">
+ {{_ 'in-list'}}
+ <a href="#" class="{{# if currentUser.isBoardMember }}js-open-move-from-header{{else}}disabled{{/ if }}"><strong>{{ card.list.title }}</strong></a>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="window-main-col clearfix">
+ <div class="card-detail-data gutter clearfix">
+ <div class="card-detail-item card-detail-item-block clear clearfix editable">
+ {{# if card.members }}
+ <div class="card-detail-item card-detail-item-members clearfix js-card-detail-members">
+ <h3 class="card-detail-item-header">{{_ 'members'}}</h3>
+ <div class="js-card-detail-members-list clearfix">
+ {{# each card.members }}
+ {{> userAvatar userId=this size="small" cardId=../card._id }}
+ {{/ each }}
+ <a class="card-detail-item-add-button dark-hover js-details-edit-members">
+ <span class="icon-sm fa fa-plus"></span>
+ </a>
+ </div>
+ </div>
+ {{/ if }}
+ {{# if card.labels }}
+ <div class="card-detail-item card-detail-item-labels clearfix js-card-detail-labels">
+ <h3 class="card-detail-item-header">{{_ 'labels'}}</h3>
+ <div class="js-card-detail-labels-list clearfix editable-labels js-edit-label">
+ {{# each card.labels }}
+ <span class="card-label card-label-{{color}}" title="{{name}}">{{ name }}</span>
+ {{/ each }}
+ <a class="card-detail-item-add-button dark-hover js-details-edit-labels">
+ <span class="icon-sm fa fa-plus"></span>
+ </a>
+ </div>
+ </div>
+ {{/ if }}
+ <div class="card-detail-item card-detail-item-block clear clearfix editable" attr="desc">
+ {{# if card.description }}
+ <h3 class="card-detail-item-header js-show-with-desc">{{_ 'description'}}</h3>
+ {{# if currentUser.isBoardMember }}
+ <a href="#" class="card-detail-item-header-edit hide-on-edit js-show-with-desc js-edit-desc">{{_ 'edit'}}</a>
+ {{/ if }}
+ <div class="current markeddown hide-on-edit js-card-desc js-show-with-desc">
+ {{#viewer}}{{ card.description }}{{/viewer}}
+ </div>
+ {{ else }}
+ {{# if currentUser.isBoardMember }}
+ <p class="bottom">
+ <a href="#" class="hide-on-edit quiet-button w-img js-edit-desc js-hide-with-desc">
+ <span class="icon-sm fa fa-align-left"></span>
+ {{_ 'edit-description'}}
+ </a>
+ </p>
+ {{/ if }}
+ {{/ if }}
+ <div class="card-detail-edit edit">
+ <form id="WindowDescEdit">
+ {{#editor class="field single-line2" id="desc"}}{{ card.description }}{{/editor}}
+ <div class="edit-controls clearfix">
+ <input type="submit" class="primary confirm js-title-save-edit" value="{{_ 'save'}}">
+ <a href="#" class="icon-lg fa fa-times dark-hover cancel js-cancel-edit"></a>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ {{# if card.attachments.count }}
+ {{ > WindowAttachmentsModule card=card }}
+ {{/ if}}
+ {{ > WindowActivityModule card=card }}
+ </div>
+ {{# if currentUser.isBoardMember }}
+ {{ > WindowSidebarModule card=card }}
+ {{/if}}
+ </div>
+</template>
+
+<template name="WindowActivityModule">
+ <div class="card-detailwindow-module">
+ <div class="window-module-title window-module-title-no-divider">
+ <span class="window-module-title-icon icon-lg fa fa-comments-o"></span>
+ <h3>{{ _ 'activity'}}</h3>
+ </div>
+ {{# if currentUser.isBoardMember }}
+ <div class="new-comment js-new-comment">
+ {{> userAvatar user=currentUser size="small" class="member-no-menu" }}
+ <form id="CommentForm">
+ {{#editor class="new-comment-input js-new-comment-input"}}{{/editor}}
+ <div class="add-controls clearfix">
+ <input type="submit" class="primary confirm clear js-add-comment" value="{{_ 'comment'}}" tabindex="2">
+ </div>
+ </form>
+ </div>
+ {{/ if }}
+ {{ > activities mode="card" }}
+ </div>
+</template>
+
+<template name="WindowAttachmentsModule">
+ <div class="window-module js-attachments-section clearfix">
+ <div class="window-module-title window-module-title-no-divider">
+ <span class="window-module-title-icon icon-lg fa fa-paperclip"></span>
+ <h3 class="inline-block">{{_ 'attachments'}}</h3>
+ </div>
+ <div class="gutter">
+ <div class="clearfix js-attachment-list">
+ {{# each card.attachments }}
+ <div class="attachment-thumbnail">
+ {{# if isUploaded }}
+ <a href="{{ url download=true }}" class="attachment-thumbnail-preview js-open-viewer attachment-thumbnail-preview-is-cover">
+ {{# if isImage }}
+ <img src="{{ url }}">
+ {{ else }}
+ <span class="attachment-thumbnail-preview-ext">{{ extension }}</span>
+ {{ /if }}
+ </a>
+ <p class="attachment-thumbnail-details js-open-viewer">
+ <a href="" class="attachment-thumbnail-details-title js-attachment-thumbnail-details">
+ {{ name }}
+ <span class="block quiet">
+ {{_ 'added'}} <span class="date">{{ moment uploadedAt }}</span>
+ </span>
+ </a>
+ <span class="quiet attachment-thumbnail-details-options">
+ <a href="{{ url download=true }}" class="attachment-thumbnail-details-options-item dark-hover js-download">
+ <span class="icon-sm fa fa-download"></span>
+ <span class="attachment-thumbnail-details-options-item-text">{{_ 'download'}}</span>
+ </a>
+ {{# if isImage }}
+ <a class="attachment-thumbnail-details-options-item dark-hover {{#if $eq ../card.coverId _id}}js-remove-cover{{else}}js-add-cover{{/if}}">
+ <span class="icon-sm fa fa-thumb-tack"></span>
+ <span class="attachment-thumbnail-details-options-item-text">{{#if $eq ../card.coverId _id}}{{_ 'remove-cover'}}{{else}}{{_ 'add-cover'}}{{/if}}</span>
+ </a>
+ {{/if}}
+ <a href="#" class="attachment-thumbnail-details-options-item attachment-thumbnail-details-options-item-delete dark-hover js-confirm-delete">
+ <span class="icon-sm fa fa-close"></span>
+ <span class="attachment-thumbnail-details-options-item-text">{{_ 'delete'}}</span>
+ </a>
+ </span>
+ </p>
+ {{ else }}
+ +spinner
+ {{/ if }}
+ </div>
+ {{/each}}
+ </div>
+ <p>
+ <a href="#" class="quiet-button js-attach">{{_ 'add-attachment' }}</a>
+ </p>
+ </div>
+ </div>
+</template>
+
+<template name="WindowSidebarModule">
+ <div class="window-sidebar" style="position: relative;">
+ <div class="window-module clearfix">
+ <h3>{{_ 'add'}}</h3>
+ <div class="clearfix">
+ <a href="#" class="button-link js-change-card-members" title="{{_ 'members-title'}}">
+ <span class="icon-sm fa fa-user"></span> {{_ 'members'}}
+ </a>
+ <a href="#" class="button-link js-edit-labels" title="{{_ 'labels-title'}}">
+ <span class="icon-sm fa fa-tags"></span> {{_ 'labels'}}
+ </a>
+ <a href="#" class="button-link js-attach" title="{{_ 'attachment-title'}}">
+ <span class="icon-sm fa fa-paperclip"></span> {{_ 'attachment'}}
+ </a>
+ </div>
+ </div>
+ <div class="window-module other-actions clearfix">
+ <h3>{{_ 'actions'}}</h3>
+ <div class="clearfix">
+ <hr>
+ {{ #if card.archived }}
+ <a href="#" class="button-link js-unarchive-card" title="{{_ 'send-to-board-title'}}">
+ <span class="icon-sm fa fa-recycle"></span> {{_ 'send-to-board'}}
+ </a>
+ <a href="#" class="button-link negate js-delete-card" title="{{_ 'delete-title'}}">
+ <span class="icon-sm fa fa-trash-o"></span> {{_ 'delete'}}
+ </a>
+ {{ else }}
+ <a href="#" class="button-link js-archive-card" title="{{_ 'archive-title'}}">
+ <span class="icon-sm fa fa-archive"></span> {{_ 'archive'}}
+ </a>
+ {{ /if }}
+ </div>
+ </div>
+ <div class="window-module clearfix">
+ <p class="quiet bottom">
+ <a href="#" class="quiet-button js-more-menu" title="{{_ 'share-and-more-title'}}">{{_ 'share-and-more'}}</a>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/client/components/forms/cachedValue.js b/client/components/forms/cachedValue.js
new file mode 100644
index 00000000..a2898d85
--- /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 00000000..1084a4a6
--- /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 00000000..5ad9039e
--- /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 00000000..2e2b2eba
--- /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 00000000..0e8efeeb
--- /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 00000000..fa6ec096
--- /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 00000000..f636de75
--- /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 00000000..5196af5d
--- /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 00000000..014cfd80
--- /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 00000000..dd4bb49a
--- /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 00000000..3d458055
--- /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 00000000..18484174
--- /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 00000000..ff7820a4
--- /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 00000000..ef08cf76
--- /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 00000000..beb90c5e
--- /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 00000000..588c9b6e
--- /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 00000000..2a545309
--- /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 00000000..1177d930
--- /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 00000000..7ad602f1
--- /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: '&commat;', 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 00000000..18df4d9e
--- /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 00000000..53695d10
--- /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 00000000..8c9993af
--- /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 00000000..ba24db0a
--- /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 00000000..787e8225
--- /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 = '<img src="' + Emoji.baseImagePath + value + '.png"></img>';
+ 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 00000000..bae832e8
--- /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 00000000..f4b8cc86
--- /dev/null
+++ b/client/components/main/spinner.styl
@@ -0,0 +1,45 @@
+/*
+ * From https://github.com/tobiasahlin/SpinKit
+ *
+ * Usage:
+ *
+ * <div class="sk-spinner sk-spinner-wave">
+ * <div class="sk-rect1"></div>
+ * <div class="sk-rect2"></div>
+ * <div class="sk-rect3"></div>
+ * <div class="sk-rect4"></div>
+ * <div class="sk-rect5"></div>
+ * </div>
+ *
+ */
+
+.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 00000000..9310a6e5
--- /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 00000000..e9be0f93
--- /dev/null
+++ b/client/components/main/templates.html
@@ -0,0 +1,18 @@
+<template name="notfound">
+ {{ > message label='page-not-found'}}
+</template>
+
+<template name='message'>
+ <div class="big-message quiet {{ color }}">
+ <h1>{{_ label}}</h1>
+ {{#with pathFor route='Login'}}
+ <p>{{{_ 'page-maybe-private' this}}}</p>
+ {{/with}}
+ </div>
+</template>
+
+<template name="editor">
+ <textarea class="{{class}}" placeholder="{{_ 'comment-placeholder'}}" id="{{id}}" tabindex="1">{{> UI.contentBlock }}</textarea>
+</template>
+
+<template name="viewer">{{#markdown}}{{#emoji}}{{#mentions}}{{> UI.contentBlock }}{{/mentions}}{{/emoji}}{{/markdown}}</template>
diff --git a/client/components/modal/events.js b/client/components/modal/events.js
new file mode 100644
index 00000000..2943f841
--- /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 00000000..e69de29b
--- /dev/null
+++ b/client/components/modal/helpers.js
diff --git a/client/components/modal/modal.tpl.jade b/client/components/modal/modal.tpl.jade
new file mode 100644
index 00000000..2f40ad75
--- /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 00000000..1067421f
--- /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 00000000..a76dad7f
--- /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 00000000..df3b8901
--- /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 00000000..2b58bf33
--- /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 00000000..3f0142d4
--- /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 00000000..4b741dc7
--- /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 00000000..d8b063f0
--- /dev/null
+++ b/client/components/sidebar/templates.html.old
@@ -0,0 +1,307 @@
+<template name="boardWidgets">
+ <a href="#" class="sidebar-show-btn dark-hover js-show-sidebar">
+ <span class="icon-sm fa fa-chevron-left"></span>
+ <span class="text">{{_ 'show-sidebar'}}</span>
+ </a>
+ <div class="board-widgets {{#if session 'sidebarIsOpen'}}show{{else}}hide{{/if}}">
+ <div>
+ <a href="#" class="sidebar-hide-btn dark-hover js-hide-sidebar" title="{{_ 'close-sidebar-title'}}">
+ <span class="icon-sm fa fa-chevron-right"></span>
+ </a>
+ {{#unless isTrue currentWidget "homeWidget"}}
+ <div class="board-widgets-title clearfix">
+ <a href="#" class="board-sidebar-back-btn js-pop-widget-view">
+ <span class="left-arrow"></span>{{_ 'back'}}
+ </a>
+ <h3 class="text">{{currentWidgetTitle}}</h3>
+ <hr>
+ </div>
+ {{/unless}}
+ <div class="board-widgets-content-wrapper">
+ <div class="board-widgets-content default fancy-scrollbar short{{#unless session 'menuWidgetIsOpen'}} short{{/unless}}">
+ {{> UI.dynamic template=currentWidget data=this }}
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<template name="homeWidget">
+{{ > menuWidget }}
+{{ > membersWidget }}
+{{ > activityWidget }}
+</template>
+
+<template name="menuWidget">
+ <div class="board-widget board-widget-nav clearfix{{#unless session 'menuWidgetIsOpen'}} collapsed{{/unless}}">
+ <h3 class="dark-hover toggle-widget-nav js-toggle-widget-nav">{{_ 'menu'}}
+ <span class="icon-sm fa fa-chevron-circle-down toggle-menu-icon"></span>
+ </h3>
+ <ul class="nav-list">
+ <hr style="margin-top: 0;">
+ <li>
+ <a href="#" class="nav-list-item js-open-archive">
+ <span class="icon-sm fa fa-archive icon-type"></span>
+ {{_ 'archived-items'}}
+ </a>
+ </li>
+ <li>
+ <a href="#" class="nav-list-item js-open-card-filter">
+ <span class="icon-sm fa fa-filter icon-type"></span>
+ {{_ 'filter-cards'}}
+ </a>
+ </li>
+ {{#if currentUser.isBoardAdmin}}
+ <hr>
+ <li>
+ <a class="nav-list-item nav-list-sub-item board-settings-background js-change-background">
+ <span class="board-settings-background-preview" style="background-color:{{board.background.color}}"></span>
+ {{_ 'change-background'}}…
+ </a>
+ </li>
+ {{#unless isSandstorm }}
+ <li>
+ <a class="nav-list-item nav-list-sub-item js-close-board" href="#">{{_ 'close-board'}}</a>
+ </li>
+ {{/unless}}
+ {{/if}}
+ {{!
+ XXX Language should be handled by sandstorm, but for now display a language selection link in the board menu.
+ This link is normally present in the header bar that is not displayed on sandstorm.
+ }}
+ {{#if isSandstorm}}
+ <hr>
+ <li>
+ <a class="nav-list-item nav-list-sub-item js-language">{{_ 'language'}}</a>
+ </li>
+ {{/if}}
+ </ul>
+ </div>
+</template>
+
+<template name="membersWidget">
+ <hr>
+ <div class="board-widget board-widget-members clearfix">
+ <div class="board-widget-title">
+ <h3>{{_ 'members'}}</h3>
+ </div>
+ <div class="board-widget-content">
+ <div class="board-widget-members js-list-board-members clearfix js-list-draggable-board-members">
+ {{# each board.members }}
+ {{> userAvatar userId=this.userId draggable=true size="small" showBadges=true}}
+ {{/ each }}
+ </div>
+ {{# unless isSandstrom }}
+ {{# if currentUser.isBoardAdmin }}
+ <a href="#" class="button-link js-open-manage-board-members">
+ <span class="icon-sm fa fa-user"></span> {{_ 'add-members'}}
+ </a>
+ {{/ if }}
+ {{/ unless }}
+ </div>
+ </div>
+</template>
+
+<template name="activityWidget">
+ {{# if board.activities.count }}
+ <hr>
+ <div class="board-widget board-widget-activity bottom clearfix">
+ <div class="board-widget-title">
+ <h3>{{_ 'activity'}}</h3>
+ </div>
+ <div class="board-widget-content">
+ <div class="activity-gradient-t"></div>
+ <div class="activity-gradient-b"></div>
+ <div class="board-actions-list fancy-scrollbar">
+ {{ > activities }}
+ </div>
+ </div>
+ </div>
+ {{/if}}
+</template>
+
+<template name="memberPopup">
+ <div class="board-member-menu">
+ <div class="mini-profile-info">
+ {{> userAvatar user=user}}
+ <div class="info">
+ <h3 class="bottom" style="margin-right: 40px;">
+ <a class="js-profile" href="{{ pathFor route='Profile' username=user.username }}">{{ user.profile.name }}</a>
+ </h3>
+ <p class="quiet bottom">@{{ user.username }}</p>
+ </div>
+ </div>
+ {{# if currentUser.isBoardMember }}
+ <ul class="pop-over-list">
+ {{# if currentUser.isBoardAdmin }}
+ <li>
+ <a class="js-change-role" href="#">
+ {{_ 'change-permissions'}} <span class="quiet" style="font-weight: normal;">({{ memberType }})</span>
+ </a>
+ </li>
+ {{/ if }}
+
+ <li>
+ {{# if currentUser.isBoardAdmin }}
+ <a class="js-remove-member">{{_ 'remove-from-board'}}</a>
+ {{ else }}
+ <a class="js-leave-member">{{_ 'leave-board'}}</a>
+ {{/ if }}
+ </li>
+ </ul>
+ {{/ if }}
+ </div>
+</template>
+
+<template name="filterWidget">
+ <ul class="pop-over-label-list checkable">
+ {{#each board.labels}}
+ <li class="item matches-filter">
+ <a class="name js-toggle-label-filter">
+ <span class="card-label card-label-{{color}}"></span>
+ <span class="full-name">
+ {{#if name}}
+ {{name}}
+ {{else}}
+ <span class="quiet">{{_ "label-default" color}}</span>
+ {{/if}}
+ </span>
+ {{#if Filter.labelIds.isSelected _id}}
+ <span class="icon-sm fa fa-check"></span>
+ {{/if}}
+ </a>
+ </li>
+ {{/each}}
+ </ul>
+ <hr>
+ <ul class="pop-over-member-list checkable">
+ {{#each board.members}}
+ {{#with getUser userId}}
+ <li class="item js-member-item {{#if Filter.members.isSelected _id}}active{{/if}}">
+ <a href="#" class="name js-toogle-member-filter">
+ {{> userAvatar user=this size="small" }}
+ <span class="full-name">
+ {{ profile.name }}
+ (<span class="username">{{ username }}</span>)
+ </span>
+ {{#if Filter.members.isSelected _id}}
+ <span class="icon-sm fa fa-check checked-icon"></span>
+ {{/if}}
+ </a>
+ </li>
+ {{/with}}
+ {{/each}}
+ </ul>
+ <hr>
+ <ul class="pop-over-list inset normal-weight">
+ <li>
+ <a class="js-clear-all {{#unless Filter.isActive}}disabled{{/unless}}" style="padding-left: 40px;">
+ {{_ 'filter-clear'}}
+ </a>
+ </li>
+ </ul>
+</template>
+
+<template name="backgroundWidget">
+ <div class="board-widgets-content-wrapper fancy-scrollbar">
+ <div class="board-widgets-content">
+ <div class="board-backgrounds-list clearfix">
+ {{#each backgroundColors}}
+ <div class="board-background-select js-select-background">
+ <span class="background-box " style="background-color: {{this}}; "></span>
+ </div>
+ {{/each}}
+ </div>
+ {{!--
+ <h2 class="clear">Photos</h2>
+ <div class="board-backgrounds-list relative clearfix js-gold-photos-list disabled">
+ <div class="board-background-select js-select-background">
+ <span class="background-box " style="background-image: url(&quot;{{url}}&quot;);">
+ <a class="background-option js-background-attribution" href={{href}} target="_blank" title={{title}}>
+ <img src="https://d78fikflryjgj.cloudfront.net/images/d906fe5c1274c56c5571d49705547587/cc.png" style="height: 14px; width: 14px; vertical-align: text-top;" title="http://creativecommons.org/licenses/by/2.0/deed.en">
+ <span class="text" style="margin-left: 2px;">{{author}}</span>
+ </a>
+ </span>
+ </div>
+ </div>
+ --}}
+ </div>
+ </div>
+</template>
+
+<template name="closeBoardPopup">
+ <p>{{_ 'close-board-pop'}}</p>
+ <input type="submit" class="js-confirm negate full" value="{{_ 'close'}}">
+</template>
+
+<template name="removeMemberPopup">
+ <p>{{_ 'remove-member-pop'
+ name=user.profile.name
+ username=user.username
+ boardTitle=board.title}}</p>
+ <input type="submit" class="js-confirm negate full" value="{{_ 'remove-member'}}">
+</template>
+
+<template name="addMemberPopup">
+ <div class="search-with-spinner">
+ {{> esInput index="users" }}
+ </div>
+
+ <div class="manage-member-section hide js-search-results" style="display: block;">
+ <ul class="pop-over-member-list options js-list">
+ {{# esEach index="users"}}
+ <li class="item js-member-item {{# if isBoardMember }}disabled{{/if}}">
+ <a href="#" class="name js-select-member {{# if isBoardMember }}multi-line{{/if}}" title="{{ profile.name }} ({{ username }})">
+ {{> userAvatar user=this size="small" }}
+ <span class="full-name">
+ {{ profile.name }} (<span class="username">{{ username }}</span>)
+ </span>
+ {{# if isBoardMember }}
+ <div class="extra-text quiet">({{_ 'joined'}})</div>
+ {{/if}}
+ <span class="icon-sm fa fa-chevron-right light option js-open-option"></span>
+ </a>
+ </li>
+ {{/esEach }}
+ </ul>
+ </div>
+
+ {{# ifEsIsSearching index='users' }}
+ <div class="tac">
+ <span class="tabbed-pane-main-col-loading-spinner spinner"></span>
+ </div>
+ {{ /ifEsIsSearching }}
+
+ {{# ifEsHasNoResults index="users" }}
+ <div class="manage-member-section js-no-results">
+ <p class="quiet center" style="padding: 16px 4px;">{{_ 'no-results'}}</p>
+ </div>
+ {{ /ifEsHasNoResults }}
+
+ <div class="manage-member-section js-helper">
+ <p class="bottom quiet" style="padding: 6px;">{{_ 'search-member-desc'}}</p>
+ </div>
+</template>
+
+<template name="changePermissionsPopup">
+ <ul class="pop-over-list">
+ <li>
+ <a class="{{#if isLastAdmin}}disabled{{else}}js-set-admin{{/if}}">
+ {{_ 'admin'}}
+ {{#if isAdmin}}<span class="icon-sm fa fa-check"></span>{{/if}}
+ <span class="sub-name">{{_ 'admin-desc'}}</span>
+ </a>
+ </li>
+ <li>
+ <a class="{{#if isLastAdmin}}disabled{{else}}js-set-normal{{/if}}">
+ {{_ 'normal'}}
+ {{#unless isAdmin}}<span class="icon-sm fa fa-check"></span>{{/unless}}
+ <span class="sub-name">{{_ 'normal-desc'}}</span>
+ </a>
+ </li>
+ </ul>
+ {{#if isLastAdmin}}
+ <hr>
+ <p class="quiet bottom">{{_ 'last-admin-desc'}}</p>
+ {{/if}}
+</template>
diff --git a/client/components/sidebar/templates.jade b/client/components/sidebar/templates.jade
new file mode 100644
index 00000000..23a1a87e
--- /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
+ | (<span class="username">{{ username }}</span>)
+ 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 00000000..70ef69e0
--- /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 00000000..14df9717
--- /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 00000000..845c810d
--- /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 00000000..74c24ad5
--- /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 00000000..70594fb5
--- /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 00000000..33867298
--- /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 00000000..3dfdaa37
--- /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 00000000..d59e174d
--- /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 00000000..5783eebf
--- /dev/null
+++ b/client/components/users/templates.html
@@ -0,0 +1,118 @@
+<template name="setLanguagePopup">
+<ul class="pop-over-list">
+ {{#each languages}}
+ <li class="{{# if isCurrentLanguage}}active{{/if}}">
+ <a class="js-set-language">
+ {{name}}
+ {{# if isCurrentLanguage}}
+ <span class="icon-sm fa fa-check"></span>
+ {{/if}}
+ </a>
+ </li>
+ {{/each}}
+</ul>
+</template>
+
+<template name='profile'>
+ {{ # if profile }}
+ <div class="tabbed-pane-header">
+ <div class="tabbed-pane-header-wrapper clearfix">
+ <a class="tabbed-pane-header-image profile-image ed js-change-avatar-profile" href="#">
+ {{> userAvatar user=profile size="large"}}
+ </a>
+ <div class="tabbed-pane-header-details">
+ <div class="js-current-details">
+ <div class="tabbed-pane-header-details-name">
+ <h1 class="inline"> {{ profile.profile.name }} </h1>
+ <p class="window-title-extra quiet"> @{{ profile.username }} </p>
+ </div>
+ <div class="tabbed-pane-header-details-content">
+ <p>{{ profile.profile.bio }}</p>
+ </div>
+ <div class="tabbed-pane-header-details-content"></div>
+ </div>
+ {{ > profileEditForm }}
+ </div>
+ </div>
+ </div>
+ {{ else }}
+ {{ > message label='user-profile-not-found' }}
+ {{ /if }}
+</template>
+
+<template name="settings">
+ {{ > profile profile=currentUser }}
+ <div class="tabbed-pane-main-col clearfix">
+ <div class="tabbed-pane-main-col-loading hide js-loading-page">
+ <span class="tabbed-pane-main-col-loading-spinner spinner"></span>
+ </div>
+ <div class="tabbed-pane-main-col-wrapper js-content">
+ <div class="window-module clearfix">
+ <div class="window-module-title">
+ <h3>{{_ "account-details"}}</h3>
+ </div>
+ <a class="big-link js-change-name-and-bio" href="#">
+ <span class="text">{{_ 'change-name-initials-bio'}}</span>
+ </a>
+ <a class="big-link js-change-avatar" href="#">
+ <span class="text">{{_ 'change-avatar'}}</span>
+ </a>
+ <a class="big-link js-change-password" href="#">
+ <span class="text">{{_ 'change-password'}}</span>
+ </a>
+ <a class="big-link js-change-email" href="#">
+ <span class="text">{{_ 'change-email'}}</span>
+ </a>
+ </div>
+ </div>
+ </div>
+</template>
+
+<template name="profileEditForm">
+ {{#if $eq currentUser.username profile.username }}
+ {{# if session 'ProfileEditForm' }}
+ <form id="ProfileEditForm" class="js-profile-form">
+ <p class="error js-profile-form-error hide"></p>
+ <label>{{_ "username"}}</label>
+ <input type="text" id="username" value="{{ profile.username }}" disabled>
+ <label>{{_ "fullname"}}</label>
+ <input type="text" id="name" value="{{ profile.profile.name }}">
+ <label>
+ {{_ "bio"}} <span class="quiet">({{_ 'optional'}})</span>
+ </label>
+ <textarea id="bio">{{ profile.profile.bio }}</textarea>
+ <input type="submit" class="primary wide js-submit-profile" value="{{_ 'save'}}">
+ <input type="button" class="js-cancel-edit-profile" value="{{_ 'cancel'}}">
+ </form>
+ {{ else }}
+ <a class="button-link tabbed-pane-header-details-edit js-edit-profile" href="#">
+ <span class="icon-sm fa fa-pencil"></span>
+ {{_ "edit-profile"}}
+ </a>
+ {{ /if }}
+ {{ /if }}
+</template>
+
+<template name="userPopup">
+ <div class="board-member-menu">
+ <div class="mini-profile-info">
+ {{> userAvatar user=user}}
+ <div class="info">
+ <h3 class="bottom" style="margin-right: 40px;">
+ <a class="js-profile" href="{{ pathFor route='Profile' username=user.username }}">{{ user.profile.name }}</a>
+ </h3>
+ <p class="quiet bottom">@{{ user.username }}</p>
+ </div>
+ </div>
+ </div>
+</template>
+
+
+<template name="memberName">
+ <a class="inline-object js-show-mem-menu" href="{{ pathFor route='Profile' username=user.username }}">
+ {{ user.profile.name }}
+ {{# if username }}
+ ({{ user.username }})
+ {{ /if }}
+ </a>
+</template>
diff --git a/client/config/accounts.js b/client/config/accounts.js
new file mode 100644
index 00000000..9e0d17d3
--- /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 00000000..fc4ba58b
--- /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 00000000..c859013f
--- /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 00000000..1f07ac62
--- /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 00000000..507a2bb7
--- /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 00000000..7d7e3ebb
--- /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 00000000..c1267938
--- /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 00000000..8d16be53
--- /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 00000000..dd2a43b0
--- /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 00000000..9e92e999
--- /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 00000000..9d881b44
--- /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 00000000..c7a30018
--- /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 00000000..0f12e35e
--- /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 00000000..9dab7802
--- /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 00000000..1e24cf7c
--- /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 00000000..c8fe6b18
--- /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 00000000..e406b10c
--- /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 00000000..538b6af4
--- /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 00000000..196477ec
--- /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 00000000..1dcccf12
--- /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 00000000..675d4ee7
--- /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 <strong>öffentlich</strong> 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 <a href='%s'>einloggst</a>.",
+ "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 00000000..e1c90273
--- /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 <strong>public</strong>.",
+ "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 <a href='%s'>logging in</a>.",
+ "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 00000000..a34360b4
--- /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 <strong>public</strong>.",
+ "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 <a href='%s'>connectant</a>.",
+ "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 00000000..d914f118
--- /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": "このページはプライベートです。<a href='%s'>ログイン</a>して見てください。",
+ "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 00000000..128e18c1
--- /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á <strong>público</strong>.",
+ "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 <a href='%s'>logado</a>.",
+ "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 00000000..fabf7ac0
--- /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 <strong>genel</strong>e 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. <a href='%s'>Oturum açarak</a> 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 00000000..cba08bed
--- /dev/null
+++ b/public/favicon.png
Binary files differ
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 00000000..6fea7167
--- /dev/null
+++ b/public/logo.png
Binary files differ
diff --git a/sandstorm-pkgdef.capnp b/sandstorm-pkgdef.capnp
new file mode 100644
index 00000000..51bede4c
--- /dev/null
+++ b/sandstorm-pkgdef.capnp
@@ -0,0 +1,61 @@
+@0xa5275bd3ad124e12;
+
+using Spk = import "/sandstorm/package.capnp";
+# This imports:
+# $SANDSTORM_HOME/latest/usr/include/sandstorm/package.capnp
+# Check out that file to see the full, documented package definition format.
+
+const pkgdef :Spk.PackageDefinition = (
+ # The package definition. Note that the spk tool looks specifically for the
+ # "pkgdef" constant.
+
+ id = "m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h",
+ # Your app ID is actually its public key. The private key was placed in your
+ # keyring. All updates must be signed with the same key.
+
+ manifest = (
+ # This manifest is included in your app package to tell Sandstorm
+ # about your app.
+
+ appVersion = 1, # Increment this for every release.
+
+ actions = [
+ # Define your "new document" handlers here.
+ ( title = (defaultText = "New board"),
+ command = .myCommand
+ # The command to run when starting for the first time. (".myCommand" is
+ # just a constant defined at the bottom of the file.)
+ )
+ ],
+
+ continueCommand = .myCommand
+ # This is the command called to start your app back up after it has been
+ # shut down for inactivity. Here we're using the same command as for
+ # starting a new instance, but you could use different commands for each
+ # case.
+ ),
+
+ sourceMap = (
+ # The following directories will be copied into your package.
+ searchPath = [
+ ( sourcePath = ".meteor-spk/deps" ),
+ ( sourcePath = ".meteor-spk/bundle" )
+ ]
+ ),
+
+ alwaysInclude = [ "." ]
+ # This says that we always want to include all files from the source map. (An
+ # alternative is to automatically detect dependencies by watching what the app
+ # opens while running in dev mode. To see what that looks like, run `spk init`
+ # without the -A option.)
+);
+
+const myCommand :Spk.Manifest.Command = (
+ # Here we define the command used to start up your server.
+ argv = ["/sandstorm-http-bridge", "4000", "--", "node", "start.js"],
+ environ = [
+ # Note that this defines the *entire* environment seen by your app.
+ (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"),
+ (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}")
+ ]
+);
diff --git a/sandstorm.js b/sandstorm.js
new file mode 100644
index 00000000..5319ee60
--- /dev/null
+++ b/sandstorm.js
@@ -0,0 +1,94 @@
+// Sandstorm context is detected using the METEOR_SETTINGS environment variable
+// in the package definition.
+var isSandstorm = Meteor.settings && Meteor.settings.public &&
+ Meteor.settings.public.sandstorm;
+
+// In sandstorm we only have one board per sandstorm instance. Since we want to
+// keep most of our code unchanged, we simply hard-code a board `_id` and
+// redirect the user to this particular board.
+var sandstormBoard = {
+ _id: 'sandstorm',
+ slug: 'board',
+
+ // XXX Should be shared with the grain instance name.
+ title: 'LibreBoard',
+ permission: 'public',
+ background: {
+ type: 'color',
+ color: '#16A085'
+ },
+
+ // XXX Not certain this is a bug, but we except these fields to get inserted
+ // by the `Lists.before.insert` collection-hook. Since this hook is not called
+ // in this case, we have to duplicate the logic and set them here.
+ archived: false,
+ createdAt: new Date()
+};
+
+// On the first launch of the instance a user is automatically created thanks to
+// the `accounts-sandstorm` package. After its creation we insert the unique
+// board document. Note that when the `Users.after.insert` hook is called, the
+// user is inserted into the database but not connected. So despite the
+// appearances `userId` is null in this block.
+//
+// If the hard-coded board already exists and we are inserting a new user, we
+// assume that the owner of the board want to share write privileges with the
+// new user.
+// XXX Improve that when the Sandstorm sharing model (“Powerbox”) arrives.
+if (isSandstorm && Meteor.isServer) {
+ Users.after.insert(function(userId, doc) {
+ if (! Boards.findOne(sandstormBoard._id)) {
+ Boards.insert(_.extend(sandstormBoard, { userId: doc._id }));
+ Boards.update(sandstormBoard._id, {
+ $set: {
+ 'members.0.userId': doc._id
+ }
+ });
+ Activities.update({
+ activityTypeId: sandstormBoard._id
+ }, {
+ $set: {
+ userId: doc._id
+ }
+ });
+ } else {
+ Boards.update({
+ _id: sandstormBoard._id,
+ permission: 'public'
+ }, {
+ $push: {
+ members: doc._id
+ }
+ });
+ }
+ });
+}
+
+// On the client, redirect the user to the hard-coded board. On the first launch
+// the user will be redirected to the board before its creation. But that’s not
+// a problem thanks to the reactive board publication.
+if (isSandstorm && Meteor.isClient) {
+ Router.go('Board', {
+ boardId: sandstormBoard._id,
+ slug: getSlug(sandstormBoard.title)
+ });
+
+ // XXX Hack. `Meteor.absoluteUrl` doesn't work in Sandstorm, since every
+ // session has a different URL whereas Meteor computes absoluteUrl based on
+ // the ROOT_URL environment variable. So we overwrite this function on a
+ // sandstorm client to return relative paths instead of absolutes.
+ var _absoluteUrl = Meteor.absoluteUrl;
+ var _defaultOptions = Meteor.absoluteUrl.defaultOptions;
+ Meteor.absoluteUrl = function(path, options) {
+ var url = _absoluteUrl(path, options);
+ return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, '');
+ };
+ Meteor.absoluteUrl.defaultOptions = _defaultOptions;
+}
+
+// We use this blaze helper in the UI to hide some template that does not make
+// sense in the context of sandstorm, like board staring, board archiving, user
+// name edition, etc.
+Blaze.registerHelper('isSandstorm', function() {
+ return isSandstorm;
+});
diff --git a/server/lib/utils.js b/server/lib/utils.js
new file mode 100644
index 00000000..56c87c0b
--- /dev/null
+++ b/server/lib/utils.js
@@ -0,0 +1,8 @@
+allowIsBoardAdmin = function(userId, board) {
+ var admins = _.pluck(_.where(board.members, {isAdmin: true}), 'userId');
+ return _.contains(admins, userId);
+};
+
+allowIsBoardMember = function(userId, board) {
+ return _.contains(_.pluck(board.members, 'userId'), userId);
+};
diff --git a/server/migrations.js b/server/migrations.js
new file mode 100644
index 00000000..d7ff5b53
--- /dev/null
+++ b/server/migrations.js
@@ -0,0 +1,113 @@
+// Anytime you change the schema of one of the collection in a non-backward
+// compatible way you have to write a migration in this file using the following
+// API:
+//
+// Migrations.add(name, migrationCallback, optionalOrder);
+
+Migrations.add('board-background-color', function() {
+ var defaultColor = '#16A085';
+ Boards.update({
+ background: {
+ $exists: false
+ }
+ }, {
+ $set: {
+ background: {
+ type: 'color',
+ color: defaultColor
+ }
+ }
+ }, {
+ multi: true
+ });
+});
+
+Migrations.add('lowercase-board-permission', function() {
+ _.forEach(['Public', 'Private'], function(permission) {
+ Boards.update(
+ { permission: permission },
+ { $set: { permission: permission.toLowerCase() } },
+ { multi: true }
+ );
+ });
+});
+
+// Security migration: see https://github.com/libreboard/libreboard/issues/99
+Migrations.add('change-attachments-type-for-non-images', function() {
+ var newTypeForNonImage = 'application/octet-stream';
+ Attachments.find().forEach(function(file) {
+ if (! file.isImage()) {
+ Attachments.update(file._id, {
+ $set: {
+ 'original.type': newTypeForNonImage,
+ 'copies.attachments.type': newTypeForNonImage
+ }
+ });
+ }
+ });
+});
+
+Migrations.add('card-covers', function() {
+ Cards.find().forEach(function(card) {
+ var cover = Attachments.findOne({ cardId: card._id, cover: true });
+ if (cover) {
+ Cards.update(card._id, {$set: {coverId: cover._id}});
+ }
+ });
+ Attachments.update({}, {$unset: {cover: ''}}, {multi: true});
+});
+
+Migrations.add('use-css-class-for-boards-colors', function() {
+ var associationTable = {
+ '#27AE60': 'nephritis',
+ '#C0392B': 'pomegranate',
+ '#2980B9': 'belize',
+ '#8E44AD': 'wisteria',
+ '#2C3E50': 'midnight',
+ '#E67E22': 'pumpkin'
+ };
+ Boards.find().forEach(function(board) {
+ var oldBoardColor = board.background.color;
+ var newBoardColor = associationTable[oldBoardColor];
+ Boards._collection.update({ _id: board._id }, {
+ $set: { color: newBoardColor },
+ $unset: { background: '' }
+ });
+ });
+});
+
+Migrations.add('denormalize-star-number-per-board', function() {
+ Boards.find().forEach(function(board) {
+ var nStars = Users.find({'profile.starredBoards': board._id}).count();
+ Boards.update(board._id, {$set: {stars: nStars}});
+ });
+});
+
+// We want to keep a trace of former members so we can efficiently publish their
+// infos in the general board publication.
+Migrations.add('add-member-isactive-field', function() {
+ Boards.find({}, {fields: {members: 1}}).forEach(function(board) {
+ var allUsersWithSomeActivity = _.chain(
+ Activities.find({boardId: board._id}, {fields:{userId:1}}).fetch())
+ .pluck('userId')
+ .uniq()
+ .value();
+ var currentUsers = _.pluck(board.members, 'userId');
+ var formerUsers = _.difference(allUsersWithSomeActivity, currentUsers);
+
+ var newMemberSet = [];
+ _.forEach(board.members, function(member) {
+ member.isActive = true;
+ newMemberSet.push(member);
+ });
+ _.forEach(formerUsers, function(userId) {
+ newMemberSet.push({
+ userId: userId,
+ isAdmin: false,
+ isActive: false
+ });
+ });
+ Boards._collection.update({_id: board._id},
+ {$set: {members: newMemberSet}});
+ });
+});
diff --git a/server/publications/activities.js b/server/publications/activities.js
new file mode 100644
index 00000000..5277206c
--- /dev/null
+++ b/server/publications/activities.js
@@ -0,0 +1,24 @@
+// We use activities fields at three different places:
+// 1. The home page that contains
+// 2. The board
+// 3.
+// We use publish paginate for these three publications.
+
+Meteor.publish('activities', function(mode, id, limit) {
+ check(mode, Match.Where(function(x) {
+ return ['board', 'card'].indexOf(x) !== -1;
+ }));
+ check(id, String);
+ check(limit, Number);
+
+ var selector = {};
+ if (mode === 'board')
+ selector.boardId = id;
+ else if (mode === 'card')
+ selector.cardId = id;
+
+ return Activities.find(selector, {
+ sort: {createdAt: -1},
+ limit: limit
+ });
+});
diff --git a/server/publications/boards.js b/server/publications/boards.js
new file mode 100644
index 00000000..0ff34750
--- /dev/null
+++ b/server/publications/boards.js
@@ -0,0 +1,121 @@
+// This is the publication used to display the board list. We publish all the
+// non-archived boards:
+// 1. that the user is a member of
+// 2. the user has starred
+Meteor.publish('boards', function() {
+ // Ensure that the user is connected
+ if (! Match.test(this.userId, String))
+ return;
+
+ // Defensive programming to verify that starredBoards has the expected
+ // format -- since the field is in the `profile` a user can modify it.
+ var starredBoards = Users.findOne(this.userId).profile.starredBoards || [];
+ check(starredBoards, [String]);
+
+ return Boards.find({
+ archived: false,
+ $or: [
+ { 'members.userId': this.userId },
+ { _id: { $in: starredBoards } }
+ ]
+ }, {
+ fields: {
+ _id: 1,
+ slug: 1,
+ title: 1,
+ color: 1,
+ permission: 1
+ }
+ });
+});
+
+Meteor.publishComposite('board', function(boardId, slug) {
+ check(boardId, String);
+ check(slug, String);
+ return {
+ find: function() {
+ return Boards.find({
+ _id: boardId,
+ slug: slug,
+ archived: false,
+ // If the board is not public the user has to be a member of it to see
+ // it.
+ $or: [
+ { permission: 'public' },
+ { 'members.userId': this.userId }
+ ]
+ }, { limit: 1 });
+ },
+ children: [
+ // Lists
+ {
+ find: function(board) {
+ return Lists.find({
+ boardId: board._id
+ });
+ }
+ },
+
+ // Cards and cards comments
+ // XXX Originally we were publishing the card documents as a child of the
+ // list publication defined above using the following selector `{ listId:
+ // list._id }`. But it was causing a race condition in publish-composite,
+ // that I documented here:
+ //
+ // https://github.com/englue/meteor-publish-composite/issues/29
+ //
+ // I then tried to replace publish-composite by cottz:publish, but it had
+ // a similar problem:
+ //
+ // https://github.com/Goluis/cottz-publish/issues/4
+ // https://github.com/libreboard/libreboard/pull/78
+ //
+ // The current state of relational publishing in meteor is a bit sad,
+ // there are a lot of various packages, with various APIs, some of them
+ // are unmaintained. Fortunately this is something that will be fixed by
+ // meteor-core at some point:
+ //
+ // https://trello.com/c/BGvIwkEa/48-easy-joins-in-subscriptions
+ //
+ // And in the meantime our code below works pretty well -- it's not even a
+ // hack!
+ {
+ find: function(board) {
+ return Cards.find({
+ boardId: board._id
+ });
+ },
+
+ children: [
+ // comments
+ {
+ find: function(card) {
+ return CardComments.find({
+ cardId: card._id
+ });
+ }
+ },
+ // Attachments
+ {
+ find: function(card) {
+ return Attachments.find({
+ cardId: card._id
+ });
+ }
+ }
+ ]
+ },
+
+ // Board members. This publication also includes former board members that
+ // are no more members of the board but may have some activities attached
+ // to them.
+ {
+ find: function(board) {
+ return Users.find({
+ _id: { $in: _.pluck(board.members, 'userId') }
+ });
+ }
+ }
+ ]
+ };
+});
diff --git a/server/publications/cards.js b/server/publications/cards.js
new file mode 100644
index 00000000..a9664095
--- /dev/null
+++ b/server/publications/cards.js
@@ -0,0 +1,4 @@
+Meteor.publish('card', function(cardId) {
+ check(cardId, String);
+ return Cards.find({ _id: cardId });
+});
diff --git a/server/publications/users.js b/server/publications/users.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/server/publications/users.js