' + value;
12 | },
13 | replace: function (value) {
14 | return ':' + value + ':';
15 | },
16 | index: 1
17 | },
18 | { // user mentions
19 | match: /\B@(\w*)$/,
20 | search: function (term, callback) {
21 | var currentBoard = Boards.findOne(Router.current().params.boardId);
22 | callback($.map(currentBoard.members, function (member) {
23 | var username = Users.findOne(member.userId).username;
24 | return username.indexOf(term) === 0 ? username : null;
25 | }));
26 | },
27 | template: function (value) {
28 | return value;
29 | },
30 | replace: function (username) {
31 | return '@' + username + ' ';
32 | },
33 | index: 1
34 | }
35 | ]);
36 | }
37 |
--------------------------------------------------------------------------------
/collections/activities.js:
--------------------------------------------------------------------------------
1 | // Activities don't need a schema because they are always set from the a trusted
2 | // environment - the server - and there is no risk that a user change the logic
3 | // we use with this collection. Moreover using a schema for this collection
4 | // would be difficult (different activities have different fields) and wouldn't
5 | // bring any direct advantage.
6 | //
7 | // XXX The activities API is not so nice and need some functionalities. For
8 | // instance if a user archive a card, and un-archive it a few seconds later
9 | // we should remove both activities assuming it was an error the user decided
10 | // to revert.
11 | Activities = new Mongo.Collection('activities');
12 |
13 | Activities.helpers({
14 | board: function() {
15 | return Boards.findOne(this.boardId);
16 | },
17 | user: function() {
18 | return Users.findOne(this.userId);
19 | },
20 | member: function() {
21 | return Users.findOne(this.memberId);
22 | },
23 | list: function() {
24 | return Lists.findOne(this.listId);
25 | },
26 | oldList: function() {
27 | return Lists.findOne(this.oldListId);
28 | },
29 | card: function() {
30 | return Cards.findOne(this.cardId);
31 | },
32 | comment: function() {
33 | return CardComments.findOne(this.commentId);
34 | },
35 | attachment: function() {
36 | return Attachments.findOne(this.attachmentId);
37 | }
38 | });
39 |
40 | Activities.before.insert(function(userId, doc) {
41 | doc.createdAt = new Date();
42 | });
43 |
--------------------------------------------------------------------------------
/client/views/lists/rendered.js:
--------------------------------------------------------------------------------
1 | Template.lists.rendered = function() {
2 | var _this = this,
3 | data = this.data,
4 | lists = _this.$(".lists");
5 |
6 | if (Meteor.user().isBoardMember()) {
7 | lists.sortable({
8 | connectWith: ".lists",
9 | handle: ".list-header",
10 | tolerance: 'pointer',
11 | appendTo: 'body',
12 | helper: "clone",
13 | items: '.list:not(.add-list)',
14 | placeholder: 'list placeholder',
15 | start: function (event, ui) {
16 | $('.list.placeholder').height(ui.item.height());
17 | },
18 | stop: function(event, ui) {
19 | lists.find('.list:not(.add-list)').each(function(i, list) {
20 | var data = Blaze.getData(list);
21 | Lists.update(data._id, {
22 | $set: {
23 | sort: i
24 | }
25 | });
26 | });
27 | }
28 | }).disableSelection();
29 |
30 | // If there is no data in the board (ie, no lists) we autofocus the list
31 | // creation form by clicking on the corresponding element.
32 | if (data.board.lists().count() === 0) {
33 | _this.$("#AddListForm .js-open-add-list").click();
34 | }
35 | }
36 |
37 | // update height add, update, remove resize board height.
38 | Lists.find().observe({
39 | added: Utils.resizeHeight('.board-canvas'),
40 | updated: Utils.resizeHeight('.board-canvas'),
41 | removed: Utils.resizeHeight('.board-canvas')
42 | });
43 | };
44 |
--------------------------------------------------------------------------------
/client/views/widgets/helpers.js:
--------------------------------------------------------------------------------
1 | var currentBoard = function() {
2 | return Boards.findOne(Router.current().params.boardId);
3 | }
4 |
5 | var widgetTitles = {
6 | filter: 'filter-cards',
7 | background: 'change-background'
8 | };
9 |
10 | Template.boardWidgets.helpers({
11 | currentWidget: function() {
12 | return Session.get('currentWidget') + 'Widget';
13 | },
14 | currentWidgetTitle: function() {
15 | return TAPi18n.__(widgetTitles[Session.get('currentWidget')]);
16 | }
17 | });
18 |
19 | Template.addMemberPopup.helpers({
20 | isBoardMember: function() {
21 | var user = Users.findOne(this._id);
22 | return user && user.isBoardMember();
23 | }
24 | });
25 |
26 | Template.backgroundWidget.helpers({
27 | backgroundColors: function() {
28 | return DefaultBoardBackgroundColors;
29 | }
30 | });
31 |
32 | Template.memberPopup.helpers({
33 | user: function() {
34 | return Users.findOne(this.userId);
35 | },
36 | memberType: function() {
37 | var type = Users.findOne(this.userId).isBoardAdmin() ? 'admin' : 'normal';
38 | return TAPi18n.__(type).toLowerCase();
39 | }
40 | });
41 |
42 | Template.removeMemberPopup.helpers({
43 | user: function() {
44 | return Users.findOne(this.userId)
45 | },
46 | board: function() {
47 | return currentBoard();
48 | }
49 | });
50 |
51 | Template.changePermissionsPopup.helpers({
52 | isAdmin: function() {
53 | return this.user.isBoardAdmin();
54 | },
55 | isLastAdmin: function() {
56 | if (! this.user.isBoardAdmin())
57 | return false;
58 | var nbAdmins = _.where(currentBoard().members, { isAdmin: true }).length;
59 | return nbAdmins === 1;
60 | }
61 | });
62 |
--------------------------------------------------------------------------------
/sandstorm.js:
--------------------------------------------------------------------------------
1 | // Sandstorm context is detected using the METEOR_SETTINGS environment variable
2 | // in the package definition.
3 | // XXX We could probably rely on the user object to detect sandstorm usage.
4 | var isSandstorm = Meteor.settings &&
5 | Meteor.settings.public && Meteor.settings.public.sandstorm;
6 |
7 | // In sandstorm we only have one board per sandstorm instance. Since we want to
8 | // keep most of our code unchanged, we simply hard-code a board `_id` and
9 | // redirect the user to this particular board.
10 | var sandstormBoard = {
11 | _id: "sandstorm",
12 | title: "LibreBoard"
13 | };
14 |
15 | // On the first launch of the instance a user is automatically created thanks to
16 | // the `accounts-sandstorm` package. After its creation we insert the unique
17 | // board document.
18 | if (isSandstorm && Meteor.isServer) {
19 | Users.after.insert(function(userId, doc) {
20 | Boards.insert({
21 | _id: sandstormBoard._id,
22 | // XXX should be shared with the instance name
23 | title: sandstormBoard.title,
24 | userId: doc._id
25 | });
26 | });
27 | }
28 |
29 | // On the client, redirect the user to the hard-coded board. On the first launch
30 | // the user will be redirected to the board before its creation. But that's not
31 | // a problem thanks to the reactive board publication.
32 | if (isSandstorm && Meteor.isClient) {
33 | Router.go('Board', {
34 | boardId: sandstormBoard._id,
35 | slug: getSlug(sandstormBoard.title)
36 | });
37 | }
38 |
39 | // We use this blaze helper in the UI to hide some template that does not make
40 | // sense in the context of sandstorm, like board staring, board archiving, user
41 | // name edition, etc.
42 | Blaze.registerHelper("isSandstorm", function() {
43 | return isSandstorm;
44 | });
45 |
--------------------------------------------------------------------------------
/collections/users.js:
--------------------------------------------------------------------------------
1 | Users = Meteor.users;
2 |
3 | // Search subscribe mongodb fields ['username', 'profile.name']
4 | Users.initEasySearch(['username', 'profile.name'], {
5 | use: 'mongo-db'
6 | });
7 |
8 |
9 | // HELPERS
10 | Users.helpers({
11 | boards: function() {
12 | return Boards.find({ userId: this._id });
13 | },
14 | hasStarred: function(boardId) {
15 | return this.profile.starredBoards && _.contains(this.profile.starredBoards, boardId);
16 | },
17 | isBoardMember: function() {
18 | var board = Boards.findOne(Router.current().params.boardId);
19 | return _.contains(_.pluck(board.members, 'userId'), this._id);
20 | },
21 | isBoardAdmin: function() {
22 | var board = Boards.findOne(Router.current().params.boardId);
23 | return this.isBoardMember(board) && _.where(board.members, {userId: this._id})[0].isAdmin;
24 | }
25 | });
26 |
27 |
28 | // BEFORE HOOK
29 | Users.before.insert(function (userId, doc) {
30 |
31 | // connect profile.status default
32 | doc.profile.status = 'offline';
33 |
34 | // slugify to username
35 | doc.username = getSlug(doc.profile.name, '');
36 | });
37 |
38 |
39 | // AFTER HOOK
40 | isServer(function() {
41 | Users.after.insert(function(userId, doc) {
42 | var ExampleBoard = {
43 | title: 'Welcome Board',
44 | userId: doc._id,
45 | permission: 'private' // Private || Public
46 | };
47 |
48 | // Welcome Board insert and list, card :)
49 | Boards.insert(ExampleBoard, function(err, boardId) {
50 |
51 | // lists
52 | _.forEach(['Basics', 'Advanced'], function(title) {
53 | var list = {
54 | title: title,
55 | boardId: boardId,
56 | userId: ExampleBoard.userId
57 | };
58 |
59 | // insert List
60 | Lists.insert(list);
61 | });
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/collections/attachments.js:
--------------------------------------------------------------------------------
1 | Attachments = new FS.Collection("attachments", {
2 | stores: [
3 | // XXX Add a new store for cover thumbnails so we don't load big images
4 | // in the general board view
5 | new FS.Store.GridFS("attachments")
6 | ]
7 | });
8 |
9 | Attachments.allow({
10 | insert: function(userId, doc) {
11 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
12 | },
13 | update: function(userId, doc) {
14 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
15 | },
16 | remove: function(userId, doc) {
17 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
18 | },
19 | // We authorize the attachment download either:
20 | // - if the board is public, everyone (even unconnected) can download it
21 | // - if the board is private, only board members can download it
22 | //
23 | // XXX We have a bug with the userId verification:
24 | //
25 | // https://github.com/CollectionFS/Meteor-CollectionFS/issues/449
26 | //
27 | download: function(userId, doc) {
28 | var query = {
29 | $or: [
30 | { 'members.userId': userId },
31 | { permission: 'public' }
32 | ]
33 | };
34 | return !!Boards.findOne(doc.boardId, query);
35 | },
36 | fetch: ['boardId']
37 | });
38 |
39 | // XXX Enforce a schema for the Attachments CollectionFS
40 |
41 | Attachments.files.before.insert(function(userId, doc) {
42 | var file = new FS.File(doc);
43 | doc.userId = userId;
44 |
45 | if (file.isImage()) {
46 | doc.cover = true;
47 | }
48 | });
49 |
50 | isServer(function() {
51 | Attachments.files.after.insert(function(userId, doc) {
52 | Activities.insert({
53 | type: 'card',
54 | activityType: "addAttachment",
55 | attachmentId: doc._id,
56 | boardId: doc.boardId,
57 | cardId: doc.cardId,
58 | userId: userId
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/client/views/cards/rendered.js:
--------------------------------------------------------------------------------
1 | Template.cards.rendered = function() {
2 | var _this = this,
3 | cards = _this.$(".cards");
4 |
5 | if (Meteor.user().isBoardMember()) {
6 | cards.sortable({
7 | connectWith: ".js-sortable",
8 | tolerance: 'pointer',
9 | appendTo: 'body',
10 | helper: "clone",
11 | items: '.list-card:not(.placeholder, .hide, .js-composer)',
12 | placeholder: 'list-card placeholder',
13 | start: function (event, ui) {
14 | $('.list-card.placeholder').height(ui.item.height());
15 | },
16 | stop: function(event, ui) {
17 | var data = new Utils.getCardData(ui.item);
18 | Cards.update(data.cardId, {
19 | $set: {
20 | listId: data.listId,
21 | sort: data.sort
22 | }
23 | });
24 | }
25 | }).disableSelection();
26 |
27 | Utils.liveEvent('mouseover', function($this) {
28 | $this.find('.js-member-droppable').droppable({
29 | hoverClass: "draggable-hover-card",
30 | accept: '.js-member',
31 | drop: function(event, ui) {
32 | var memberId = Blaze.getData(ui.draggable.get(0)).userId;
33 | var cardId = Blaze.getData(this)._id;
34 | Cards.update(cardId, {$addToSet: { members: memberId}});
35 | }
36 | });
37 | });
38 | }
39 |
40 | // update height add, update, remove resize board height.
41 | Cards.find().observe({
42 | added: Utils.resizeHeight('.board-canvas'),
43 | updated: Utils.resizeHeight('.board-canvas'),
44 | removed: Utils.resizeHeight('.board-canvas')
45 | });
46 | };
47 |
48 | Template.formLabel.rendered = function() {
49 | this.find('.js-autofocus').focus();
50 | };
51 |
52 | Template.cardMorePopup.rendered = function() {
53 | this.find('.js-autoselect').select();
54 | };
55 |
--------------------------------------------------------------------------------
/client/views/boards/routers.js:
--------------------------------------------------------------------------------
1 | Router.route('/boards', {
2 | name: 'Boards',
3 | template: 'boards',
4 | bodyClass: 'page-index large-window tabbed-page',
5 | authenticated: 'Login',
6 | waitOn: function() {
7 | return Meteor.subscribe('boards');
8 | }
9 | });
10 |
11 | Router.route('/boards/:boardId/:slug', {
12 | name: 'Board',
13 | template: 'board',
14 | bodyClass: 'page-index chrome chrome-39 mac extra-large-window body-webkit-scrollbars body-board-view bgBoard',
15 | onAfterAction: function() {
16 | Session.set('sidebarIsOpen', true);
17 | Session.set('currentWidget', 'home');
18 | Session.set('menuWidgetIsOpen', false);
19 | },
20 | waitOn: function() {
21 | var params = this.params;
22 |
23 | return [
24 |
25 | // Update currentUser profile status
26 | Meteor.subscribe('connectUser'),
27 |
28 | // Board page list, cards members vs
29 | Meteor.subscribe('board', params.boardId, params.slug)
30 | ]
31 | },
32 | data: function() {
33 | return Boards.findOne(this.params.boardId);
34 | }
35 | });
36 |
37 | // Reactively set the color of the page from the color of the current board.
38 | Meteor.startup(function() {
39 | Tracker.autorun(function() {
40 | var currentRoute = Router.current();
41 | // We have to be very defensive here because we have no idea what the
42 | // state of the application is, so we have to test existence of any
43 | // property we want to use.
44 | // XXX There is one feature of coffeescript that rely shine in this kind
45 | // of code: `currentRoute?.params?.boardId` -- concise and clear.
46 | var currentBoard = Boards.findOne(currentRoute &&
47 | currentRoute.params &&
48 | currentRoute.params.boardId);
49 | if (currentBoard &&
50 | currentBoard.background &&
51 | currentBoard.background.type === "color") {
52 | $(document.body).css({
53 | backgroundColor: currentBoard.background.color
54 | });
55 | } else {
56 | $(document.body).css({ backgroundColor: '' });
57 | }
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/client/views/main/routers.js:
--------------------------------------------------------------------------------
1 | /*
2 | * If you want to use a default layout template for all routes you can
3 | * configure a global Router option.
4 | */
5 | Router.configure({
6 | loadingTemplate: 'loading',
7 | notFoundTemplate: 'notfound',
8 |
9 | yieldRegions: {
10 | '': {
11 | to: 'modal'
12 | }
13 | },
14 |
15 | /*
16 | * onBeforeAction hooks now require you to call this.next(),
17 | * and no longer take a pause() argument. So the default behaviour is reversed.
18 | * ClassMapper body add, remove class.
19 | */
20 | onBeforeAction: function(pause) {
21 | var body = $('body'),
22 | options = this.route.options,
23 | bodyClass = options["bodyClass"],
24 | authenticate = options['authenticated'],
25 | redirectLoggedInUsers = options['redirectLoggedInUsers'];
26 |
27 | // redirect logged in users to Boards view when they try to open Login or Signup views
28 | if (Meteor.user() && redirectLoggedInUsers) {
29 | // redirect
30 | this.redirect('Boards');
31 | return;
32 | }
33 |
34 | // authenticated
35 | if (!Meteor.user() && authenticate) {
36 |
37 | // redirect
38 | this.redirect(authenticate);
39 |
40 | // options authenticate not next().
41 | return;
42 | }
43 |
44 | // Remove class attribute body
45 | body.removeAttr('class');
46 |
47 | // if klass iron router name currentRouter then add Class
48 | if (bodyClass) body.addClass(bodyClass);
49 |
50 | // reset default sessions
51 | Session.set('error', false);
52 | Session.set('warning', false);
53 |
54 | Popup.close();
55 |
56 | // Layout template found then set render this.route options layout.
57 | if (!options.layoutTemplate) {
58 |
59 | // if user undefined then layout render
60 | if (!Meteor.user()) this.layout('layout');
61 |
62 | // user found then AuthLayout render
63 | else this.layout('AuthLayout');
64 | }
65 |
66 | // Next
67 | this.next();
68 | }
69 | });
70 |
71 | Router.route('/', {
72 | name: 'Home',
73 | template: 'home',
74 | layoutTemplate: 'LandingLayout'
75 | });
76 |
--------------------------------------------------------------------------------
/sandstorm-pkgdef.capnp:
--------------------------------------------------------------------------------
1 | @0xa5275bd3ad124e12;
2 |
3 | using Spk = import "/sandstorm/package.capnp";
4 | # This imports:
5 | # $SANDSTORM_HOME/latest/usr/include/sandstorm/package.capnp
6 | # Check out that file to see the full, documented package definition format.
7 |
8 | const pkgdef :Spk.PackageDefinition = (
9 | # The package definition. Note that the spk tool looks specifically for the
10 | # "pkgdef" constant.
11 |
12 | id = "m86q05rdvj14yvn78ghaxynqz7u2svw6rnttptxx49g1785cdv1h",
13 | # Your app ID is actually its public key. The private key was placed in
14 | # your keyring. All updates must be signed with the same key.
15 |
16 | manifest = (
17 | # This manifest is included in your app package to tell Sandstorm
18 | # about your app.
19 |
20 | appVersion = 0, # Increment this for every release.
21 |
22 | actions = [
23 | # Define your "new document" handlers here.
24 | ( title = (defaultText = "New LibreBoard"),
25 | command = .myCommand
26 | # The command to run when starting for the first time. (".myCommand"
27 | # is just a constant defined at the bottom of the file.)
28 | )
29 | ],
30 |
31 | continueCommand = .myCommand
32 | # This is the command called to start your app back up after it has been
33 | # shut down for inactivity. Here we're using the same command as for
34 | # starting a new instance, but you could use different commands for each
35 | # case.
36 | ),
37 |
38 | sourceMap = (
39 | # The following directories will be copied into your package.
40 | searchPath = [
41 | ( sourcePath = ".meteor-spk/deps" ),
42 | ( sourcePath = ".meteor-spk/bundle" )
43 | ]
44 | ),
45 |
46 | alwaysInclude = [ "." ]
47 | # This says that we always want to include all files from the source map.
48 | # (An alternative is to automatically detect dependencies by watching what
49 | # the app opens while running in dev mode. To see what that looks like,
50 | # run `spk init` without the -A option.)
51 | );
52 |
53 | const myCommand :Spk.Manifest.Command = (
54 | # Here we define the command used to start up your server.
55 | argv = ["/sandstorm-http-bridge", "4000", "--", "node", "start.js"],
56 | environ = [
57 | # Note that this defines the *entire* environment seen by your app.
58 | (key = "PATH", value = "/usr/local/bin:/usr/bin:/bin"),
59 | (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}")
60 | ]
61 | );
62 |
--------------------------------------------------------------------------------
/collections/lists.js:
--------------------------------------------------------------------------------
1 | Lists = new Mongo.Collection('lists');
2 |
3 | Lists.attachSchema(new SimpleSchema({
4 | title: {
5 | type: String
6 | },
7 | archived: {
8 | type: Boolean,
9 | defaultValue: false
10 | },
11 | boardId: {
12 | type: String
13 | },
14 | createdAt: {
15 | type: Date,
16 | denyUpdate: true,
17 | autoValue: function() {
18 | if (this.isInsert)
19 | return new Date();
20 | }
21 | },
22 | sort: {
23 | type: Number,
24 | decimal: true,
25 | // XXX We should probably provide a default
26 | optional: true
27 | },
28 | updatedAt: {
29 | type: Date,
30 | denyInsert: true,
31 | optional: true,
32 | autoValue: function() {
33 | if (this.isUpdate)
34 | return new Date();
35 | }
36 | }
37 | }));
38 |
39 | Lists.allow({
40 | insert: function(userId, doc) {
41 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
42 | },
43 | update: function(userId, doc) {
44 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
45 | },
46 | remove: function(userId, doc) {
47 | return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
48 | },
49 | fetch: ['boardId']
50 | });
51 |
52 |
53 | Lists.helpers({
54 | cards: function() {
55 | return Cards.find(_.extend(Filter.getMongoSelector(), {
56 | listId: this._id,
57 | archived: false
58 | }), { sort: ['sort'] });
59 | },
60 | board: function() {
61 | return Boards.findOne(this.boardId);
62 | }
63 | });
64 |
65 | // HOOKS
66 | Lists.hookOptions.after.update = { fetchPrevious: false };
67 |
68 | isServer(function() {
69 | Lists.after.insert(function(userId, doc) {
70 | Activities.insert({
71 | type: 'list',
72 | activityType: "createList",
73 | boardId: doc.boardId,
74 | listId: doc._id,
75 | userId: userId
76 | });
77 | });
78 |
79 | Lists.after.update(function(userId, doc) {
80 | if (doc.archived) {
81 | Activities.insert({
82 | type: 'list',
83 | activityType: "archivedList",
84 | listId: doc._id,
85 | boardId: doc.boardId,
86 | userId: userId
87 | });
88 | }
89 | });
90 | });
91 |
--------------------------------------------------------------------------------
/client/views/boards/events.js:
--------------------------------------------------------------------------------
1 | var toggleBoardStar = function(boardId) {
2 | var queryType = Meteor.user().hasStarred(boardId) ? '$pull' : '$addToSet';
3 | var query = {};
4 | query[queryType] = {
5 | 'profile.starredBoards': boardId
6 | };
7 | Meteor.users.update(Meteor.userId(), query);
8 | };
9 |
10 | Template.boards.events({
11 | 'click .js-star-board': function(event, t) {
12 | toggleBoardStar(this._id);
13 | event.preventDefault();
14 | }
15 | });
16 |
17 | Template.board.events({
18 | 'click .js-star-board': function(event, t) {
19 | toggleBoardStar(this._id);
20 | },
21 | 'click .js-rename-board:not(.no-edit)': Popup.open('boardChangeTitle'),
22 | 'click #permission-level:not(.no-edit)': Popup.open('boardChangePermission'),
23 | 'click .js-filter-cards-indicator': function(event) {
24 | Session.set('currentWidget', 'filter');
25 | event.preventDefault();
26 | },
27 | 'click .js-filter-card-clear': function(event) {
28 | Filter.reset();
29 | event.stopPropagation();
30 | }
31 | });
32 |
33 | Template.createBoardPopup.events({
34 | 'submit #CreateBoardForm': function(event, t) {
35 | var title = t.$('#boardNewTitle');
36 |
37 | // trim value title
38 | if ($.trim(title.val())) {
39 | // İnsert Board title
40 | var boardId = Boards.insert({
41 | title: title.val(),
42 | permission : 'public'
43 | });
44 |
45 | // Go to Board _id
46 | Utils.goBoardId(boardId);
47 | }
48 | event.preventDefault();
49 | }
50 | });
51 |
52 | Template.boardChangeTitlePopup.events({
53 | 'submit #ChangeBoardTitleForm': function(event, t) {
54 | var title = t.$('.js-board-name').val().trim();
55 | if (title) {
56 | Boards.update(this._id, {
57 | $set: {
58 | title: title
59 | }
60 | });
61 | Popup.close();
62 | }
63 | event.preventDefault();
64 | }
65 | });
66 |
67 | Template.boardChangePermissionPopup.events({
68 | 'click .js-select': function(event, t) {
69 | var $this = $(event.currentTarget),
70 | permission = $this.attr('name');
71 |
72 | Boards.update(this._id, {
73 | $set: {
74 | permission: permission
75 | }
76 | });
77 | Popup.close();
78 | }
79 | });
80 |
--------------------------------------------------------------------------------
/client/views/main/helpers.js:
--------------------------------------------------------------------------------
1 | var Helpers = {
2 | error: function() {
3 | return Session.get('error');
4 | },
5 | toLowerCase: function(text) {
6 | return text && text.toLowerCase();
7 | },
8 | toUpperCase: function(text) {
9 | return text && text.toUpperCase();
10 | },
11 | firstChar: function(text) {
12 | return text && text[0].toUpperCase();
13 | },
14 | session: function(prop) {
15 | return Session.get(prop);
16 | },
17 | isTrue: function(a, b) {
18 | return a == b;
19 | },
20 | getUser: function(userId) {
21 | return Users.findOne(userId);
22 | }
23 | };
24 |
25 | Template.warning.helpers({
26 | warning: function() {
27 | return Utils.Warning.get();
28 | }
29 | });
30 |
31 | // Register all Helpers
32 | _.each(Helpers, function(fn, name) { Blaze.registerHelper(name, fn); });
33 |
34 | // XXX I believe we should compute a HTML rendered field on the server that
35 | // would handle markdown, emojies and user mentions. We can simply have two
36 | // fields, one source, and one compiled version (in HTML) and send only the
37 | // compiled version to most users -- who don't need to edit.
38 | // In the meantime, all the transformation are done on the client using the
39 | // Blaze API.
40 | var at = HTML.CharRef({html: '@', str: '@'});
41 | Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
42 | var view = this;
43 | var content = Blaze.toHTML(view.templateContentBlock);
44 | var currentBoard = Boards.findOne(Router.current().params.boardId);
45 | var knowedUsers = _.map(currentBoard.members, function(member) {
46 | member.username = Users.findOne(member.userId).username;
47 | return member;
48 | });
49 |
50 | var mentionRegex = /\B@(\w*)/gi;
51 | var currentMention, knowedUser, href, linkClass, linkValue, link;
52 | while (currentMention = mentionRegex.exec(content)) {
53 |
54 | knowedUser = _.findWhere(knowedUsers, { username: currentMention[1] });
55 | if (! knowedUser)
56 | continue;
57 |
58 | linkValue = [' ', at, knowedUser.username];
59 | href = Router.url('Profile', { username: knowedUser.username });
60 | linkClass = 'atMention' + (knowedUser.userId === Meteor.userId() ? ' me' : '');
61 | link = HTML.A({ href: href, "class": linkClass }, linkValue);
62 |
63 | content = content.replace(currentMention[0], Blaze.toHTML(link));
64 | }
65 |
66 | return HTML.Raw(content);
67 | }));
68 |
--------------------------------------------------------------------------------
/Contributing.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We’re glad you’re interested in helping the LibreBoard project! We welcome bug
4 | reports, enhancement ideas, and pull requests, in our Github bug tracker. Before
5 | opening a new thread please verify that your issue hasn’t already been reported.
6 |
7 | {{_ 'list-archive-cards-pop'}}
82 | 83 | 84 | -------------------------------------------------------------------------------- /client/views/widgets/events.js: -------------------------------------------------------------------------------- 1 | Template.boardWidgets.events({ 2 | 'click .js-show-sidebar': function(event, t) { 3 | Session.set('sidebarIsOpen', true); 4 | }, 5 | 'click .js-hide-sidebar': function() { 6 | Session.set('sidebarIsOpen', false); 7 | }, 8 | 'click .js-pop-widget-view': function() { 9 | Session.set('currentWidget', 'home'); 10 | } 11 | }); 12 | 13 | Template.menuWidget.events({ 14 | 'click .js-open-card-filter': function() { 15 | Session.set('currentWidget', 'filter'); 16 | }, 17 | 'click .js-change-background': function() { 18 | Session.set('currentWidget', 'background'); 19 | }, 20 | 'click .js-close-board': Popup.afterConfirm('closeBoard', function() { 21 | Boards.update(this.board._id, { 22 | $set: { 23 | archived: true 24 | } 25 | }); 26 | 27 | Router.go('Boards'); 28 | }), 29 | 'click .js-toggle-widget-nav': function(event, t) { 30 | Session.set('menuWidgetIsOpen', ! Session.get('menuWidgetIsOpen')); 31 | } 32 | }); 33 | 34 | Template.filterWidget.events({ 35 | 'click .js-toggle-label-filter': function(event) { 36 | Filter.labelIds.toogle(this._id); 37 | event.preventDefault(); 38 | }, 39 | 'click .js-toogle-member-filter': function(event) { 40 | Filter.members.toogle(this._id); 41 | event.preventDefault(); 42 | } 43 | }); 44 | 45 | Template.backgroundWidget.events({ 46 | 'click .js-select-background': function(event) { 47 | var currentBoardId = Router.current().params.boardId; 48 | Boards.update(currentBoardId, {$set: { 49 | background: { 50 | type: 'color', 51 | color: this.toString() 52 | } 53 | }}); 54 | event.preventDefault(); 55 | } 56 | }); 57 | 58 | var getMemberIndex = function(board, searchId) { 59 | for (var i = 0; i < board.members.length; i++) { 60 | if (board.members[i].userId === searchId) 61 | return i; 62 | } 63 | throw new Meteor.Error("Member not found"); 64 | } 65 | 66 | Template.memberPopup.events({ 67 | 'click .js-change-role': Popup.open('changePermissions'), 68 | 'click .js-remove-member:not(.disabled)': Popup.afterConfirm('removeMember', function(){ 69 | var currentBoardId = Router.current().params.boardId; 70 | Boards.update(currentBoardId, {$pull: {members: {userId: this.userId}}}); 71 | Popup.close(); 72 | }), 73 | 'click .js-leave-member': function(event, t) { 74 | // @TODO 75 | 76 | Popup.close(); 77 | } 78 | }); 79 | 80 | Template.membersWidget.events({ 81 | 'click .js-open-manage-board-members': Popup.open('addMember'), 82 | 'click .member': Popup.open('member') 83 | }); 84 | 85 | Template.addMemberPopup.events({ 86 | 'click .pop-over-member-list li:not(.disabled)': function(event, t) { 87 | var userId = this._id; 88 | var boardId = t.data.board._id; 89 | Boards.update(boardId, { 90 | $push: { 91 | members: { 92 | userId: userId, 93 | isAdmin: false 94 | } 95 | } 96 | }); 97 | Popup.close(); 98 | } 99 | }); 100 | 101 | Template.changePermissionsPopup.events({ 102 | 'click .js-set-admin, click .js-set-normal': function(event, t) { 103 | var currentBoard = Boards.findOne(Router.current().params.boardId); 104 | var memberIndex = getMemberIndex(currentBoard, this.user._id); 105 | var isAdmin = $(event.currentTarget).hasClass('js-set-admin'); 106 | var setQuery = {}; 107 | setQuery[['members', memberIndex, 'isAdmin'].join('.')] = isAdmin; 108 | Boards.update(currentBoard._id, { $set: setQuery }); 109 | Popup.back(1); 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /client/styles/print.styl: -------------------------------------------------------------------------------- 1 | @media print { 2 | * { 3 | text-shadow: none!important; 4 | } 5 | 6 | #surface { 7 | height: auto!important; 8 | } 9 | 10 | body { 11 | overflow: visible!important; 12 | } 13 | 14 | .attachment-thumbnail, 15 | .phenom, img { 16 | page-break-before: auto; 17 | page-break-after: auto; 18 | page-break-inside: avoid; 19 | position: relative; 20 | } 21 | 22 | #header { 23 | display: none; 24 | } 25 | 26 | #notification { 27 | display: none!important; 28 | } 29 | 30 | #board { 31 | height: auto!important; 32 | margin-left: 8px!important; 33 | overflow: visible!important; 34 | display: block!important; 35 | } 36 | 37 | .list { 38 | display: block; 39 | float: none; 40 | margin-bottom: 16px; 41 | max-height: none!important; 42 | width: 400px!important; 43 | } 44 | 45 | .list-cards { 46 | max-height: none!important; 47 | -moz-box-flex: 0; 48 | -webkit-box-flex: 0; 49 | -ms-flex: 0 0 auto; 50 | -webkit-flex: 0 0 auto; 51 | flex: 0 0 auto; 52 | } 53 | 54 | .extra-large-window .list, .large-window .list { 55 | -webkit-box-shadow: none; 56 | box-shadow: none; 57 | float: none; 58 | } 59 | 60 | .list-header .icon-sm { 61 | visibility: hidden; 62 | } 63 | 64 | .list-header .list-title { 65 | margin-left: 12px; 66 | } 67 | 68 | .open-card-composer { 69 | display: none; 70 | } 71 | 72 | .list-card { 73 | border: 1px solid #dbdbdb; 74 | border-bottom-color: #c2c2c2; 75 | width: 340px; 76 | } 77 | 78 | .badge, .board-widgets, .list-card-votes, .member .status { 79 | display: none; 80 | } 81 | 82 | .pop-over, .other-actions, .quiet-actions, .window-sidebar .button-link, .card-detail-desc .quiet-button, .chechklist-new-item, .new-comment { 83 | display: none!important; 84 | } 85 | 86 | .card-label { 87 | color: #4d4d4d; 88 | } 89 | 90 | .hide-on-edit, .card-detail-item-add-button { 91 | display: none; 92 | } 93 | 94 | .checklist-progress-bar { 95 | -webkit-box-shadow: none; 96 | box-shadow: none; 97 | border: 1px solid #e3e3e3; 98 | } 99 | 100 | .checklist-progress-bar-current { 101 | height: 7px; 102 | margin-top: 1px; 103 | border: 1px solid #777; 104 | } 105 | 106 | .action-comment { 107 | border: 1px solid #dbdbdb; 108 | -webkit-box-shadow: none; 109 | box-shadow: none; 110 | } 111 | 112 | .editable .current { 113 | display: block!important; 114 | } 115 | 116 | .editable .edit, .editable .edits-warning { 117 | display: none!important; 118 | } 119 | 120 | body.window-up #content { 121 | display: none; 122 | } 123 | 124 | body.window-up #surface { 125 | display: none; 126 | min-height: initial; 127 | height: auto!important; 128 | } 129 | 130 | body.window-up .window-overlay { 131 | background: #fff; 132 | display: block!important; 133 | height: auto; 134 | overflow: visible; 135 | position: relative; 136 | } 137 | 138 | body.window-up .window-sidebar, body.window-up .dialog-close-button { 139 | display: none; 140 | } 141 | 142 | body.window-up .window { 143 | position: relative!important; 144 | left: auto!important; 145 | top: auto!important; 146 | width: 700px!important; 147 | } 148 | 149 | body.window-up .window input, body.window-up .window textarea { 150 | display: none; 151 | } 152 | 153 | body.window-up .window-wrapper { 154 | background: #fff; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /client/styles/metrello.css: -------------------------------------------------------------------------------- 1 | /** 2 | * We should merge these declarations in the appropriate stylus files. 3 | */ 4 | 5 | #AddBoardForm input[type='text'] { width:234px; } 6 | .boardPage { background-color: rgb(35, 113, 159); } 7 | .dn { display:none; } 8 | .header-btn-btn { padding-left:23px!important; ; } 9 | .whiteBg { background:white; } 10 | .bgnone { background:none!important; } 11 | .cursorDefault { cursor:default!important; } 12 | .tac { text-align:center; } 13 | .pb100 { padding-bottom:100px!important; } 14 | .mt50 { margin-top:50px; } 15 | .list-header-name { word-wrap: break-word; } 16 | .list-card-details:hover { background:#f2f2f2;border-bottom-color:#b3b3b3; } 17 | .tdn { text-decoration:none; } 18 | 19 | .header-member { min-width:105px!important; text-align:center; } 20 | 21 | .white { color:white!important; } 22 | .primarys { font-size:20px; line-height: 1.44em; padding: .6em 1.3em!important; border-radius: 3px!important; box-shadow: 0 2px 0 #4d4d4d!important; } 23 | .layout-twothirds-center { display: block; max-width: 585px; margin: 0 auto; position: relative; font-size:20px; line-height: 100px; } 24 | 25 | a.list-card-details { text-decoration:none; } 26 | 27 | #WindowTitleEdit .single-line, .single-line2 { overflow: hidden; word-wrap: break-word; resize: none; height: 60px; } 28 | .single-line2 { overflow: hidden; word-wrap: break-word; resize: none; height: 108px; } 29 | .fieldSingle { overflow: hidden; word-wrap: break-word; resize: none; height: 34px; } 30 | 31 | .create_board, .user_header_menu { right:5px!important; top:45px!important; display:block!important; } 32 | .rename_board { left:8px!important; top: 85px!important; display:block!important } 33 | .permission_level { left: 81px!important; top: 85px!important; max-height: 370px!important; display: block!important; } 34 | 35 | .bgBoard { 36 | background-color: #16A085; 37 | } 38 | 39 | .promo-nav a#navLanding {background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAICAMAAAD6Ou7DAAAAM1BMVEUAAAD///////////////////////////////////////////////////////////////+3leKCAAAAEHRSTlMAlg/kKQc7w9X4AX9QUf799Yq2lgAAADZJREFUCB0FwYUBgAAAwCDs1v1/rQDcDwDjNI0AhhoA1q/eFVj2qn0BV1VdMB9VdcycW1XVdv5TywJ7mq6QbgAAAABJRU5ErkJggg==') no-repeat center bottom;cursor:default;color:#a2d6f4;padding-bottom:12px} 40 | 41 | .base-loading { background:url('/img/middle_logo.png'); width:80px; height:30px; margin:50px auto; } 42 | .base-loading .loading-gif { background:url(/img/loading.gif); background-size: 18px 18px; height: 18px; left: 2px; position: relative; top: 6px; width: 18px; } 43 | 44 | .sortable-dragging { cursor:-webkit-grab; } 45 | .list-area > .placeholder { background-color:rgba(0,0,0,0.2)!important; border-color:transparent!important; -webkit-box-shadow:none!important; box-shadow:none!important; float:left; width:250px; margin: 0 5px; padding: 4px 3px 3px;} 46 | 47 | #header-search { float:left; margin:1px 8px 0 0; position:relative; z-index:1; } 48 | #header-search label { display:none; } 49 | #header-search input[type="text"] { background:rgba(255,255,255,0.5);border-top-left-radius:3px;border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:3px;border:none;float:left;font-size:13px;height:29px;min-height:29px;line-height:19px;width:160px;margin:0} 50 | #header-search input[type="text"]:hover{background:rgba(255,255,255,0.7)} 51 | #header-search input[type="text"]:focus{background:#e8ebee;-webkit-box-shadow:none;box-shadow:none} 52 | #header-search .header-btn{border-top-left-radius:0;border-top-right-radius:3px;border-bottom-right-radius:3px;border-bottom-left-radius:0} 53 | #header-search input[type="submit"]{display:none} 54 | 55 | #landingLogo { 56 | clear:both; 57 | text-align:center; 58 | padding: 40px 40px 20px 40px; 59 | font-weight: normal; 60 | } 61 | 62 | .account-header #landingLogo { 63 | font-size: 35px; 64 | padding: 12px; 65 | } 66 | 67 | .width-wrapper {max-width:1080px;margin:0 auto;padding:0 60px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box; } 68 | 69 | .section-wrapper p { font-weight: 300; line-height: 1.44em; padding-bottom:30px; } 70 | .outline-link { border: 1px solid; border-radius: 3px; display: inline-block; margin: .6em 0; padding: .6em 1em; text-decoration: none; color: #5e98ba; background: transparent;} 71 | .tac h1 { font-size: 2em; } 72 | 73 | #Team { margin: 20px auto; max-width: 890px; position: relative; font-family: "jaf-facitweb","Helvetica Neue",Arial,sans-serif; color:#4d4d4d; } 74 | #Team .developer { display: inline-block; vertical-align: top; width: 250px; } 75 | #Team .developer img { width:150px; border:1px solid #c7c7c7; border-radius:4px; } 76 | #Team h4 { font-size:20px; } 77 | #Team i { font-size:25px; margin-right:5px; } 78 | -------------------------------------------------------------------------------- /client/views/lists/events.js: -------------------------------------------------------------------------------- 1 | Template.lists.events({ 2 | 'click .js-open-card-composer': function(event, t) { 3 | var $el = $(event.currentTarget), 4 | list = $el.parents('.list'), 5 | composer = list.find('.card-composer'); 6 | allComposers = t.$('.card-composer'); 7 | 8 | // all lists hide composer and open click composer show 9 | allComposers.addClass('hide'); 10 | t.$('.js-open-card-composer').removeClass('hide'); 11 | 12 | // click open composer and focus 13 | composer.removeClass('hide'); 14 | composer.find('.js-card-title').focus(); 15 | $el.addClass('hide'); 16 | 17 | // stop click hash 18 | event.preventDefault(); 19 | }, 20 | 'click .js-open-list-menu': Popup.open('listAction'), 21 | 'click .hide-on-edit': function(event, t) { 22 | var $this = $(event.currentTarget), 23 | listHeader = $this.parents('.list-header'); 24 | 25 | // remove all editing classes 26 | $('.js-list-header').removeClass('editing'); 27 | 28 | // and current editing open. 29 | listHeader.addClass('editing'); 30 | 31 | // focus current form title 32 | listHeader.find('.single-line').focus(); 33 | 34 | // stop click hash 35 | event.preventDefault(); 36 | } 37 | }); 38 | 39 | Template.listTitleEditForm.events({ 40 | 'submit #ListTitleEditForm': function(event, t) { 41 | var title = t.find('.js-title-input').value; 42 | if ($.trim(title)) { 43 | Lists.update(this.list._id, { 44 | $set: { 45 | title: title 46 | } 47 | }); 48 | 49 | // all editing remove 50 | $('.js-list-header').removeClass('editing'); 51 | } 52 | event.preventDefault(); 53 | }, 54 | 'click .js-cancel-edit': function(event, t) { 55 | $('.js-list-header').removeClass('editing'); 56 | } 57 | }); 58 | 59 | Template.addlistForm.events({ 60 | 'click .js-open-add-list': function(event, t) { 61 | t.$('.list').removeClass('idle'); 62 | t.$('.list-name-input').focus(); 63 | }, 64 | 'click .js-cancel-edit': function(event, t) { 65 | t.$('.list').addClass('idle'); 66 | }, 67 | // XXX We do something similar in the new card form event and should 68 | // probably avoid repeating ourself 69 | // 70 | // Keydown (and not keypress) in necessary here because the `keyCode` 71 | // property is consistent in all browsers, (there is not keyCode for the 72 | // `keypress` event in firefox) 73 | 'keydown .list-name-input': function(event, t) { 74 | var code = event.keyCode; 75 | if (code === 27) { 76 | t.$('.js-cancel-edit').click(); 77 | event.preventDefault(); 78 | } 79 | }, 80 | 'submit #AddListForm': function(event, t) { 81 | var title = t.find('.list-name-input'); 82 | if ($.trim(title.value)) { 83 | // insert 84 | Lists.insert({ 85 | title: title.value, 86 | boardId: this.board._id, 87 | sort: $('.list').length 88 | }, function() { 89 | 90 | // insert complete to scrollLeft 91 | Utils.Scroll('#board').left(270, true); 92 | }); 93 | 94 | // clear input 95 | title.value = ''; 96 | } 97 | event.preventDefault(); 98 | } 99 | }); 100 | 101 | Template.listActionPopup.events({ 102 | 'click .js-add-card': function(event, t) {}, 103 | 'click .js-list-subscribe': function(event, t) {}, 104 | 'click .js-move-cards': Popup.open('listMoveCards'), 105 | 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() { 106 | Cards.find({listId: this._id}).forEach(function(card) { 107 | Cards.update(card._id, { 108 | $set: { 109 | archived: true 110 | } 111 | }) 112 | }); 113 | Popup.close(); 114 | }), 115 | 'click .js-close-list': function(event, t) { 116 | Lists.update(this._id, { 117 | $set: { 118 | archived: true 119 | } 120 | }); 121 | 122 | Popup.close(); 123 | 124 | event.preventDefault(); 125 | } 126 | }); 127 | 128 | Template.listMoveCardsPopup.events({ 129 | 'click .js-select-list': function() { 130 | var fromList = Template.parentData(2).data._id; 131 | var toList = this._id; 132 | Cards.find({listId: fromList}).forEach(function(card) { 133 | Cards.update(card._id, { 134 | $set: { 135 | listId: toList 136 | } 137 | }); 138 | }); 139 | Popup.close(); 140 | } 141 | }); 142 | -------------------------------------------------------------------------------- /client/lib/utils.js: -------------------------------------------------------------------------------- 1 | Utils = { 2 | error: function(err) { 3 | Session.set("error", (err && err.message || false)); 4 | }, 5 | 6 | is_authenticated: function() { 7 | return Meteor.user() ? true : false; 8 | }, 9 | 10 | resizeHeight: function(selector, callback) { 11 | return function() { 12 | var board = jQuery(selector); 13 | var headerSize = $('#header').outerHeight(); 14 | board.height($(window).height() - 60 - headerSize); 15 | 16 | // call 17 | callback && callback(); 18 | } 19 | }, 20 | 21 | boardScrollLeft: function() { 22 | var el = jQuery('#board'), 23 | data = Blaze.getData(el.get(0)); 24 | if (data) { 25 | var sessionName = data.board ? 'scrollBoardLeft-' + data.board._id : false; 26 | 27 | if (sessionName) { 28 | el.scroll(function() { 29 | Session.set(sessionName, $(this).scrollLeft()); 30 | }); 31 | el.scrollLeft(Session.get(sessionName)); 32 | } 33 | } 34 | }, 35 | 36 | widgetsHeight: function() { 37 | var wrapper = $('.board-wrapper'), 38 | widgets = $('.board-widgets'), 39 | boardActions = $('.board-actions-list'), 40 | pop = $('.pop-over'); 41 | 42 | // set height widgets 43 | widgets.height(wrapper.height()); 44 | boardActions.height(wrapper.height() - 215); 45 | pop.find('.content').css('max-height', widgets.height() / 2); 46 | }, 47 | 48 | // scroll 49 | Scroll: function(selector) { 50 | var $el = $(selector); 51 | return { 52 | top: function(px, add) { 53 | var t = $el.scrollTop(); 54 | $el.animate({ scrollTop: (add ? (t + px) : px) }); 55 | }, 56 | left: function(px, add) { 57 | var l = $el.scrollLeft(); 58 | $el.animate({ scrollLeft: (add ? (l + px) : px) }); 59 | } 60 | }; 61 | }, 62 | 63 | Warning: { 64 | get: function() { 65 | return Session.get('warning'); 66 | }, 67 | open: function(desc) { 68 | Session.set('warning', { desc: desc }); 69 | }, 70 | close: function() { 71 | Session.set('warning', false); 72 | } 73 | }, 74 | 75 | goBoardId: function(_id) { 76 | var board = Boards.findOne(_id); 77 | return board && Router.go('Board', { boardId: board._id, slug: board.slug }); 78 | }, 79 | 80 | liveEvent: function(events, callback) { 81 | $(document).on(events, function() { 82 | callback($(this)); 83 | }); 84 | }, 85 | 86 | // new getCardData(cardEl) 87 | getCardData: function(item) { 88 | var el = item.get(0), 89 | card = Blaze.getData(el), 90 | list = Blaze.getData(item.parents('.list').get(0)), 91 | before = item.prev('.card').get(0), 92 | after = item.next('.card').get(0); 93 | 94 | this.listId = list._id; 95 | this.cardId = card._id; 96 | 97 | // if before and after undefined then sort 0 98 | if (!before && !after) { 99 | 100 | // set sort 0 101 | this.sort = 0; 102 | } else { 103 | 104 | /* 105 | * 106 | * Blaze.getData takes as a parameter an html element 107 | * and will return the data context that was bound when 108 | * that html element was rendered! 109 | */ 110 | if(!before) { 111 | 112 | /* 113 | * if it was dragged into the first position grab the 114 | * next element's data context and subtract one from the rank 115 | */ 116 | this.sort = Blaze.getData(after).sort - 1; 117 | } else if(!after) { 118 | /* 119 | * if it was dragged into the last position grab the 120 | * previous element's data context and add one to the rank 121 | */ 122 | this.sort = Blaze.getData(before).sort + 1; 123 | } else { 124 | 125 | /* 126 | * else take the average of the two ranks of the previous 127 | * and next elements 128 | */ 129 | this.sort = (Blaze.getData(after).sort + Blaze.getData(before).sort) / 2; 130 | } 131 | } 132 | }, 133 | getLabelIndex: function(boardId, labelId) { 134 | var board = Boards.findOne(boardId), 135 | labels = {}; 136 | _.each(board.labels, function(a, b) { 137 | labels[a._id] = b; 138 | }); 139 | return { 140 | index: labels[labelId], 141 | key: function(key) { 142 | return 'labels.' + labels[labelId] + '.' + key; 143 | } 144 | } 145 | } 146 | }; 147 | 148 | InputsCache = new ReactiveDict('inputsCache'); 149 | 150 | Blaze.registerHelper('inputCache', function (formName, formInstance) { 151 | var key = formName.toString() + '-' + formInstance.toString(); 152 | return InputsCache.get(key); 153 | }); 154 | -------------------------------------------------------------------------------- /client/styles/aging.import.styl: -------------------------------------------------------------------------------- 1 | // Currently not used 2 | 3 | .aging-regular.aging-level-0 { 4 | opacity: 1; 5 | } 6 | 7 | .aging-regular.aging-level-1 { 8 | opacity: .75; 9 | } 10 | 11 | .aging-regular.aging-level-2 { 12 | opacity: .5; 13 | } 14 | 15 | .aging-regular.aging-level-3 { 16 | opacity: .25; 17 | } 18 | 19 | .aging-pirate { 20 | background-position: top left, top right; 21 | background-repeat: no-repeat; 22 | } 23 | 24 | .aging-pirate .list-card-details { 25 | background-position: bottom left, bottom right; 26 | background-repeat: no-repeat; 27 | } 28 | 29 | .aging-pirate.aging-level-1 { 30 | background-color: #faf6ef; 31 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/53102e7b253303f6eb34f3622da9bb32/TopLeftLevel1.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/b6830a5537c94f982a32b280cff6b97d/TopRightLevel1.png); 32 | -webkit-box-shadow: inset 0 0 15px rgba(205, 172, 132, .4); 33 | box-shadow: inset 0 0 15px rgba(205, 172, 132, .4); 34 | } 35 | 36 | .aging-pirate.aging-level-1 .list-card-operation { 37 | background-color: #faf6ef; 38 | } 39 | 40 | .aging-pirate.aging-level-1 .list-card-cover, .aging-pirate.aging-level-1 .list-card-stickers-area .stickers { 41 | opacity: .75; 42 | } 43 | 44 | .aging-pirate.aging-level-1 .list-card-details { 45 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/accbe663e1d810cfb14d7e978845e27f/BottomLeftLevel1.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/2fdf1b0eadc4b04edaece4c4b41aa699/BottomRightLevel1.png); 46 | } 47 | 48 | .aging-pirate.aging-level-1.has-stickers .list-card-details { 49 | background-color: rgba(250, 246, 239, .7); 50 | } 51 | 52 | .aging-pirate.aging-level-1.active-card, .aging-pirate.aging-level-1.active-card .list-card-operation { 53 | background-color: #f4ead7; 54 | } 55 | 56 | .aging-pirate.aging-level-1.active-card.has-stickers .list-card-details { 57 | background-color: rgba(244, 234, 215, .7); 58 | } 59 | 60 | .aging-pirate.aging-level-2 { 61 | background-color: #f6eedf; 62 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/18f4120762d23b79c07e5fda7a2056d0/TopLeftLevel2.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/c74a8a1f361b08433f2a8b05470917c3/TopRightLevel2.png); 63 | -webkit-box-shadow: inset 0 0 25px rgba(205, 172, 132, .5); 64 | box-shadow: inset 0 0 25px rgba(205, 172, 132, .5); 65 | } 66 | 67 | .aging-pirate.aging-level-2 .list-card-operation { 68 | background-color: #f5ecdb; 69 | } 70 | 71 | .aging-pirate.aging-level-2 .list-card-cover, .aging-pirate.aging-level-2 .list-card-stickers-area .stickers { 72 | opacity: .5; 73 | } 74 | 75 | .aging-pirate.aging-level-2 .list-card-details { 76 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/602baf1ea202cc5f5c34a30bd5e364f4/BottomLeftLevel2.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/ac74c3df4a788a4eeb59d8a0f6d0f235/BottomRightLevel2.png); 77 | } 78 | 79 | .aging-pirate.aging-level-2.has-stickers .list-card-details { 80 | background-color: rgba(246, 238, 223, .7); 81 | } 82 | 83 | .aging-pirate.aging-level-2.active-card, .aging-pirate.aging-level-2.active-card .list-card-operation { 84 | background-color: #efe1c8; 85 | } 86 | 87 | .aging-pirate.aging-level-2.active-card.has-stickers .list-card-details { 88 | background-color: rgba(239, 225, 200, .7); 89 | } 90 | 91 | .aging-pirate.aging-level-3 { 92 | background-color: #efe1c8; 93 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/194454130e88f74111874a8935ecf3ba/TopLeftLevel3.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/8c21cb29b9a909eecc47678db2ef1654/TopRightLevel3.png); 94 | -webkit-box-shadow: inset 0 0 40px rgba(205, 172, 132, .6); 95 | box-shadow: inset 0 0 40px rgba(205, 172, 132, .6); 96 | } 97 | 98 | .aging-pirate.aging-level-3 .list-card-operation { 99 | background-color: #efe1c8; 100 | } 101 | 102 | .aging-pirate.aging-level-3 .list-card-cover, .aging-pirate.aging-level-3 .list-card-stickers-area .stickers { 103 | opacity: .25; 104 | } 105 | 106 | .aging-pirate.aging-level-3 .list-card-details { 107 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/d4bb1b3ae5fd2aef9beaf2600113073f/BottomLeftLevel3.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/5737b3080b474c0d96439d6852e31ec3/BottomRightLevel3.png); 108 | } 109 | 110 | .aging-pirate.aging-level-3.has-stickers .list-card-details { 111 | background-color: rgba(239, 225, 200, .7); 112 | } 113 | 114 | .aging-pirate.aging-level-3.active-card, .aging-pirate.aging-level-3.active-card .list-card-operation { 115 | background-color: #e8d4b0; 116 | } 117 | 118 | .aging-pirate.aging-level-3.active-card.has-stickers .list-card-details { 119 | background-color: rgba(232, 212, 176, .7); 120 | } 121 | 122 | .aging-pirate.aging-level-3.aging-treasure .list-card-details { 123 | background-position: bottom left, bottom right, bottom 1px right 10px; 124 | background-image: url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/d4bb1b3ae5fd2aef9beaf2600113073f/BottomLeftLevel3.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/5737b3080b474c0d96439d6852e31ec3/BottomRightLevel3.png), url(https://d78fikflryjgj.cloudfront.net/images/powerups/card-aging/85890fb25d1b8909c095b39344a5e804/BottomRightLevel3Treasure.png); 125 | } 126 | -------------------------------------------------------------------------------- /server/publications/boards.js: -------------------------------------------------------------------------------- 1 | // This is the publication used to display the board list. We publish all the 2 | // non-archived boards: 3 | // 1. that the user is a member of 4 | // 2. the user has starred 5 | Meteor.publish('boards', function() { 6 | // Ensure that the user is connected 7 | check(this.userId, String); 8 | 9 | // Defensive programming to verify that starredBoards has the expected 10 | // format -- since the field is in the `profile` a user can modify it. 11 | var starredBoards = Users.findOne(this.userId).profile.starredBoards || []; 12 | check(starredBoards, [String]); 13 | 14 | return Boards.find({ 15 | archived: false, 16 | $or: [ 17 | { 'members.userId': this.userId }, 18 | { _id: { $in: starredBoards } } 19 | ] 20 | }, { 21 | fields: { 22 | _id: 1, 23 | slug: 1, 24 | title: 1, 25 | background: 1 26 | } 27 | }); 28 | }); 29 | 30 | Meteor.publishComposite('board', function(boardId, slug) { 31 | check(boardId, String); 32 | check(slug, String); 33 | return { 34 | find: function() { 35 | return Boards.find({ 36 | _id: boardId, 37 | slug: slug, 38 | archived: false, 39 | $or: [ 40 | // If the board is not public the user has to be a member of 41 | // it to see it. 42 | { permission: 'public' }, 43 | { 'members.userId': this.userId } 44 | ] 45 | }, { limit: 1 }); 46 | }, 47 | // XXX For efficiency we shouldn't publish all activities and comments 48 | // in this publication, and instead use the card publication for that 49 | // purpose. 50 | children: [ 51 | // Lists 52 | { 53 | find: function(board) { 54 | return Lists.find({ 55 | boardId: board._id 56 | }); 57 | } 58 | }, 59 | 60 | // Cards and cards comments 61 | // XXX Originally we were publishing the card documents as a child 62 | // of the list publication defined above using the following 63 | // selector `{ listId: list._id }`. But it was causing a race 64 | // condition in publish-composite, that I documented here: 65 | // 66 | // https://github.com/englue/meteor-publish-composite/issues/29 67 | // 68 | // I then tried to replace publish-composite by cottz:publish, but 69 | // it had a similar problem: 70 | // 71 | // https://github.com/Goluis/cottz-publish/issues/4 72 | // https://github.com/libreboard/libreboard/pull/78 73 | // 74 | // The current state of relational publishing in meteor is a bit 75 | // sad, there are a lot of various packages, with various APIs, some 76 | // of them are unmaintained. Fortunately this is something that will 77 | // be fixed by meteor-core at some point: 78 | // 79 | // https://trello.com/c/BGvIwkEa/48-easy-joins-in-subscriptions 80 | // 81 | // And in the meantime our code below works pretty well -- it's not 82 | // even a hack! 83 | { 84 | find: function(board) { 85 | return Cards.find({ 86 | boardId: board._id 87 | }); 88 | }, 89 | 90 | children: [ 91 | // comments 92 | { 93 | find: function(card) { 94 | return CardComments.find({ 95 | cardId: card._id 96 | }); 97 | } 98 | }, 99 | // Attachments 100 | { 101 | find: function(card) { 102 | return Attachments.find({ 103 | cardId: card._id 104 | }); 105 | } 106 | } 107 | ] 108 | }, 109 | 110 | // Board members 111 | { 112 | find: function(board) { 113 | return Users.find({ 114 | _id: { $in: _.pluck(board.members, 'userId') } 115 | }); 116 | } 117 | }, 118 | 119 | // Activities 120 | { 121 | find: function(board) { 122 | return Activities.find({ 123 | boardId: board._id 124 | }); 125 | }, 126 | children: [ 127 | // Activities members. In general activity members are 128 | // already published in the board member publication above, 129 | // but it can be the case that a board member was removed 130 | // and we still want to read his activity history. 131 | // XXX A more efficient way to do this would be to keep a 132 | // {active: Boolean} field in the `board.members` so we can 133 | // publish former board members in one single publication, 134 | // and have a easy way to distinguish between current and 135 | // former members. 136 | { 137 | find: function(activity) { 138 | if (activity.memberId) 139 | return Users.find(activity.memberId); 140 | } 141 | } 142 | ] 143 | } 144 | ] 145 | } 146 | }); 147 | -------------------------------------------------------------------------------- /client/styles/icons.styl: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Trellicons Regular; 3 | src: url(/fonts/trellicons-regular.woff) format("woff"), 4 | url(/fonts/trellicons-regular.ttf) format("truetype"), 5 | url(/fonts/trellicons-regular.svg#trelliconsregular) format("svg"); 6 | font-weight: 400; 7 | font-style: normal 8 | } 9 | 10 | .icon-lg, 11 | .icon-sm { 12 | color: #a6a6a6; 13 | display: inline-block; 14 | font-family: Trellicons Regular; 15 | -webkit-font-smoothing: antialiased; 16 | font-style: normal; 17 | font-weight: 400; 18 | line-height: 1; 19 | text-align: center; 20 | text-decoration: none 21 | 22 | &.custom-image { 23 | text-indent: 100%; 24 | overflow: hidden 25 | 26 | img { 27 | overflow: hidden; 28 | position: absolute; 29 | top: 0; 30 | left: 0 31 | } 32 | } 33 | } 34 | 35 | .icon-lg.dark, 36 | .icon-sm.dark, 37 | .icon-lg.dark-hover:hover, 38 | .icon-sm.dark-hover:hover, 39 | .dark-hover:hover .icon-lg, 40 | .dark-hover:hover .icon-sm { 41 | color: #686868; 42 | text-decoration: none 43 | } 44 | 45 | .icon-lg.light, 46 | .icon-sm.light, 47 | .icon-lg.light-hover:hover, 48 | .icon-sm.light-hover:hover, 49 | .light-hover:hover .icon-lg, 50 | .light-hover:hover .icon-sm { 51 | color: #fff; 52 | text-decoration: none 53 | } 54 | 55 | .icon-sm { 56 | height: 18px; 57 | font-size: 12px; 58 | line-height: 18px; 59 | width: 18px 60 | } 61 | 62 | .icon-lg { 63 | height: 30px; 64 | font-size: 20px; 65 | line-height: 30px; 66 | width: 30px 67 | } 68 | 69 | .icon-org:before, 70 | .icon-org-private:before { 71 | content: "\f000" 72 | } 73 | 74 | .icon-member:before { 75 | content: "\f001" 76 | } 77 | 78 | .icon-board:before { 79 | content: "\f002" 80 | } 81 | 82 | .icon-list:before { 83 | content: "\f003" 84 | } 85 | 86 | .icon-card:before { 87 | content: "\f004" 88 | } 89 | 90 | .icon-notification:before { 91 | content: "\f005" 92 | } 93 | 94 | .icon-trophy:before { 95 | content: "\f006" 96 | } 97 | 98 | .icon-rocket:before { 99 | content: "\f007" 100 | } 101 | 102 | .icon-cal:before { 103 | content: "\f039" 104 | } 105 | 106 | .icon-briefcase:before { 107 | content: "\f037" 108 | } 109 | 110 | .icon-briefcase-bc-color { 111 | color: #71838e 112 | } 113 | 114 | .icon-crown:before { 115 | content: "\f041" 116 | } 117 | 118 | .icon-crown.icon-color { 119 | color: #a89d24 120 | } 121 | 122 | .icon-comment:before { 123 | content: "\f008" 124 | } 125 | 126 | .icon-label:before { 127 | content: "\f009" 128 | } 129 | 130 | .icon-attachment:before { 131 | content: "\f010" 132 | } 133 | 134 | .icon-checklist:before { 135 | content: "\f011" 136 | } 137 | 138 | .icon-vote:before { 139 | content: "\f012" 140 | } 141 | 142 | .icon-clock:before { 143 | content: "\f013" 144 | } 145 | 146 | .icon-archive:before { 147 | content: "\f014" 148 | } 149 | 150 | .icon-activity:before { 151 | content: "\f015" 152 | } 153 | 154 | .icon-follow:before { 155 | content: "\f016" 156 | } 157 | 158 | .icon-kiwi:before { 159 | content: "\f017" 160 | } 161 | 162 | .icon-sticker:before { 163 | content: "\f040" 164 | } 165 | 166 | .icon-cover:before { 167 | content: "\f044" 168 | } 169 | 170 | .icon-gear:before { 171 | content: "\f018" 172 | } 173 | 174 | .icon-info:before { 175 | content: "\f019" 176 | } 177 | 178 | .icon-search:before { 179 | content: "\f020" 180 | } 181 | 182 | .icon-pin:before { 183 | content: "\f021" 184 | } 185 | 186 | .icon-camera:before { 187 | content: "\f038" 188 | } 189 | 190 | .icon-menu:before { 191 | content: "\f022" 192 | } 193 | 194 | .icon-close:before { 195 | content: "\f023" 196 | } 197 | 198 | .icon-leftarrow:before { 199 | content: "\f024" 200 | } 201 | 202 | .icon-rightarrow:before { 203 | content: "\f025" 204 | } 205 | 206 | .icon-add:before { 207 | content: "\f026" 208 | } 209 | 210 | .icon-remove:before { 211 | content: "\f027" 212 | } 213 | 214 | .icon-check:before { 215 | content: "\f028" 216 | } 217 | 218 | .icon-reopen:before { 219 | content: "\f029" 220 | } 221 | 222 | .icon-open:before { 223 | content: "\f045" 224 | } 225 | 226 | .icon-movedown:before,.icon-move:before { 227 | content: "\f030" 228 | } 229 | 230 | .icon-movedown { 231 | -webkit-transform: rotate(90deg); 232 | transform: rotate(90deg) 233 | } 234 | 235 | .icon-edit:before { 236 | content: "\f031" 237 | } 238 | 239 | .icon-desc:before { 240 | content: "\f046" 241 | } 242 | 243 | .icon-star:before { 244 | content: "\f042" 245 | } 246 | 247 | .icon-star-active { 248 | color: #e6bf00 249 | } 250 | 251 | .icon-filter:before { 252 | content: "\f043" 253 | } 254 | 255 | .icon-private:before { 256 | content: "\f032" 257 | } 258 | 259 | .icon-private { 260 | color: #990f0f 261 | } 262 | 263 | .dark-hover:hover .icon-private, 264 | .icon-private.dark-hover:hover { 265 | color: #7d0c0c 266 | } 267 | 268 | .icon-org-private { 269 | color: #e6bf00 270 | } 271 | 272 | .dark-hover:hover .icon-org-private, 273 | .icon-org-private.dark-hover:hover { 274 | color: #c7a600 275 | } 276 | 277 | .icon-public:before { 278 | content: "\f033" 279 | } 280 | 281 | .icon-public { 282 | color: #3d990f 283 | } 284 | 285 | .dark-hover:hover .icon-public, 286 | .icon-public.dark-hover:hover { 287 | color: #327d0c 288 | } 289 | 290 | .icon-facebook:before { 291 | content: "\f034" 292 | } 293 | 294 | .icon-facebook-brand-color { 295 | color: #3b5998 296 | } 297 | 298 | .icon-google:before { 299 | content: "\f035" 300 | } 301 | 302 | .icon-google-brand-color { 303 | color: #e11a31 304 | } 305 | 306 | .icon-twitter:before { 307 | content: "\f036" 308 | } 309 | 310 | .icon-twitter-brand-color { 311 | color: #00aced 312 | } 313 | 314 | .business-class-mark { 315 | background-repeat: no-repeat; 316 | display: block 317 | } 318 | 319 | .emoji { 320 | height: 18px; 321 | width: 18px; 322 | vertical-align: text-bottom 323 | } 324 | -------------------------------------------------------------------------------- /client/views/boards/templates.html: -------------------------------------------------------------------------------- 1 | 2 |{{_ 'no-boards'}}
37 | {{ / each }} 38 |
78 | 85 | {{_ 'home-login'}} 86 |
87 |