diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/client/main.css b/client/main.css new file mode 100644 index 0000000..ed5e77e --- /dev/null +++ b/client/main.css @@ -0,0 +1,126 @@ +/* CSS declarations go here */ +body { + font-family: sans-serif; + background-color: #315481; + background-image: linear-gradient(to bottom, #315481, #918e82 100%); + background-attachment: fixed; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + padding: 0; + margin: 0; + + font-size: 14px; +} + +.container { + max-width: 600px; + margin: 0 auto; + min-height: 100%; + background: white; +} + +header { + background: #d2edf4; + background-image: linear-gradient(to bottom, #d0edf5, #e1e5f0 100%); + padding: 20px 15px 15px 15px; + position: relative; +} + +#login-buttons { + display: block; +} + +h1 { + font-size: 1.5em; + margin: 0; + margin-bottom: 10px; + display: inline-block; + margin-right: 1em; +} + +form { + margin-top: 10px; + margin-bottom: -10px; + position: relative; +} + +.new-task input { + box-sizing: border-box; + padding: 10px 0; + background: transparent; + border: none; + width: 100%; + padding-right: 80px; + font-size: 1em; +} + +.new-task input:focus{ + outline: 0; +} + +ul { + margin: 0; + padding: 0; + background: white; +} + +.delete { + float: right; + font-weight: bold; + background: none; + font-size: 1em; + border: none; + position: relative; +} + +li { + position: relative; + list-style: none; + padding: 15px; + border-bottom: #eee solid 1px; +} + +li .text { + margin-left: 10px; +} + +li.checked { + color: #888; +} + +li.checked .text { + text-decoration: line-through; +} + +li.private { + background: #eee; + border-color: #ddd; +} + +header .hide-completed { + float: right; +} + +.toggle-private { + margin-left: 5px; +} + +@media (max-width: 600px) { + li { + padding: 12px 15px; + } + + .search { + width: 150px; + clear: both; + } + + .new-task input { + padding-bottom: 5px; + } +} diff --git a/client/main.html b/client/main.html new file mode 100644 index 0000000..7c48505 --- /dev/null +++ b/client/main.html @@ -0,0 +1,3 @@ + + simple + diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..739c50f --- /dev/null +++ b/client/main.js @@ -0,0 +1,2 @@ +import '../imports/startup/accounts-config.js'; +import '../imports/ui/body.js'; diff --git a/imports/api/tasks.js b/imports/api/tasks.js new file mode 100644 index 0000000..d7d72dd --- /dev/null +++ b/imports/api/tasks.js @@ -0,0 +1,72 @@ +import { Meteor } from 'meteor/meteor'; +import { Mongo } from 'meteor/mongo'; +import { check } from 'meteor/check'; + +export const Tasks = new Mongo.Collection('tasks'); + +if (Meteor.isServer) { + // This code only runs on the server + // Only publish tasks that are public or belong to the current user + Meteor.publish('tasks', function tasksPublication() { + return Tasks.find({ + $or: [ + { private: { $ne: true } }, + { owner: this.userId }, + ], + }); + }); +} + +Meteor.methods({ + 'tasks.insert'(text) { + check(text, String); + + // Make sure the user is logged in before inserting a task + if (! this.userId) { + throw new Meteor.Error('not-authorized'); + } + + Tasks.insert({ + text, + createdAt: new Date(), + owner: this.userId, + username: Meteor.users.findOne(this.userId).username, + }); + }, + 'tasks.remove'(taskId) { + check(taskId, String); + + const task = Tasks.findOne(taskId); + if (task.private && task.owner !== this.userId) { + // If the task is private, make sure only the owner can delete it + throw new Meteor.Error('not-authorized'); + } + + Tasks.remove(taskId); + }, + 'tasks.setChecked'(taskId, setChecked) { + check(taskId, String); + check(setChecked, Boolean); + + const task = Tasks.findOne(taskId); + if (task.private && task.owner !== this.userId) { + // If the task is private, make sure only the owner can check it off + throw new Meteor.Error('not-authorized'); + } + + Tasks.update(taskId, { $set: { checked: setChecked } }); + }, + 'tasks.setPrivate'(taskId, setToPrivate) { + check(taskId, String); + check(setToPrivate, Boolean); + + const task = Tasks.findOne(taskId); + + // Make sure only the task owner can make a task private + if (task.owner !== this.userId) { + throw new Meteor.Error('not-authorized'); + } + + Tasks.update(taskId, { $set: { private: setToPrivate } }); + }, +}); diff --git a/imports/api/tasks.tests.js b/imports/api/tasks.tests.js new file mode 100644 index 0000000..9b61c5a --- /dev/null +++ b/imports/api/tasks.tests.js @@ -0,0 +1,41 @@ +/* eslint-env mocha */ + +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import { assert } from 'meteor/practicalmeteor:chai'; + +import { Tasks } from './tasks.js'; + +if (Meteor.isServer) { + describe('Tasks', () => { + describe('methods', () => { + const userId = Random.id(); + let taskId; + + beforeEach(() => { + Tasks.remove({}); + taskId = Tasks.insert({ + text: 'test task', + createdAt: new Date(), + owner: userId, + username: 'tmeasday', + }); + }); + + it('can delete owned task', () => { + // Find the internal implementation of the task method so we can + // test it in isolation + const deleteTask = Meteor.server.method_handlers['tasks.remove']; + + // Set up a fake method invocation that looks like what the method expects + const invocation = { userId }; + + // Run the method with `this` set to the fake invocation + deleteTask.apply(invocation, [taskId]); + + // Verify that the method does what we expected + assert.equal(Tasks.find().count(), 0); + }); + }); + }); +} diff --git a/imports/startup/accounts-config.js b/imports/startup/accounts-config.js new file mode 100644 index 0000000..7e4f7e5 --- /dev/null +++ b/imports/startup/accounts-config.js @@ -0,0 +1,5 @@ +import { Accounts } from 'meteor/accounts-base'; + +Accounts.ui.config({ + passwordSignupFields: 'USERNAME_ONLY', +}); diff --git a/imports/ui/body.html b/imports/ui/body.html new file mode 100644 index 0000000..6a9e4e1 --- /dev/null +++ b/imports/ui/body.html @@ -0,0 +1,26 @@ + +
+
+

Todo List ({{incompleteCount}})

+ + + + {{> loginButtons}} + + {{#if currentUser}} +
+ +
+ {{/if}} +
+ + +
+ diff --git a/imports/ui/body.js b/imports/ui/body.js new file mode 100644 index 0000000..8a4407f --- /dev/null +++ b/imports/ui/body.js @@ -0,0 +1,48 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { ReactiveDict } from 'meteor/reactive-dict'; + +import { Tasks } from '../api/tasks.js'; + +import './task.js'; +import './body.html'; + +Template.body.onCreated(function bodyOnCreated() { + this.state = new ReactiveDict(); + Meteor.subscribe('tasks'); +}); + +Template.body.helpers({ + tasks() { + const instance = Template.instance(); + if (instance.state.get('hideCompleted')) { + // If hide completed is checked, filter tasks + return Tasks.find({ checked: { $ne: true } }, { sort: { createdAt: -1 } }); + } + // Otherwise, return all of the tasks + return Tasks.find({}, { sort: { createdAt: -1 } }); + }, + incompleteCount() { + return Tasks.find({ checked: { $ne: true } }).count(); + }, +}); + +Template.body.events({ + 'submit .new-task'(event) { + // Prevent default browser form submit + event.preventDefault(); + + // Get value from form element + const target = event.target; + const text = target.text.value; + + // Insert a task into the collection + Meteor.call('tasks.insert', text); + + // Clear form + target.text.value = ''; + }, + 'change .hide-completed input'(event, instance) { + instance.state.set('hideCompleted', event.target.checked); + }, +}); diff --git a/imports/ui/task.html b/imports/ui/task.html new file mode 100644 index 0000000..218789f --- /dev/null +++ b/imports/ui/task.html @@ -0,0 +1,19 @@ + diff --git a/imports/ui/task.js b/imports/ui/task.js new file mode 100644 index 0000000..6b51496 --- /dev/null +++ b/imports/ui/task.js @@ -0,0 +1,23 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import './task.html'; + +Template.task.helpers({ + isOwner() { + return this.owner === Meteor.userId(); + }, +}); + +Template.task.events({ + 'click .toggle-checked'() { + // Set the checked property to the opposite of its current value + Meteor.call('tasks.setChecked', this._id, !this.checked); + }, + 'click .delete'() { + Meteor.call('tasks.remove', this._id); + }, + 'click .toggle-private'() { + Meteor.call('tasks.setPrivate', this._id, !this.private); + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2afe1d5 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "mindcontrol2", + "private": true, + "scripts": { + "start": "meteor run" + }, + "dependencies": { + "meteor-node-stubs": "~0.2.0" + } +} diff --git a/server/main.js b/server/main.js new file mode 100644 index 0000000..ab941a4 --- /dev/null +++ b/server/main.js @@ -0,0 +1 @@ +import '../imports/api/tasks.js';