瀏覽代碼

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
Maxime Quandalle 10 年之前
當前提交
2dbea30842
共有 100 個文件被更改,包括 6827 次插入0 次删除
  1. 5 0
      .gitignore
  2. 77 0
      .jscsrc
  3. 82 0
      .jshintrc
  4. 8 0
      .meteor/.finished-upgraders
  5. 1 0
      .meteor/.gitignore
  6. 7 0
      .meteor/.id
  7. 1 0
      .meteor/cordova-plugins
  8. 53 0
      .meteor/packages
  9. 2 0
      .meteor/platforms
  10. 1 0
      .meteor/release
  11. 120 0
      .meteor/versions
  12. 7 0
      .travis.yml
  13. 57 0
      Contributing.md
  14. 9 0
      Dockerfile
  15. 21 0
      LICENSE
  16. 25 0
      README.md
  17. 8 0
      client/components/activities/activities.jade
  18. 77 0
      client/components/activities/activities.js
  19. 0 0
      client/components/activities/comments.jade
  20. 0 0
      client/components/activities/comments.js
  21. 30 0
      client/components/activities/events.js
  22. 154 0
      client/components/activities/templates.html
  23. 33 0
      client/components/boards/body.jade
  24. 70 0
      client/components/boards/body.js
  25. 54 0
      client/components/boards/body.styl
  26. 34 0
      client/components/boards/colors.styl
  27. 96 0
      client/components/boards/events.js
  28. 87 0
      client/components/boards/header.jade
  29. 7 0
      client/components/boards/header.js
  30. 137 0
      client/components/boards/header.styl
  31. 45 0
      client/components/boards/helpers.js
  32. 14 0
      client/components/boards/list.jade
  33. 85 0
      client/components/boards/list.styl
  34. 34 0
      client/components/boards/router.js
  35. 47 0
      client/components/cards/details.jade
  36. 103 0
      client/components/cards/details.js
  37. 161 0
      client/components/cards/details.styl
  38. 285 0
      client/components/cards/events.js
  39. 48 0
      client/components/cards/helpers.js
  40. 183 0
      client/components/cards/labels.styl
  41. 136 0
      client/components/cards/minicard.styl
  42. 12 0
      client/components/cards/popups.jade
  43. 15 0
      client/components/cards/router.js
  44. 336 0
      client/components/cards/templates.html
  45. 22 0
      client/components/forms/cachedValue.js
  46. 636 0
      client/components/forms/forms.styl
  47. 6 0
      client/components/forms/inlinedform.jade
  48. 93 0
      client/components/forms/inlinedform.js
  49. 50 0
      client/components/lists/body.jade
  50. 73 0
      client/components/lists/body.js
  51. 16 0
      client/components/lists/events.js
  52. 13 0
      client/components/lists/header.jade
  53. 25 0
      client/components/lists/header.js
  54. 5 0
      client/components/lists/main.jade
  55. 81 0
      client/components/lists/main.js
  56. 136 0
      client/components/lists/main.styl
  57. 28 0
      client/components/lists/menu.jade
  58. 46 0
      client/components/lists/menu.js
  59. 8 0
      client/components/main/events.js
  60. 40 0
      client/components/main/header.jade
  61. 10 0
      client/components/main/header.js
  62. 266 0
      client/components/main/header.styl
  63. 63 0
      client/components/main/helpers.js
  64. 17 0
      client/components/main/layouts.jade
  65. 16 0
      client/components/main/popup.js
  66. 585 0
      client/components/main/popup.styl
  67. 13 0
      client/components/main/popup.tpl.jade
  68. 40 0
      client/components/main/rendered.js
  69. 5 0
      client/components/main/router.js
  70. 45 0
      client/components/main/spinner.styl
  71. 6 0
      client/components/main/spinner.tpl.jade
  72. 18 0
      client/components/main/templates.html
  73. 14 0
      client/components/modal/events.js
  74. 0 0
      client/components/modal/helpers.js
  75. 5 0
      client/components/modal/modal.tpl.jade
  76. 93 0
      client/components/sidebar/events.js
  77. 51 0
      client/components/sidebar/helpers.js
  78. 37 0
      client/components/sidebar/infiniteScrolling.js
  79. 21 0
      client/components/sidebar/rendered.js
  80. 55 0
      client/components/sidebar/sidebar.js
  81. 154 0
      client/components/sidebar/sidebar.styl
  82. 307 0
      client/components/sidebar/templates.html.old
  83. 103 0
      client/components/sidebar/templates.jade
  84. 7 0
      client/components/users/avatar.jade
  85. 59 0
      client/components/users/events.js
  86. 50 0
      client/components/users/form.styl
  87. 27 0
      client/components/users/headerButtons.jade
  88. 5 0
      client/components/users/headerButtons.js
  89. 27 0
      client/components/users/helpers.js
  90. 107 0
      client/components/users/member.styl
  91. 29 0
      client/components/users/router.js
  92. 118 0
      client/components/users/templates.html
  93. 35 0
      client/config/accounts.js
  94. 3 0
      client/config/avatar.js
  95. 28 0
      client/config/router.js
  96. 152 0
      client/lib/emoji-values.js
  97. 133 0
      client/lib/filter.js
  98. 22 0
      client/lib/i18n.js
  99. 55 0
      client/lib/keyboard.js
  100. 1 0
      client/lib/mixins.js

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+*~
+*.swp
+.meteor-spk
+.tx/
+*.sublime-workspace

+ 77 - 0
.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
+}

+ 82 - 0
.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
+  }
+}

+ 8 - 0
.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

+ 1 - 0
.meteor/.gitignore

@@ -0,0 +1 @@
+local

+ 7 - 0
.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

+ 1 - 0
.meteor/cordova-plugins

@@ -0,0 +1 @@
+

+ 53 - 0
.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

+ 2 - 0
.meteor/platforms

@@ -0,0 +1,2 @@
+server
+browser

+ 1 - 0
.meteor/release

@@ -0,0 +1 @@
+METEOR@1.1.0.2

+ 120 - 0
.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

+ 7 - 0
.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

+ 57 - 0
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

+ 9 - 0
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

+ 21 - 0
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.

+ 25 - 0
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

+ 8 - 0
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

+ 77 - 0
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');

+ 0 - 0
client/components/activities/comments.jade


+ 0 - 0
client/components/activities/comments.js


+ 30 - 0
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();
+  }
+});

+ 154 - 0
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>

+ 33 - 0
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'}}

+ 70 - 0
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');

+ 54 - 0
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

+ 34 - 0
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)

+ 96 - 0
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();
+  }
+});

+ 87 - 0
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'}}")

+ 7 - 0
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);
+  }
+});

+ 137 - 0
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;
+        }
+    }
+}

+ 45 - 0
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);
+  }
+});

+ 14 - 0
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'}}

+ 85 - 0
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

+ 34 - 0
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);
+  }
+});

+ 47 - 0
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

+ 103 - 0
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
+      }
+    });
+  }
+});

+ 161 - 0
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%

+ 285 - 0
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();
+  }
+});

+ 48 - 0
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);
+  }
+});

+ 183 - 0
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

+ 136 - 0
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

+ 12 - 0
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

+ 15 - 0
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);
+  }
+});

+ 336 - 0
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>

+ 22 - 0
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('');
+  }
+});

+ 636 - 0
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

+ 6 - 0
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

+ 93 - 0
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');

+ 50 - 0
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

+ 73 - 0
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');

+ 16 - 0
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 = '';
+    }
+  }
+});

+ 13 - 0
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

+ 25 - 0
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');

+ 5 - 0
client/components/lists/main.jade

@@ -0,0 +1,5 @@
+template(name='list')
+  .list.js-list(id="js-list-{{_id}}")
+    .list-wrapper
+      +listHeader
+      +listBody

+ 81 - 0
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');

+ 136 - 0
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

+ 28 - 0
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'}}")

+ 46 - 0
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();
+  }
+});

+ 8 - 0
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();
+    }
+  }
+});

+ 40 - 0
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

+ 10 - 0
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')
+});

+ 266 - 0
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;
+// }

+ 63 - 0
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);
+}));

+ 17 - 0
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
+

+ 16 - 0
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);
+  }
+});

+ 585 - 0
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%

+ 13 - 0
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)

+ 40 - 0
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
+    }
+  ]);
+};

+ 5 - 0
client/components/main/router.js

@@ -0,0 +1,5 @@
+Router.route('/', {
+  name: 'Home',
+  redirectLoggedInUsers: true,
+  authenticated: true
+});

+ 45 - 0
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) }
+}

+ 6 - 0
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

+ 18 - 0
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>

+ 14 - 0
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();
+  }
+});

+ 0 - 0
client/components/modal/helpers.js


+ 5 - 0
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)

+ 93 - 0
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);
+//   }
+// });

+ 51 - 0
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;
+//   }
+// });

+ 37 - 0
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');
+        }
+      }
+    }];
+  }
+});

+ 21 - 0
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();
+        }
+      });
+    });
+  });
+};
+

+ 55 - 0
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');

+ 154 - 0
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

+ 307 - 0
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>

+ 103 - 0
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'}}

+ 7 - 0
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)

+ 59 - 0
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')
+});

+ 50 - 0
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

+ 27 - 0
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'}}

+ 5 - 0
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')
+});

+ 27 - 0
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();
+  }
+});

+ 107 - 0
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
+

+ 29 - 0
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'
+});

+ 118 - 0
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>

+ 35 - 0
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
+  }
+]);

+ 3 - 0
client/config/avatar.js

@@ -0,0 +1,3 @@
+Avatar.options = {
+  fallbackType: 'initials'
+};

+ 28 - 0
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();
+  }
+});

+ 152 - 0
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'];

+ 133 - 0
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);

+ 22 - 0
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);
+  }
+});

+ 55 - 0
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);
+  }
+});

+ 1 - 0
client/lib/mixins.js

@@ -0,0 +1 @@
+Mixins = {};

部分文件因文件數量過多而無法顯示