diff --git a/querulous/README b/querulous/README
new file mode 100644
index 0000000..deca6d4
--- /dev/null
+++ b/querulous/README
@@ -0,0 +1,5 @@
+This advanced todo list demonstrates a modern AJAX-based web application. This is a work in progress, and we plan to add several features in the future releases. For now you can check it out to learn how to:
+
+- Integrate authentication, and security.
+- Use AJAX and the Javascript reverse routing.
+- Integrate with compiled assets - LESS CSS and CoffeeScript.
\ No newline at end of file
diff --git a/querulous/app/Global.scala b/querulous/app/Global.scala
new file mode 100644
index 0000000..ed02fbc
--- /dev/null
+++ b/querulous/app/Global.scala
@@ -0,0 +1,62 @@
+import play.api._
+
+import models._
+import anorm._
+
+object Global extends GlobalSettings {
+
+ override def onStart(app: Application) {
+ InitialData.insert()
+ }
+
+}
+
+/**
+ * Initial set of data to be imported
+ * in the sample application.
+ */
+object InitialData {
+
+ def date(str: String) = new java.text.SimpleDateFormat("yyyy-MM-dd").parse(str)
+
+ def insert() = {
+
+ if(User.findAll.isEmpty) {
+
+ Seq(
+ User("guillaume@sample.com", "Guillaume Bort", "secret"),
+ User("maxime@sample.com", "Maxime Dantec", "secret"),
+ User("sadek@sample.com", "Sadek Drobi", "secret"),
+ User("erwan@sample.com", "Erwan Loisant", "secret")
+ ).foreach(User.create)
+
+ Seq(
+ Project(Id(1), "Play framework", "Play 2.0") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"),
+ Project(Id(2), "Play framework", "Play 1.2.4") -> Seq("guillaume@sample.com", "erwan@sample.com"),
+ Project(Id(3), "Play framework", "Website") -> Seq("guillaume@sample.com", "maxime@sample.com"),
+ Project(Id(4), "Zenexity", "Secret project") -> Seq("guillaume@sample.com", "maxime@sample.com", "sadek@sample.com", "erwan@sample.com"),
+ Project(Id(5), "Zenexity", "Playmate") -> Seq("maxime@sample.com"),
+ Project(Id(6), "Personal", "Things to do") -> Seq("guillaume@sample.com"),
+ Project(Id(7), "Zenexity", "Play samples") -> Seq("guillaume@sample.com", "maxime@sample.com"),
+ Project(Id(8), "Personal", "Private") -> Seq("maxime@sample.com"),
+ Project(Id(9), "Personal", "Private") -> Seq("guillaume@sample.com"),
+ Project(Id(10), "Personal", "Private") -> Seq("erwan@sample.com"),
+ Project(Id(11), "Personal", "Private") -> Seq("sadek@sample.com")
+ ).foreach {
+ case (project,members) => Project.create(project, members)
+ }
+
+ /*Seq(
+ Task(NotAssigned, "Todo", 1, "Fix the documentation", false, None, Some("guillaume@sample.com")),
+ Task(NotAssigned, "Urgent", 1, "Prepare the beta release", false, Some(date("2011-11-15")), None),
+ Task(NotAssigned, "Todo", 9, "Buy some milk", false, None, None),
+ Task(NotAssigned, "Todo", 2, "Check 1.2.4-RC2", false, Some(date("2011-11-18")), Some("guillaume@sample.com")),
+ Task(NotAssigned, "Todo", 7, "Finish zentask integration", true, Some(date("2011-11-15")), Some("maxime@sample.com")),
+ Task(NotAssigned, "Todo", 4, "Release the secret project", false, Some(date("2012-01-01")), Some("sadek@sample.com"))
+ ).foreach(Task.create)*/
+
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/querulous/app/assets/javascripts/main.coffee b/querulous/app/assets/javascripts/main.coffee
new file mode 100644
index 0000000..f096d97
--- /dev/null
+++ b/querulous/app/assets/javascripts/main.coffee
@@ -0,0 +1,420 @@
+# -----------------------------------------------
+# MAIN
+# -----------------------------------------------
+# DISCLAMER :
+# If you're used to Backbone.js, you may be
+# confused by the absence of models, but the goal
+# of this sample is to demonstrate some features
+# of Play including the template engine.
+# I'm not using client-side templating nor models
+# for this purpose, and I do not recommend this
+# behavior for real life projects.
+# -----------------------------------------------
+
+# Just a log helper
+log = (args...) ->
+ console.log.apply console, args if console.log?
+
+# ------------------------------- DROP DOWN MENUS
+$(".options dt, .users dt").live "click", (e) ->
+ e.preventDefault()
+ if $(e.target).parent().hasClass("opened")
+ $(e.target).parent().removeClass("opened")
+ else
+ $(e.target).parent().addClass("opened")
+ $(document).one "click", ->
+ $(e.target).parent().removeClass("opened")
+ false
+
+# --------------------------------- EDIT IN PLACE
+$.fn.editInPlace = (method, options...) ->
+ this.each ->
+ methods =
+ # public methods
+ init: (options) ->
+ valid = (e) =>
+ newValue = @input.val()
+ options.onChange.call(options.context, newValue)
+ cancel = (e) =>
+ @el.show()
+ @input.hide()
+ @el = $(this).dblclick(methods.edit)
+ @input = $("")
+ .insertBefore(@el)
+ .keyup (e) ->
+ switch(e.keyCode)
+ # Enter key
+ when 13 then $(this).blur()
+ # Escape key
+ when 27 then cancel(e)
+ .blur(valid)
+ .hide()
+ edit: ->
+ @input
+ .val(@el.text())
+ .show()
+ .focus()
+ .select()
+ @el.hide()
+ close: (newName) ->
+ @el.text(newName).show()
+ @input.hide()
+ # jQuery approach: http://docs.jquery.com/Plugins/Authoring
+ if ( methods[method] )
+ return methods[ method ].apply(this, options)
+ else if (typeof method == 'object')
+ return methods.init.call(this, method)
+ else
+ $.error("Method " + method + " does not exist.")
+
+# ---------------------------------------- DRAWER
+class Drawer extends Backbone.View
+ initialize: ->
+ $("#newGroup").click @addGroup
+ # HTML is our model
+ @el.children("li").each (i,group) ->
+ new Group
+ el: $(group)
+ $("li",group).each (i,project) ->
+ new Project
+ el: $(project)
+ addGroup: ->
+ jsRoutes.controllers.Projects.addGroup().ajax
+ success: (data) ->
+ _view = new Group
+ el: $(data).appendTo("#projects")
+ _view.el.find(".groupName").editInPlace("edit")
+ error: (err) ->
+ # TODO: Deal with
+
+# ---------------------------------------- GROUPS
+class Group extends Backbone.View
+ events:
+ "click .toggle" : "toggle"
+ "click .newProject" : "newProject"
+ "click .deleteGroup" : "deleteGroup"
+ initialize: ->
+ @id = @el.attr("data-group")
+ @name = $(".groupName", @el).editInPlace
+ context: this
+ onChange: @renameGroup
+ newProject: (e) ->
+ e.preventDefault()
+ @el.removeClass("closed")
+ jsRoutes.controllers.Projects.add().ajax
+ context: this
+ data:
+ group: @el.attr("data-group")
+ success: (tpl) ->
+ _list = $("ul",@el)
+ _view = new Project
+ el: $(tpl).appendTo(_list)
+ _view.el.find(".name").editInPlace("edit")
+ error: (err) ->
+ $.error("Error: " + err)
+ deleteGroup: (e) ->
+ e.preventDefault()
+ false if (!confirm "Remove group and projects inside?")
+ id = @el.attr("data-group-id")
+ @loading(true)
+ jsRoutes.controllers.Projects.deleteGroup(@id).ajax
+ context: this
+ success: ->
+ @el.remove()
+ @loading(false)
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ renameGroup: (name) =>
+ @loading(true)
+ jsRoutes.controllers.Projects.renameGroup(@id).ajax
+ context: this
+ data:
+ name: name
+ success: (data) ->
+ @loading(false)
+ @name.editInPlace("close", data)
+ @el.attr("data-group", data)
+ @id = @el.attr("data-group")
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ toggle: (e) ->
+ e.preventDefault()
+ @el.toggleClass("closed")
+ false
+ loading: (display) ->
+ if (display)
+ @el.children(".options").hide()
+ @el.children(".loader").show()
+ else
+ @el.children(".options").show()
+ @el.children(".loader").hide()
+
+# --------------------------------------- PROJECT
+class Project extends Backbone.View
+ events:
+ "click .delete" : "deleteProject"
+ initialize: ->
+ @id = @el.attr("data-project")
+ @name = $(".name", @el).editInPlace
+ context: this
+ onChange: @renameProject
+ renameProject: (name) ->
+ @loading(true)
+ jsRoutes.controllers.Projects.rename(@id).ajax
+ context: this
+ data:
+ name: name
+ success: (data) ->
+ @loading(false)
+ @name.editInPlace("close", data)
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ deleteProject: (e) ->
+ e.preventDefault()
+ @loading(true)
+ jsRoutes.controllers.Projects.delete(@id).ajax
+ context: this
+ success: ->
+ @el.remove()
+ @loading(false)
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ false
+ loading: (display) ->
+ if (display)
+ @el.children(".delete").hide()
+ @el.children(".loader").show()
+ else
+ @el.children(".delete").show()
+ @el.children(".loader").hide()
+
+# ---------------------------------------- ROUTER
+class AppRouter extends Backbone.Router
+ initialize: ->
+ @currentApp = new Tasks
+ el: $("#main")
+ routes:
+ "" : "index"
+ "/projects/:project/tasks" : "tasks"
+ index: ->
+ # show dashboard
+ $("#main").load "/ #main"
+ tasks: (project) ->
+ # load project || display app
+ currentApp = @currentApp
+ $("#main").load "/projects/" + project + "/tasks", (tpl) ->
+ currentApp.render(project)
+
+# ----------------------------------------- TASKS
+class Tasks extends Backbone.View
+ events:
+ "click .newFolder" : "newFolder"
+ "click .list .action" : "removeUser"
+ "click .addUserList .action" : "addUser"
+ render: (project) ->
+ @project = project
+ # HTML is our model
+ @folders = $.map $(".folder", @el), (folder) =>
+ new TaskFolder
+ el: $(folder)
+ project: @project
+ newFolder: (e) ->
+ e.preventDefault()
+ jsRoutes.controllers.Tasks.addFolder(@project).ajax
+ context: this
+ success: (tpl) ->
+ newFolder = new TaskFolder
+ el: $(tpl).insertBefore(".newFolder")
+ project: @project
+ newFolder.el.find("header > h3").editInPlace("edit")
+ error: (err) ->
+ $.error("Error: " + err)
+ false
+ removeUser: (e) ->
+ e.preventDefault()
+ jsRoutes.controllers.Projects.removeUser(@project).ajax
+ context: this
+ data:
+ user: $(e.target).parent().data('user-id')
+ success: ->
+ $(e.target).parent().appendTo(".addUserList")
+ error: (err) ->
+ $.error("Error: " + err)
+ false
+ addUser: (e) ->
+ e.preventDefault()
+ jsRoutes.controllers.Projects.addUser(@project).ajax
+ context: this
+ data:
+ user: $(e.target).parent().data('user-id')
+ success: ->
+ $(e.target).parent().appendTo(".users .list")
+ error: (err) ->
+ $.error("Error: " + err)
+ false
+
+# ---------------------------------- TASKS FOLDER
+class TaskFolder extends Backbone.View
+ events:
+ "click .deleteCompleteTasks" : "deleteCompleteTasks"
+ "click .deleteAllTasks" : "deleteAllTasks"
+ "click .deleteFolder" : "deleteFolder"
+ "change header>input" : "toggleAll"
+ "submit .addTask" : "newTask"
+ initialize: (options) =>
+ @project = options.project
+ @tasks = $.map $(".list li",@el), (item)=>
+ newTask = new TaskItem
+ el: $(item)
+ folder: @
+ newTask.bind("change", @refreshCount)
+ newTask.bind("delete", @deleteTask)
+ @counter = @el.find(".counter")
+ @id = @el.attr("data-folder-id")
+ @name = $("header > h3", @el).editInPlace
+ context: this
+ onChange: @renameFolder
+ @refreshCount()
+ newTask: (e) =>
+ e.preventDefault()
+ $(document).focus() # temporary disable form
+ form = $(e.target)
+ taskBody = $("input[name=taskBody]", form).val()
+ url = form.attr("action")
+ jsRoutes.controllers.Tasks.add(@project, @id).ajax
+ url: url
+ type: "POST"
+ context: this
+ data:
+ title: $("input[name=taskBody]", form).val()
+ dueDate: $("input[name=dueDate]", form).val()
+ assignedTo: $("input[name=assignedTo]", form).val()
+ success: (tpl) ->
+ newTask = new TaskItem(el: $(tpl), folder: @)
+ @el.find("ul").append(newTask.el)
+ @tasks.push(newTask)
+ form.find("input[type=text]").val("").first().focus()
+ error: (err) ->
+ alert "Something went wrong:" + err
+ false
+ renameFolder: (name) =>
+ @loading(true)
+ jsRoutes.controllers.Tasks.renameFolder(@project, @id).ajax
+ context: this
+ data:
+ name: name
+ success: (data) ->
+ @loading(false)
+ @name.editInPlace("close", data)
+ @el.attr("data-folder-id", data)
+ @id = @el.attr("data-folder-id")
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ deleteCompleteTasks: (e) =>
+ e.preventDefault()
+ $.each @tasks, (i, item) ->
+ item.deleteTask() if item.el.find(".done:checked").length > 0
+ true
+ false
+ deleteAllTasks: (e) =>
+ e.preventDefault()
+ $.each @tasks, (i, item)->
+ item.deleteTask()
+ true
+ false
+ deleteFolder: (e) =>
+ e.preventDefault()
+ @el.remove()
+ false
+ toggleAll: (e) =>
+ val = $(e.target).is(":checked")
+ $.each @tasks, (i, item) ->
+ item.toggle(val)
+ true
+ refreshCount: =>
+ count = @tasks.filter((item)->
+ item.el.find(".done:checked").length == 0
+ ).length
+ @counter.text(count)
+ deleteTask: (task) =>
+ @tasks = _.without @tasks, tasks
+ @refreshCount()
+ loading: (display) ->
+ if (display)
+ @el.find("header .options").hide()
+ @el.find("header .loader").show()
+ else
+ @el.find("header .options").show()
+ @el.find("header .loader").hide()
+
+# ------------------------------------- TASK ITEM
+class TaskItem extends Backbone.View
+ events:
+ "change .done" : "onToggle"
+ "click .deleteTask" : "deleteTask"
+ "dblclick h4" : "editTask"
+ initialize: (options) ->
+ @check = @el.find(".done")
+ @id = @el.attr("data-task-id")
+ @folder = options.folder
+ deleteTask: (e) =>
+ e.preventDefault() if e?
+ @loading(false)
+ jsRoutes.controllers.Tasks.delete(@id).ajax
+ context: this
+ data:
+ name: name
+ success: (data) ->
+ @loading(false)
+ @el.remove()
+ @trigger("delete", @)
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ false
+ editTask: (e) =>
+ e.preventDefault()
+ # TODO
+ alert "not implemented yet."
+ false
+ toggle: (val) =>
+ @loading(true)
+ jsRoutes.controllers.Tasks.update(@id).ajax
+ context: this
+ data:
+ done: val
+ success: (data) ->
+ @loading(false)
+ @check.attr("checked",val)
+ @trigger("change", @)
+ error: (err) ->
+ @loading(false)
+ $.error("Error: " + err)
+ onToggle: (e) =>
+ e.preventDefault()
+ val = @check.is(":checked")
+ log val
+ @toggle(val)
+ false
+ loading: (display) ->
+ if (display)
+ @el.find(".delete").hide()
+ @el.find(".loader").show()
+ else
+ @el.find(".delete").show()
+ @el.find(".loader").hide()
+
+# ------------------------------------- INIT APP
+$ -> # document is ready!
+
+ app = new AppRouter()
+ drawer = new Drawer el: $("#projects")
+
+ Backbone.history.start
+ pushHistory: true
+
diff --git a/querulous/app/assets/stylesheets/apps/_tasks.less b/querulous/app/assets/stylesheets/apps/_tasks.less
new file mode 100644
index 0000000..fdd28ab
--- /dev/null
+++ b/querulous/app/assets/stylesheets/apps/_tasks.less
@@ -0,0 +1,137 @@
+// -----------------------------------
+// TASKS
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+.tasks {
+ .folder {
+ .box();
+ > header {
+ position: relative;
+ padding: 4px 25px 4px 7px;
+ input {
+ display: inline-block;
+ }
+ input[type=text] {
+ margin: 1px 0 0;
+ }
+ .options, .loader {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ }
+ }
+ > ul {
+ > li {
+ position: relative;
+ padding: 4px 20px 4px 7px;
+ border-bottom: 1px solid rgba(0,0,0,.05);
+ -webkit-user-select: text;
+ time {
+ float: right;
+ display: inline-block;
+ margin: 0 10px;
+ border-radius: 15px;
+ background: url(/assets/images/icons/clock.png) 1px 1px no-repeat;
+ background-color: @dueDateBackground;
+ border: 1px solid @dueDateBorder;
+ color: @dueDateColor;
+ font-size: 11px;
+ padding: 0 4px 1px 15px;
+ }
+ h4 {
+ display: inline-block;
+ font-weight: bold;
+ }
+ .deleteTask {
+ .delete();
+ top: 4px;
+ right: 4px;
+ }
+ &:hover .deleteTask {
+ opacity: 1;
+ -webkit-transition-delay: 0;
+ }
+ .assignedTo {
+ float: right;
+ display: inline-block;
+ margin: 0 10px;
+ padding-left: 17px;
+ color: @assignedToColor;
+ background: url(/assets/images/icons/user2.png) 0 1px no-repeat;
+ }
+ }
+ }
+ .addTask {
+ position: relative;
+ border-radius: 0 0 4px 4px;
+ padding: 5px 250px 5px 5px;
+ background: white;
+ &:after {
+ content: " ";
+ display: block;
+ clear: both;
+ }
+ input {
+ outline: none;
+ }
+ [name=taskBody] {
+ width: 100%;
+ border: 0;
+ }
+ .dueDate {
+ position: absolute;
+ right: 210px;
+ top: 4px;
+ width: 140px;
+ border-radius: 15px;
+ background: url(/assets/images/icons/clock.png) 2px 2px no-repeat;
+ background-color: @dueDateBackground;
+ border: 1px solid @dueDateBorder;
+ color: @dueDateColor;
+ font-size: 11px;
+ padding: 1px 4px 1px 15px;
+ &::-webkit-input-placeholder {
+ color: inherit;
+ }
+ &:-moz-placeholder {
+ color: inherit;
+ }
+ }
+ .assignedTo {
+ position: absolute;
+ right: 5px;
+ top: 5px;
+ width: 195px;
+ input {
+ width: 180px;
+ margin: 2px;
+ border: 0;
+ }
+ }
+ .assignToList {
+ display: none;
+ position: absolute;
+ top: 100%;
+ right: 0;
+ min-width: 100%;
+ background: rgba(0,0,0,.8);
+ color: #eee;
+ }
+ [type=submit] {
+ position: absolute;
+ left: -3000px;
+ visibility: hidden;
+ }
+ ul, div {
+ display: inline-block;
+ }
+ }
+ .loader {
+ position: absolute;
+ top: 5px;
+ right: 6px;
+ }
+ }
+}
diff --git a/querulous/app/assets/stylesheets/libs/_mate.less b/querulous/app/assets/stylesheets/libs/_mate.less
new file mode 100644
index 0000000..cf06688
--- /dev/null
+++ b/querulous/app/assets/stylesheets/libs/_mate.less
@@ -0,0 +1,43 @@
+// -----------------------------------
+// MATE: CSS helpers
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+// ------------------------ GRADIENTS
+.gradient(@from:#000, @to:#EEE) {
+ background: @from;
+ background-image: -webkit-gradient(linear, left top, left bottom, from(@from), to(@to));
+ background-image: -moz-linear-gradient(top, @from, @to);
+}
+
+// ---------------------- TRANSITIONS
+.transition(@range: all, @time: 500ms, @ease: ease-in-out) {
+ -moz-transition: @range @time @ease;
+ -webkit-transition: @range @time @ease;
+ -o-transition: @range @time @ease;
+ transition: @range @time @ease;
+}
+
+// ------------------------ TRANSFORMS
+.transform(@props) {
+ -moz-transform: @arguments;
+ -webkit-transform: @arguments;
+ -o-transform: @arguments;
+ transform: @arguments;
+}
+
+// --------------------------- HELPERS
+.ellipsis() {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.clear() {
+ &:after {
+ display: block;
+ content: " ";
+ clear: both;
+ }
+}
diff --git a/querulous/app/assets/stylesheets/libs/_reset.less b/querulous/app/assets/stylesheets/libs/_reset.less
new file mode 100644
index 0000000..dc347d5
--- /dev/null
+++ b/querulous/app/assets/stylesheets/libs/_reset.less
@@ -0,0 +1,12 @@
+html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td {margin: 0; padding: 0; border: 0; outline: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit;}
+
+table {border-collapse: collapse; border-spacing: 0;}
+caption, th, td {text-align: left; font-weight: normal;}
+form legend {display: none;}
+blockquote:before, blockquote:after, q:before, q:after {content: "";}
+blockquote, q {quotes: "" "";}
+ol, ul {list-style: none;}
+hr {display: none; visibility: hidden;}
+
+
+
diff --git a/querulous/app/assets/stylesheets/libs/_theme.less b/querulous/app/assets/stylesheets/libs/_theme.less
new file mode 100644
index 0000000..97f957c
--- /dev/null
+++ b/querulous/app/assets/stylesheets/libs/_theme.less
@@ -0,0 +1,56 @@
+// -----------------------------------
+// THEME
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+// ---------------------------- COLORS
+@mainBackground: #c7d1d8;
+@mainColor: #2a3a48;
+@drawerBackground: #e1e7ec;
+@folderColor: #6f8193;
+@projectColor: @mainColor;
+@layoutBorderColor: #606d78;
+
+@links: #4794c4;
+@linksHover: #2a5e88;
+
+@headerLight: #445868;
+@headerDark: @mainColor;
+@titleColor: #556b7b;
+@buttonColor: @mainBackground;
+@buttonHover: #fff;
+
+@activeColor: #5daad5;
+
+// Tasks
+@dueDateBackground: #accfe8;
+@dueDateBorder: #73a4ca;
+@dueDateColor: #246fa9;
+@assignedToColor: @titleColor;
+
+// Folders
+@folderBorder: #9ba5ad;
+@folderBackground: #dbe1e5;
+@folderHeaderLight: #f2f5f8;
+@folderHeaderDark: #dfe2e6;
+@folderTitle: #7a8a99;
+
+// Pannels
+@panBackground: rgba(0,0,0,.85);
+@panShadow: rgba(0,0,0,.8);
+@panText: #aaa;
+@panTitle: #fff;
+@panBorder: #333;
+@panButtonBackground: #444;
+@panButtonColor: #fff;
+
+// ----------------------------- FONTS
+@defaultFont: 13px "Lucida Grande","Helvetica Neue", sans-serif;
+
+// ----------------------------- SIZES
+@headerHeight: 40px;
+@drawerWidth: 220px;
+@navigationWidth: 70px;
+@breadcrumbHeight: 50px;
+
diff --git a/querulous/app/assets/stylesheets/login.less b/querulous/app/assets/stylesheets/login.less
new file mode 100644
index 0000000..ae5b2c7
--- /dev/null
+++ b/querulous/app/assets/stylesheets/login.less
@@ -0,0 +1,100 @@
+// -----------------------------------
+// LOGIN
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+@import 'libs/_reset.less';
+@import 'libs/_mate.less';
+@import 'libs/_theme.less';
+
+// ----------------------- MAIN STYLES
+body {
+ background: @mainBackground;
+ color: @mainColor;
+ font: @defaultFont;
+ -webkit-font-smoothing: antialised;
+ -webkit-user-select: none;
+}
+
+a {
+ color: @links;
+ text-decoration: none;
+ &:hover {
+ color: @linksHover;
+ }
+}
+
+p.note {
+ margin: 10px auto 0;
+ width: 300px;
+ padding: 20px;
+ text-align: center;
+ color: #445868;
+ text-shadow: 1px 1px rgba(255,255,255,.6);
+ em {
+ font-weight: bold;
+ }
+}
+
+form {
+ margin: 100px auto 0;
+ width: 300px;
+ padding: 20px;
+ background: #fff;
+ border-radius: 5px;
+ box-shadow: 0 2px 5px rgba(0,0,0,.3);
+ text-align: center;
+ h1 {
+ margin: 0 0 10px;
+ font-size: 20px;
+ font-weight: bold;
+ }
+ p {
+ width: inherit;
+ margin: 5px 0;
+ color: #999;
+ }
+ p.error {
+ color: #c00;
+ margin-bottom: 10px;
+ text-shadow: 1px 1px rgba(0,0,0,.1);
+ }
+ p.success {
+ color: #83BD41;
+ margin-bottom: 10px;
+ text-shadow: 1px 1px rgba(0,0,0,.1);
+ }
+ input {
+ display: block;
+ width: inherit;
+ padding: 2px;
+ background: rgba(0,0,0,.05);
+ border: 1px solid rgba(0,0,0,.15);
+ box-shadow: 0 1px 2px rgba(0,0,0,.1) inset;
+ border-radius: 3px;
+ font-size: 14px;
+ &:invalid:not(:focus) {
+ border-color: red;
+ }
+ }
+ button {
+ .button();
+ display: block;
+ width: inherit;
+ font-size: 14px;
+ }
+}
+
+nav {
+ margin-top: 15px;
+ a {
+ display: inline-block;
+ margin: 0 4px;
+ }
+}
+
+// --------------------------- IMPORTS
+@import 'main/_widgets.less'; // Some shared elements
+@import 'main/_header.less'; // Top header + User bar
+
diff --git a/querulous/app/assets/stylesheets/main.less b/querulous/app/assets/stylesheets/main.less
new file mode 100644
index 0000000..057ae1e
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main.less
@@ -0,0 +1,35 @@
+// -----------------------------------
+// MAIN
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+@import 'libs/_reset.less';
+@import 'libs/_mate.less';
+@import 'libs/_theme.less';
+
+// ----------------------- MAIN STYLES
+body {
+ background: @mainBackground;
+ color: @mainColor;
+ font: @defaultFont;
+ -webkit-font-smoothing: antialised;
+ -webkit-user-select: none;
+}
+
+a {
+ color: @links;
+ text-decoration: none;
+ &:hover {
+ color: @linksHover;
+ }
+}
+
+// --------------------------- IMPORTS
+@import 'main/_layout.less'; // General grid
+@import 'main/_widgets.less'; // Some shared elements
+@import 'main/_drawer.less'; // Project drawer
+@import 'main/_header.less'; // Top header + User bar
+@import 'main/_breadcrumb.less'; // Breadcrumb + App menu
+@import 'apps/_tasks.less'; // Tasks
+
diff --git a/querulous/app/assets/stylesheets/main/_breadcrumb.less b/querulous/app/assets/stylesheets/main/_breadcrumb.less
new file mode 100644
index 0000000..c8bde04
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main/_breadcrumb.less
@@ -0,0 +1,110 @@
+// -----------------------------------
+// BREADCRUMB
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+#main > header {
+ height: 39px;
+ background: url(/assets/images/breadcrumb.png);
+ border-bottom: 1px solid @layoutBorderColor;
+ hgroup {
+ height: inherit;
+ overflow: hidden;
+ float: left;
+ > * {
+ float: inherit;
+ position: relative;
+ height: inherit;
+ line-height: 40px;
+ margin-left: -25px;
+ padding: 0 6px 0 13px;
+ font-size: 18px;
+ text-shadow: 1px 1px 0 rgba(255,255,255,.5);
+ -webkit-border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch;
+ -moz-border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch;
+ border-image: url(/assets/images/breadcrumb-2.png) 0 30 0 20 stretch stretch;
+ border-width: 0 30px 0 25px;
+ color: @titleColor;
+ -webkit-user-select: text;
+ &:nth-child(2) {
+ -webkit-border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 1 stretch stretch;
+ -moz-border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 1 stretch stretch;
+ border-image: url(/assets/images/breadcrumb-1.png) 0 30 0 2 stretch stretch;
+ }
+ &:first-child {
+ padding-left: 20px;
+ }
+ &:nth-child(1) { z-index: 3; }
+ &:nth-child(2) { z-index: 2; }
+ &:nth-child(3) { z-index: 1; }
+ }
+ }
+ .users {
+ position: relative;
+ margin: 8px 10px;
+ float: right;
+ .pannel();
+ > dt {
+ &:before {
+ content: url(/assets/images/icons/user.png);
+ padding-right: 4px;
+ vertical-align: middle;
+ }
+ .button();
+ }
+ > dd {
+ padding: 5px 10px;
+ width: 300px;
+ color: @panText;
+ z-index: 99;
+ }
+ .wrap {
+ overflow: auto;
+ max-height: 350px;
+ width: inherit;
+ }
+ h3 {
+ margin: 10px 0 0;
+ padding: 5px 0;
+ font-size: 16px;
+ color: @panTitle;
+ &:first-of-type {
+ margin: 0;
+ }
+ }
+ dl {
+ position: relative;
+ border-top: 1px solid @panBorder;
+ padding: 5px 17px 5px 0;
+ dt {
+ .ellipsis();
+ span {
+ opacity: .5;
+ font-size: 11px;
+ }
+ }
+ }
+ .action {
+ position: absolute;
+ top: 5px;
+ right: 0px;
+ width: 16px;
+ height: 16px;
+ overflow: hidden;
+ text-indent: -99em;
+ background: #444 url(/assets/images/icons/addRemove.png) 0 1 no-repeat;
+ border-radius: 10px;
+ &:hover {
+ background-color: green;
+ }
+ }
+ .list .action {
+ background-position: 0 -19px;
+ &:hover {
+ background-color: red;
+ }
+ }
+ }
+}
+
diff --git a/querulous/app/assets/stylesheets/main/_drawer.less b/querulous/app/assets/stylesheets/main/_drawer.less
new file mode 100644
index 0000000..4cc46c7
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main/_drawer.less
@@ -0,0 +1,130 @@
+// -----------------------------------
+// PROJECTS DRAWER
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+body {
+ & > nav {
+ padding: 10px 0 0;
+ background: @drawerBackground;
+ border-right: 1px solid @layoutBorderColor;
+ }
+ .dashboard {
+ display: block;
+ position: relative;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ padding: 10px 5px 5px 25px;
+ background: url(/assets/images/icons/home.png) 6px 7px no-repeat;
+ a {
+ color: @folderColor;
+ cursor: default;
+ }
+ }
+ h4 {
+ cursor: default;
+ }
+}
+
+#projects {
+ input {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font: inherit;
+ }
+ > li {
+ position: relative;
+ padding: 10px 5px 5px 25px;
+ .options {
+ position: absolute;
+ top: 7px;
+ right: 5px;
+ button {
+ border: none;
+ }
+ }
+ .loader {
+ top: 8px;
+ right: 6px;
+ }
+ li {
+ position: relative;
+ padding: 0 0 0 25px;
+ background: url(/assets/images/icons/folder.png) 1px 1px no-repeat;
+ a {
+ display: block;
+ color: @projectColor;
+ padding: 2px;
+ }
+ input[type=text] {
+ padding: 2px 1px;
+ }
+ .delete {
+ opacity: 0.1;
+ }
+ .loader {
+ top: 2px;
+ right: 2px;
+ }
+ &:hover {
+ a {
+ color: #000;
+ }
+ .delete {
+ opacity: 1;
+ //-webkit-transition-delay: 0;
+ }
+ }
+ }
+ .toggle {
+ content: " ";
+ position: absolute;
+ left: 8px;
+ top: 12px;
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ vertical-align: middle;
+ background: url(/assets/images/icons/drawer.folder.png) 0 0 no-repeat;
+ }
+ > h4 {
+ display: block;
+ margin: 0 0 5px;
+ padding: 0 1px;
+ position: relative;
+ color: @folderColor;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ }
+ > input {
+ margin: 0 0 5px;
+ text-transform: uppercase;
+ font-weight: bold;
+ font-size: 11px;
+ }
+ &.closed {
+ >ul {
+ display: none;
+ }
+ .toggle {
+ background-position: 0 -20px;
+ }
+ }
+ }
+}
+
+#activity {
+ position: absolute;
+ bottom: 0;
+ font-size: 11px;
+ padding: 10px;
+}
+
+#newGroup {
+ font-size: 13px;
+ .new();
+ margin: 15px 25px;
+}
diff --git a/querulous/app/assets/stylesheets/main/_header.less b/querulous/app/assets/stylesheets/main/_header.less
new file mode 100644
index 0000000..dbaee12
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main/_header.less
@@ -0,0 +1,50 @@
+// -----------------------------------
+// HEADER
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+body {
+ & > header {
+ box-sizing: border-box;
+ padding: 10px;
+ z-index: 9;
+ .gradient(@headerLight, @headerDark);
+ box-shadow: 0 0 7px rgba(0,0,0,.8),
+ inset 0 1px 0 rgba(255,255,255,.2),
+ inset 0 -1px 0 rgba(0,0,0,.8);
+ }
+}
+
+#logo {
+ color: #fff;
+ font-weight: bold;
+ font-size: 16px;
+ text-transform: uppercase;
+ letter-spacing: -2px;
+ text-shadow: 1px 1px 0 #000;
+ span {
+ color: @activeColor;
+ }
+}
+
+#user {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ > * {
+ display: inline-block;
+ }
+ dt {
+ color: #fff;
+ span {
+ opacity: .5;
+ font-size: 11px;
+ }
+ }
+ dd {
+ a , button {
+ .button();
+ }
+ }
+}
diff --git a/querulous/app/assets/stylesheets/main/_layout.less b/querulous/app/assets/stylesheets/main/_layout.less
new file mode 100644
index 0000000..046c70c
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main/_layout.less
@@ -0,0 +1,48 @@
+// -----------------------------------
+// MAIN LAYOUT
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+body {
+ & > header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: @headerHeight;
+ }
+ & > nav {
+ position: absolute;
+ top: @headerHeight;
+ left: 0;
+ width: @drawerWidth;
+ bottom: 0;
+ box-sizing: border-box;
+ }
+ & > section {
+ position: absolute;
+ top: @headerHeight;
+ left: @drawerWidth;
+ right: 0;
+ bottom: 0;
+ }
+}
+
+#main > header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: @breadcrumbHeight;
+}
+
+#main > article {
+ position: absolute;
+ top: 40px;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ padding: 20px;
+ overflow: auto;
+}
diff --git a/querulous/app/assets/stylesheets/main/_widgets.less b/querulous/app/assets/stylesheets/main/_widgets.less
new file mode 100644
index 0000000..ada18d0
--- /dev/null
+++ b/querulous/app/assets/stylesheets/main/_widgets.less
@@ -0,0 +1,167 @@
+// -----------------------------------
+// WIDGETS
+// -----------------------------------
+// author: mda@zenexity.com - 2011
+// -----------------------------------
+
+// --------------------------- OPTIONS
+.pannel(){
+ > dd {
+ //display: none;
+ .transform(scale(.001));
+ .transition(-webkit-transform, 200ms, ease-out);
+ -webkit-transform-origin: right top;
+ -moz-transform-origin: right top;
+ transform-origin: right top;
+ position: absolute;
+ top: 100%;
+ right: -2px;
+ width: 150px;
+ margin: 7px 0px 0 0;
+ padding: 5px;
+ z-index: 99;
+ background: @panBackground;
+ border-radius: 5px;
+ box-shadow: 0 2px 7px @panShadow;
+ a, button {
+ display: block;
+ width: 100%;
+ padding: 3px 10px;
+ color: @panText;
+ border-radius: 2px;
+ background: none;
+ &:hover {
+ background: @panButtonBackground;
+ color: @panButtonColor;
+ }
+ }
+ &:before {
+ content: " ";
+ display: block;
+ border-bottom: 6px solid @panBackground;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: none;
+ margin-right: 0;
+ margin-top: -12px;
+ position: absolute;
+ right: 5px;
+ width: 1px;
+ height: 1px;
+ }
+ }
+ &.opened > dd {
+ .transform(scale(1));
+ }
+}
+
+.button() {
+ padding: 2px 5px;
+ border-radius: 3px;
+ border: 1px solid @headerDark - #111;
+ .gradient(fadeout(#bff, 80%), fadeout(#bff, 100%));
+ background-color: @headerDark;
+ box-shadow: 0 1px 4px rgba(0,0,0,.3),
+ inset 0 1px 0 rgba(255,255,255,.2),
+ inset 0 -1px 0 rgba(0,0,0,.2);
+ text-shadow: -1px -1px 0 rgba(0,0,0,.3);
+ color: @buttonColor;
+ cursor: pointer;
+ &:hover {
+ .gradient(fadeout(#bff, 70%), fadeout(#bff, 100%));
+ background-color: @headerDark;
+ color: @buttonHover;
+ }
+}
+
+.box() {
+ border: 1px solid @folderBorder;
+ background: @folderBackground;
+ border-radius: 5px;
+ margin: 0 0 20px;
+ > header {
+ border-radius: 5px 5px 0 0;
+ .gradient(@folderHeaderLight, @folderHeaderDark);
+ border-bottom: inherit;
+ padding: 4px 7px;
+ h3 {
+ display: inline-block;
+ font-size: 15px;
+ font-weight: bold;
+ color: @folderTitle;
+ text-shadow: 1px 1px 0 #fff;
+ }
+ }
+}
+.options {
+ width: 20px;
+ height: 20px;
+ position: relative;
+ font-size: 11px;
+ dt {
+ width: inherit;
+ cursor: pointer;
+ height: inherit;
+ text-indent: -9999em;
+ background: url(/assets/images/icons/options.png) 0 0 no-repeat;
+ }
+ &:hover dt, &.opened dt, {
+ background: url(/assets/images/icons/options.png) 0 -20px no-repeat;
+ }
+ .pannel();
+}
+
+.new {
+ display: inline-block;
+ .button();
+ &:before {
+ content: "+";
+ font-size: 15px;
+ line-height: 15px;
+ font-weight: bold;
+ color: @activeColor;
+ padding-right: 4px;
+ }
+}
+
+.delete {
+ position: absolute;
+ top: 0px;
+ right: 0;
+ border: none;
+ padding: 0;
+ width: 18px;
+ height: 20px;
+ overflow: hidden;
+ text-indent: -99em;
+ background: url(/assets/images/icons/delete.png) 0 1px no-repeat;
+ .transition(opacity, 300ms, ease-in-out);
+ -webkit-transition-delay: 100ms;
+ z-index: 7;
+ cursor: pointer;
+ &:hover {
+ background: url(/assets/images/icons/delete.png) 0 -19px no-repeat;
+ }
+}
+
+.counter {
+ color: #888;
+ font-size: 11px;
+ text-shadow: 1px 1px 0 rgba(255,255,255,.7);
+ margin: 2px 5px;
+ padding: 0px 3px;
+ background: rgba(0,0,0,.05);
+ border-radius: 10px;
+ border: 1px solid rgba(0,0,0,.15);
+}
+
+.loader {
+ display: none;
+ overflow: hidden;
+ text-indent: -99em;
+ position: absolute;
+ height: 16px;
+ width: 16px;
+ background: url(/assets/images/loading.gif);
+}
+
diff --git a/querulous/app/controllers/Application.scala b/querulous/app/controllers/Application.scala
new file mode 100644
index 0000000..856b3b4
--- /dev/null
+++ b/querulous/app/controllers/Application.scala
@@ -0,0 +1,114 @@
+package controllers
+
+import play.api._
+import play.api.mvc._
+import play.api.data._
+import play.api.data.Forms._
+
+import models._
+import views._
+
+object Application extends Controller {
+
+ // -- Authentication
+
+ val loginForm = Form(
+ tuple(
+ "email" -> text,
+ "password" -> text
+ ) verifying ("Invalid email or password", result => result match {
+ case (email, password) => User.authenticate(email, password).isDefined
+ })
+ )
+
+ /**
+ * Login page.
+ */
+ def login = Action { implicit request =>
+ Ok(html.login(loginForm))
+ }
+
+ /**
+ * Handle login form submission.
+ */
+ def authenticate = Action { implicit request =>
+ loginForm.bindFromRequest.fold(
+ formWithErrors => BadRequest(html.login(formWithErrors)),
+ user => Redirect(routes.Projects.index).withSession("email" -> user._1)
+ )
+ }
+
+ /**
+ * Logout and clean the session.
+ */
+ def logout = Action {
+ Redirect(routes.Application.login).withNewSession.flashing(
+ "success" -> "You've been logged out"
+ )
+ }
+
+ // -- Javascript routing
+
+ def javascriptRoutes = Action { implicit request =>
+ import routes.javascript._
+ Ok(
+ Routes.javascriptRouter("jsRoutes")(
+ Projects.add, Projects.delete, Projects.rename,
+ Projects.addGroup, Projects.deleteGroup, Projects.renameGroup,
+ Projects.addUser, Projects.removeUser, Tasks.addFolder,
+ Tasks.renameFolder, Tasks.deleteFolder, Tasks.index,
+ Tasks.add, Tasks.update, Tasks.delete
+ )
+ ).as("text/javascript")
+ }
+
+}
+
+/**
+ * Provide security features
+ */
+trait Secured {
+
+ /**
+ * Retrieve the connected user email.
+ */
+ private def username(request: RequestHeader) = request.session.get("email")
+
+ /**
+ * Redirect to login if the user in not authorized.
+ */
+ private def onUnauthorized(request: RequestHeader) = Results.Redirect(routes.Application.login)
+
+ // --
+
+ /**
+ * Action for authenticated users.
+ */
+ def IsAuthenticated(f: => String => Request[AnyContent] => Result) = Security.Authenticated(username, onUnauthorized) { user =>
+ Action(request => f(user)(request))
+ }
+
+ /**
+ * Check if the connected user is a member of this project.
+ */
+ def IsMemberOf(project: Long)(f: => String => Request[AnyContent] => Result) = IsAuthenticated { user => request =>
+ if(Project.isMember(project, user)) {
+ f(user)(request)
+ } else {
+ Results.Forbidden
+ }
+ }
+
+ /**
+ * Check if the connected user is a owner of this task.
+ */
+ def IsOwnerOf(task: Long)(f: => String => Request[AnyContent] => Result) = IsAuthenticated { user => request =>
+ if(Task.isOwner(task, user)) {
+ f(user)(request)
+ } else {
+ Results.Forbidden
+ }
+ }
+
+}
+
diff --git a/querulous/app/controllers/Projects.scala b/querulous/app/controllers/Projects.scala
new file mode 100644
index 0000000..5ffe841
--- /dev/null
+++ b/querulous/app/controllers/Projects.scala
@@ -0,0 +1,123 @@
+package controllers
+
+import play.api._
+import play.api.mvc._
+import play.api.data._
+import play.api.data.Forms._
+
+import anorm._
+
+import models._
+import views._
+
+/**
+ * Manage projects related operations.
+ */
+object Projects extends Controller with Secured {
+
+ /**
+ * Display the dashboard.
+ */
+ def index = IsAuthenticated { username => _ =>
+ User.findByEmail(username).map { user =>
+ Ok(
+ html.dashboard(
+ Project.findInvolving(username),
+ Task.findTodoInvolving(username),
+ user
+ )
+ )
+ }.getOrElse(Forbidden)
+ }
+
+ // -- Projects
+
+ /**
+ * Add a project.
+ */
+ def add = IsAuthenticated { username => implicit request =>
+ Form("group" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ folder => Ok(
+ views.html.projects.item(
+ Project.create(
+ Project(NotAssigned, folder, "New project"),
+ Seq(username)
+ )
+ )
+ )
+ )
+ }
+
+ /**
+ * Delete a project.
+ */
+ def delete(project: Long) = IsMemberOf(project) { username => _ =>
+ Project.delete(project)
+ Ok
+ }
+
+ /**
+ * Rename a project.
+ */
+ def rename(project: Long) = IsMemberOf(project) { _ => implicit request =>
+ Form("name" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ newName => {
+ Project.rename(project, newName)
+ Ok(newName)
+ }
+ )
+ }
+
+ // -- Project groups
+
+ /**
+ * Add a new project group.
+ */
+ def addGroup = IsAuthenticated { _ => _ =>
+ Ok(html.projects.group("New group"))
+ }
+
+ /**
+ * Delete a project group.
+ */
+ def deleteGroup(folder: String) = IsAuthenticated { _ => _ =>
+ Project.deleteInFolder(folder)
+ Ok
+ }
+
+ /**
+ * Rename a project group.
+ */
+ def renameGroup(folder: String) = IsAuthenticated { _ => implicit request =>
+ Form("name" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ newName => { Project.renameFolder(folder, newName); Ok(newName) }
+ )
+ }
+
+ // -- Members
+
+ /**
+ * Add a project member.
+ */
+ def addUser(project: Long) = IsMemberOf(project) { _ => implicit request =>
+ Form("user" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ user => { Project.addMember(project, user); Ok }
+ )
+ }
+
+ /**
+ * Remove a project member.
+ */
+ def removeUser(project: Long) = IsMemberOf(project) { _ => implicit request =>
+ Form("user" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ user => { Project.removeMember(project, user); Ok }
+ )
+ }
+
+}
+
diff --git a/querulous/app/controllers/Tasks.scala b/querulous/app/controllers/Tasks.scala
new file mode 100644
index 0000000..dc87a72
--- /dev/null
+++ b/querulous/app/controllers/Tasks.scala
@@ -0,0 +1,109 @@
+package controllers
+
+import play.api._
+import play.api.mvc._
+import play.api.data._
+import play.api.data.Forms._
+
+import java.util.{Date}
+
+import anorm._
+
+import models._
+import views._
+
+/**
+ * Manage tasks related operations.
+ */
+object Tasks extends Controller with Secured {
+
+ /**
+ * Display the tasks panel for this project.
+ */
+ def index(project: Long) = IsMemberOf(project) { _ => implicit request =>
+ Project.findById(project).map { p =>
+ val tasks = Task.findByProject(project)
+ val team = Project.membersOf(project)
+ Ok(html.tasks.index(p, tasks, team))
+ }.getOrElse(NotFound)
+ }
+
+ val taskForm = Form(
+ tuple(
+ "title" -> nonEmptyText,
+ "dueDate" -> optional(date("MM/dd/yy")),
+ "assignedTo" -> optional(text)
+ )
+ )
+
+ // -- Tasks
+
+ /**
+ * Create a task in this project.
+ */
+ def add(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request =>
+ taskForm.bindFromRequest.fold(
+ errors => BadRequest,
+ {
+ case (title, dueDate, assignedTo) =>
+ val task = Task.create(
+ Task(NotAssigned, folder, project, title, false, dueDate, assignedTo)
+ )
+ Ok(html.tasks.item(task))
+ }
+ )
+ }
+
+ /**
+ * Update a task
+ */
+ def update(task: Long) = IsOwnerOf(task) { _ => implicit request =>
+ Form("done" -> boolean).bindFromRequest.fold(
+ errors => BadRequest,
+ isDone => {
+ Task.markAsDone(task, isDone)
+ Ok
+ }
+ )
+ }
+
+ /**
+ * Delete a task
+ */
+ def delete(task: Long) = IsOwnerOf(task) { _ => implicit request =>
+ Task.delete(task)
+ Ok
+ }
+
+ // -- Task folders
+
+ /**
+ * Add a new folder.
+ */
+ def addFolder = Action {
+ Ok(html.tasks.folder("New folder"))
+ }
+
+ /**
+ * Delete a full tasks folder.
+ */
+ def deleteFolder(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request =>
+ Task.deleteInFolder(project, folder)
+ Ok
+ }
+
+ /**
+ * Rename a tasks folder.
+ */
+ def renameFolder(project: Long, folder: String) = IsMemberOf(project) { _ => implicit request =>
+ Form("name" -> nonEmptyText).bindFromRequest.fold(
+ errors => BadRequest,
+ newName => {
+ Task.renameFolder(project, folder, newName)
+ Ok(newName)
+ }
+ )
+ }
+
+}
+
diff --git a/querulous/app/models/DBStuff.scala b/querulous/app/models/DBStuff.scala
new file mode 100644
index 0000000..95f816d
--- /dev/null
+++ b/querulous/app/models/DBStuff.scala
@@ -0,0 +1,18 @@
+package models
+
+import com.twitter.querulous.evaluator.QueryEvaluator
+
+/**
+ * Author: chris
+ * Created: 7/26/12
+ */
+
+object DBStuff {
+
+ val queryEvaluator = QueryEvaluator(
+ dbhost = "localhost",
+ dbname = "zentasks",
+ username = "chris",
+ password = "chris")
+
+}
diff --git a/querulous/app/models/Project.scala b/querulous/app/models/Project.scala
new file mode 100644
index 0000000..e39b927
--- /dev/null
+++ b/querulous/app/models/Project.scala
@@ -0,0 +1,137 @@
+package models
+
+import play.api.db._
+import play.api.Play.current
+
+import anorm._
+import anorm.SqlParser._
+
+import java.sql.ResultSet
+import DBStuff.queryEvaluator
+
+case class Project(id: Pk[Long], folder: String, name: String)
+
+object Project {
+
+
+ // -- Parsers
+
+ /**
+ * Parse a Project from a ResultSet
+ */
+
+ val simpleQ = { row: ResultSet =>
+ Project(Id(row.getLong("project.id")), row.getString("project.folder"), row.getString("project.name"))
+ }
+
+ // -- Queries
+
+ /**
+ * Retrieve a Project from id.
+ */
+ def findById(id: Long): Option[Project] = {
+ queryEvaluator.selectOne("select * from project where id = ?", id)(simpleQ)
+ }
+
+ /**
+ * Retrieve project for user
+ */
+ def findInvolving(user: String): Seq[Project] = {
+ queryEvaluator.select(
+ """
+ select * from project
+ join project_member on project.id = project_member.project_id
+ where project_member.user_email = ?
+ """,
+ user
+ )(simpleQ)
+ }
+
+ /**
+ * Update a project.
+ */
+ def rename(id: Long, newName: String) {
+ queryEvaluator.execute("update project set name = ? where id = ?", newName, id)
+ }
+
+ /**
+ * Delete a project.
+ */
+ def delete(id: Long) {
+ queryEvaluator.execute("delete from project where id = ?", id)
+ }
+
+ /**
+ * Delete all project in a folder
+ */
+ def deleteInFolder(folder: String) {
+ queryEvaluator.execute("delete from project where folder = ?", folder)
+ }
+
+ /**
+ * Rename a folder
+ */
+ def renameFolder(folder: String, newName: String) {
+ queryEvaluator.execute("update project set folder = ? where folder = ?", newName, folder)
+ }
+
+ /**
+ * Retrieve project member
+ */
+ def membersOf(project: Long): Seq[User] = {
+ queryEvaluator.select(
+ """
+ select user.* from user
+ join project_member on project_member.user_email = user.email
+ where project_member.project_id = ?
+ """,
+ project
+ )(User.simpleQ)
+ }
+
+ /**
+ * Add a member to the project team.
+ */
+ def addMember(project: Long, user: String) {
+ queryEvaluator.insert("insert into project_member values(?, ?)", project, user)
+ }
+
+ /**
+ * Remove a member from the project team.
+ */
+ def removeMember(project: Long, user: String) {
+ queryEvaluator.execute("delete from project_member where project_id = ? and user_email = ?", project, user)
+ }
+
+ /**
+ * Check if a user is a member of this project
+ */
+ def isMember(project: Long, user: String): Boolean = {
+ queryEvaluator.select(
+ """
+ select count(user.email) = 1 from user
+ join project_member on project_member.user_email = user.email
+ where project_member.project_id = ? and user.email = ?
+ """,
+ project, user
+ ) { row =>
+ row.getBoolean(1) // columns are 1-indexed
+ }.head
+ }
+
+ /**
+ * Create a Project.
+ */
+ def create(project: Project, members: Seq[String]): Project = {
+ queryEvaluator.transaction { t =>
+ val id = t.insert("insert into project (name, folder) values (?, ?)", project.name, project.folder)
+
+ members.foreach { email =>
+ t.insert("insert into project_member values (?, ?)", id, email)
+ }
+
+ project.copy(id = Id(id))
+ }
+ }
+
+}
diff --git a/querulous/app/models/Task.scala b/querulous/app/models/Task.scala
new file mode 100644
index 0000000..4549113
--- /dev/null
+++ b/querulous/app/models/Task.scala
@@ -0,0 +1,147 @@
+package models
+
+import java.util.{Date}
+
+import play.api.db._
+import play.api.Play.current
+
+import anorm._
+import anorm.SqlParser._
+
+import java.sql.ResultSet
+import DBStuff.queryEvaluator
+
+case class Task(id: Pk[Long], folder: String, project: Long, title: String, done: Boolean, dueDate: Option[Date], assignedTo: Option[String])
+
+object Task {
+
+ // -- Parsers
+
+ /**
+ * Parse a Task from a ResultSet
+ */
+ val simple = {
+ get[Pk[Long]]("task.id") ~
+ get[String]("task.folder") ~
+ get[Long]("task.project") ~
+ get[String]("task.title") ~
+ get[Boolean]("task.done") ~
+ get[Option[Date]]("task.due_date") ~
+ get[Option[String]]("task.assigned_to") map {
+ case id~folder~project~title~done~dueDate~assignedTo => Task(
+ id, folder, project, title, done, dueDate, assignedTo
+ )
+ }
+ }
+
+ val simpleQ = { row: ResultSet =>
+ Task(
+ Id(row.getLong("task.id")),
+ row.getString("task.folder"),
+ row.getLong("task.project"),
+ row.getString("task.title"),
+ row.getBoolean("task.done"),
+ Option.apply(row.getDate("task.due_date")),
+ Option.apply(row.getString("task.assigned_to"))
+ )
+ }
+
+ // -- Queries
+
+ /**
+ * Retrieve a Task from the id.
+ */
+ def findById(id: Long): Option[Task] = {
+ queryEvaluator.selectOne("select * from task where id = ?", id)(simpleQ)
+ }
+
+ /**
+ * Retrieve todo tasks for the user.
+ */
+ def findTodoInvolving(user: String): Seq[(Task,Project)] = {
+ queryEvaluator.select(
+ """
+ select * from task
+ join project_member on project_member.project_id = task.project
+ join project on project.id = project_member.project_id
+ where task.done = false and project_member.user_email = ?
+ """,
+ user
+ ){ row =>
+ (simpleQ(row), Project.simpleQ(row))
+ }
+ }
+
+ /**
+ * Find tasks related to a project
+ */
+ def findByProject(project: Long): Seq[Task] = {
+ queryEvaluator.select("select * from task where task.project = ?", project)(simpleQ)
+ }
+
+ /**
+ * Delete a task
+ */
+ def delete(id: Long) {
+ queryEvaluator.execute("delete from task where id = ?", id)
+ }
+
+ /**
+ * Delete all task in a folder.
+ */
+ def deleteInFolder(projectId: Long, folder: String) {
+ queryEvaluator.execute("delete from task where project = ? and folder = ?", projectId, folder)
+ }
+
+ /**
+ * Mark a task as done or not
+ */
+ def markAsDone(taskId: Long, done: Boolean) {
+ queryEvaluator.execute("update task set done = ? where id = ?", done, taskId)
+ }
+
+ /**
+ * Rename a folder.
+ */
+ def renameFolder(projectId: Long, folder: String, newName: String) {
+ queryEvaluator.execute("update task set folder = ? where folder = ? and project = ?", newName, folder, projectId)
+ }
+
+ /**
+ * Check if a user is the owner of this task
+ */
+ def isOwner(task: Long, user: String): Boolean = {
+ queryEvaluator.select(
+ """
+ select count(task.id) = 1 from task
+ join project on task.project = project.id
+ join project_member on project_member.project_id = project.id
+ where project_member.user_email = ? and task.id = ?
+ """,
+ user, task
+ ){ row =>
+ row.getBoolean(1) // columns are 1-indexed
+ }.head
+ }
+
+ /**
+ * Create a Task.
+ */
+ def create(task: Task): Task = {
+ val id = queryEvaluator.insert(
+ """
+ insert into task (title, done, due_date, assigned_to, project, folder)
+ values (?, ?, ?, ?, ?, ?)
+ """,
+ task.title,
+ task.done,
+ task.dueDate,
+ task.assignedTo,
+ task.project,
+ task.folder
+ )
+
+ task.copy(id = Id(id))
+ }
+
+}
diff --git a/querulous/app/models/User.scala b/querulous/app/models/User.scala
new file mode 100644
index 0000000..c25c85d
--- /dev/null
+++ b/querulous/app/models/User.scala
@@ -0,0 +1,57 @@
+package models
+
+import play.api.db._
+import play.api.Play.current
+
+import anorm._
+import anorm.SqlParser._
+
+import java.sql.ResultSet
+import DBStuff.queryEvaluator
+
+case class User(email: String, name: String, password: String)
+
+object User {
+
+ // -- Parsers
+
+ /**
+ * Parse a User from a ResultSet
+ */
+
+ val simpleQ = { row: ResultSet =>
+ User(row.getString("user.email"), row.getString("user.name"), row.getString("user.password"))
+ }
+
+ // -- Queries
+
+ /**
+ * Retrieve a User from email.
+ */
+ def findByEmail(email: String): Option[User] = {
+ queryEvaluator.selectOne("select * from user where email = ?", email)(simpleQ)
+ }
+
+ /**
+ * Retrieve all users.
+ */
+ def findAll: Seq[User] = {
+ queryEvaluator.select("select * from user")(simpleQ)
+ }
+
+ /**
+ * Authenticate a User.
+ */
+ def authenticate(email: String, password: String): Option[User] = {
+ queryEvaluator.selectOne("select * from user where email = ? and password = ?", email, password)(simpleQ)
+ }
+
+ /**
+ * Create a User.
+ */
+ def create(user: User): User = {
+ queryEvaluator.insert("insert into user values (?, ?, ?)", user.email, user.name, user.password)
+ user
+ }
+
+}
diff --git a/querulous/app/views/dashboard.scala.html b/querulous/app/views/dashboard.scala.html
new file mode 100644
index 0000000..3b74d24
--- /dev/null
+++ b/querulous/app/views/dashboard.scala.html
@@ -0,0 +1,33 @@
+@(projects: Seq[Project], todoTasks: Seq[(Task,Project)], user: User)
+
+@main(projects, user){
+
+
+
+ Dashboard
+ Tasks over all projects
+
+
+
+
+ @todoTasks.groupBy(_._2).map {
+ case (project, projectTasks) => {
+
+
+
+ @projectTasks.map {
+ case (task, _) => {
+ @tasks.item(task, isEditable = false)
+ }
+ }
+
+
+ }
+ }
+
+
+}
+
diff --git a/querulous/app/views/login.scala.html b/querulous/app/views/login.scala.html
new file mode 100644
index 0000000..277c29d
--- /dev/null
+++ b/querulous/app/views/login.scala.html
@@ -0,0 +1,51 @@
+@(form: Form[(String,String)])(implicit flash: Flash)
+
+
+
+ Zentasks
+
+
+
+
+
+
+
+ @helper.form(routes.Application.authenticate) {
+
+ Sign in
+
+ @form.globalError.map { error =>
+
+ @error.message
+
+ }
+
+ @flash.get("success").map { message =>
+
+ @message
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+ Try guillaume@@sample.com with secret as password.
+
+
+
+
+
+
+
diff --git a/querulous/app/views/main.scala.html b/querulous/app/views/main.scala.html
new file mode 100644
index 0000000..53df511
--- /dev/null
+++ b/querulous/app/views/main.scala.html
@@ -0,0 +1,41 @@
+@(projects: Seq[Project], user: User)(body: Html)
+
+
+
+ Zentasks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/querulous/app/views/projects/group.scala.html b/querulous/app/views/projects/group.scala.html
new file mode 100644
index 0000000..1d39eb3
--- /dev/null
+++ b/querulous/app/views/projects/group.scala.html
@@ -0,0 +1,19 @@
+@(group: String, projects: Seq[Project] = Nil)
+
+
+
+ @group
+ Loading
+
+ - Options
+ -
+
+
+
+
+
+ @projects.map { project =>
+ @views.html.projects.item(project)
+ }
+
+
diff --git a/querulous/app/views/projects/item.scala.html b/querulous/app/views/projects/item.scala.html
new file mode 100644
index 0000000..239d70a
--- /dev/null
+++ b/querulous/app/views/projects/item.scala.html
@@ -0,0 +1,7 @@
+@(project: Project)
+
+
+ @project.name
+
+ Loading
+
diff --git a/querulous/app/views/tasks/folder.scala.html b/querulous/app/views/tasks/folder.scala.html
new file mode 100644
index 0000000..7e2d9a2
--- /dev/null
+++ b/querulous/app/views/tasks/folder.scala.html
@@ -0,0 +1,33 @@
+@(folder: String, tasks: Seq[Task] = Nil)
+
+
+
+
+ @tasks.map { task =>
+ @views.html.tasks.item( task )
+ }
+
+
+
+
diff --git a/querulous/app/views/tasks/index.scala.html b/querulous/app/views/tasks/index.scala.html
new file mode 100644
index 0000000..e77b146
--- /dev/null
+++ b/querulous/app/views/tasks/index.scala.html
@@ -0,0 +1,42 @@
+@(project:Project, tasks: Seq[Task], team: Seq[User])
+
+
+
+ @project.folder
+ @project.name
+
+
+ - Project's team
+ -
+
+
Team mates
+
+ @team.map { user =>
+
+ - @user.name (@user.email)
+ - Action
+
+ }
+
+
Add a team mate
+
+ @User.findAll.diff(team).map { user =>
+
+ - @user.name (@user.email)
+ - Action
+
+ }
+
+
+
+
+
+
+ @tasks.groupBy(_.folder).map {
+ case (folder, tasks) => {
+ @views.html.tasks.folder(folder, tasks)
+ }
+ }
+ New folder
+
+
diff --git a/querulous/app/views/tasks/item.scala.html b/querulous/app/views/tasks/item.scala.html
new file mode 100644
index 0000000..1abb529
--- /dev/null
+++ b/querulous/app/views/tasks/item.scala.html
@@ -0,0 +1,24 @@
+@(task: Task, isEditable: Boolean = true)
+
+
+
+ @if(isEditable) {
+
+ }
+
+ @task.title
+
+ @task.dueDate.map { date =>
+
+ }
+
+ @task.assignedTo.map { user =>
+ @user
+ }
+
+ @if(isEditable) {
+ Delete task
+ Loading
+ }
+
+
diff --git a/querulous/conf/application.conf b/querulous/conf/application.conf
new file mode 100644
index 0000000..1b83efd
--- /dev/null
+++ b/querulous/conf/application.conf
@@ -0,0 +1,29 @@
+# Configuration
+
+application.name=Zentasks
+
+# Secret key
+# ~~~~~
+# The secret key is used to secure cryptographics functions.
+# If you deploy your application to several instances be sure to use the same key!
+application.secret="E27D^[_9W"
+
+# Database configuration
+# ~~~~~
+# You can declare as many datasources as you want.
+# By convention, the default datasource is named `default`
+db.default.driver=org.h2.Driver
+db.default.url="jdbc:h2:mem:play"
+
+# Logger
+# ~~~~~
+# You can also configure logback (http://logback.qos.ch/), by providing a logger.xml file in the conf directory .
+
+# Root logger:
+logger=ERROR
+
+# Logger used by the framework:
+logger.play=INFO
+
+# Logger provided to your application:
+logger.application=DEBUG
diff --git a/querulous/conf/routes b/querulous/conf/routes
new file mode 100644
index 0000000..7d33254
--- /dev/null
+++ b/querulous/conf/routes
@@ -0,0 +1,41 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# ~~~~
+
+# The home page
+GET / controllers.Projects.index
+
+# Authentication
+GET /login controllers.Application.login
+POST /login controllers.Application.authenticate
+GET /logout controllers.Application.logout
+
+# Projects
+POST /projects controllers.Projects.add
+
+POST /projects/groups controllers.Projects.addGroup()
+DELETE /projects/groups controllers.Projects.deleteGroup(group: String)
+PUT /projects/groups controllers.Projects.renameGroup(group: String)
+
+DELETE /projects/:project controllers.Projects.delete(project: Long)
+PUT /projects/:project controllers.Projects.rename(project: Long)
+
+POST /projects/:project/team controllers.Projects.addUser(project: Long)
+DELETE /projects/:project/team controllers.Projects.removeUser(project: Long)
+
+# Tasks
+GET /projects/:project/tasks controllers.Tasks.index(project: Long)
+POST /projects/:project/tasks controllers.Tasks.add(project: Long, folder: String)
+PUT /tasks/:task controllers.Tasks.update(task: Long)
+DELETE /tasks/:task controllers.Tasks.delete(task: Long)
+
+POST /tasks/folder controllers.Tasks.addFolder
+DELETE /projects/:project/tasks/folder controllers.Tasks.deleteFolder(project: Long, folder: String)
+PUT /project/:project/tasks/folder controllers.Tasks.renameFolder(project: Long, folder: String)
+
+# Javascript routing
+GET /assets/javascripts/routes controllers.Application.javascriptRoutes
+
+# Map static resources from the /public folder to the /public path
+GET /assets/*file controllers.Assets.at(path="/public", file)
+
diff --git a/querulous/logs/application.log b/querulous/logs/application.log
new file mode 100644
index 0000000..abf0b3b
--- /dev/null
+++ b/querulous/logs/application.log
@@ -0,0 +1,9 @@
+2012-07-26 22:11:00,748 - [INFO] - from play in main
+Listening for HTTP on port 9000...
+
+2012-07-26 22:11:38,468 - [INFO] - from play in play-akka.actor.default-dispatcher-3
+database [default] connected at jdbc:h2:mem:play
+
+2012-07-26 22:11:38,974 - [INFO] - from play in play-akka.actor.default-dispatcher-3
+Application started (Dev)
+
diff --git a/querulous/project/Build.scala b/querulous/project/Build.scala
new file mode 100644
index 0000000..152ce2e
--- /dev/null
+++ b/querulous/project/Build.scala
@@ -0,0 +1,23 @@
+import sbt._
+import Keys._
+
+import PlayProject._
+
+object ApplicationBuild extends Build {
+
+ val appName = "zentask"
+ val appVersion = "1.0"
+
+ val appDependencies = Seq(
+ //"com.twitter" % "querulous" % "2.7.6"
+ //"com.twitter" % "querulous_2.9.1" % "2.7.0"
+ "com.twitter" % "querulous-core_2.9.1" % "2.7.0",
+ "mysql" % "mysql-connector-java" % "5.1.21"
+ )
+
+ val main = PlayProject(appName, appVersion, appDependencies, mainLang = SCALA).settings(
+ resolvers += "Maven" at "http://maven.twttr.com/"
+ )
+
+}
+
diff --git a/querulous/project/build.properties b/querulous/project/build.properties
new file mode 100644
index 0000000..390c1af
--- /dev/null
+++ b/querulous/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.11.3
\ No newline at end of file
diff --git a/querulous/project/plugins.sbt b/querulous/project/plugins.sbt
new file mode 100644
index 0000000..6ebbeb1
--- /dev/null
+++ b/querulous/project/plugins.sbt
@@ -0,0 +1,10 @@
+// Comment to get more information during initialization
+logLevel := Level.Warn
+
+// The Typesafe repository
+resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/"
+
+// Use the Play sbt plugin for Play projects
+addSbtPlugin("play" % "sbt-plugin" % Option(System.getProperty("play.version")).getOrElse("2.0"))
+
+
diff --git a/querulous/public/images/arrow-left.png b/querulous/public/images/arrow-left.png
new file mode 100644
index 0000000..9458ca1
Binary files /dev/null and b/querulous/public/images/arrow-left.png differ
diff --git a/querulous/public/images/breadcrumb-1.png b/querulous/public/images/breadcrumb-1.png
new file mode 100644
index 0000000..2b48824
Binary files /dev/null and b/querulous/public/images/breadcrumb-1.png differ
diff --git a/querulous/public/images/breadcrumb-2.png b/querulous/public/images/breadcrumb-2.png
new file mode 100644
index 0000000..c77aa79
Binary files /dev/null and b/querulous/public/images/breadcrumb-2.png differ
diff --git a/querulous/public/images/breadcrumb.png b/querulous/public/images/breadcrumb.png
new file mode 100644
index 0000000..6033bd9
Binary files /dev/null and b/querulous/public/images/breadcrumb.png differ
diff --git a/querulous/public/images/icons/addRemove.png b/querulous/public/images/icons/addRemove.png
new file mode 100644
index 0000000..ad2bdb5
Binary files /dev/null and b/querulous/public/images/icons/addRemove.png differ
diff --git a/querulous/public/images/icons/clock.png b/querulous/public/images/icons/clock.png
new file mode 100644
index 0000000..287f369
Binary files /dev/null and b/querulous/public/images/icons/clock.png differ
diff --git a/querulous/public/images/icons/delete.png b/querulous/public/images/icons/delete.png
new file mode 100644
index 0000000..d3ba1fa
Binary files /dev/null and b/querulous/public/images/icons/delete.png differ
diff --git a/querulous/public/images/icons/drawer.folder.png b/querulous/public/images/icons/drawer.folder.png
new file mode 100644
index 0000000..312c269
Binary files /dev/null and b/querulous/public/images/icons/drawer.folder.png differ
diff --git a/querulous/public/images/icons/folder.png b/querulous/public/images/icons/folder.png
new file mode 100644
index 0000000..c4b941e
Binary files /dev/null and b/querulous/public/images/icons/folder.png differ
diff --git a/querulous/public/images/icons/home.png b/querulous/public/images/icons/home.png
new file mode 100644
index 0000000..646fd54
Binary files /dev/null and b/querulous/public/images/icons/home.png differ
diff --git a/querulous/public/images/icons/options.png b/querulous/public/images/icons/options.png
new file mode 100644
index 0000000..ff1b337
Binary files /dev/null and b/querulous/public/images/icons/options.png differ
diff --git a/querulous/public/images/icons/user.png b/querulous/public/images/icons/user.png
new file mode 100644
index 0000000..4493c0e
Binary files /dev/null and b/querulous/public/images/icons/user.png differ
diff --git a/querulous/public/images/icons/user2.png b/querulous/public/images/icons/user2.png
new file mode 100644
index 0000000..f829ede
Binary files /dev/null and b/querulous/public/images/icons/user2.png differ
diff --git a/querulous/public/images/loading.gif b/querulous/public/images/loading.gif
new file mode 100644
index 0000000..daa933c
Binary files /dev/null and b/querulous/public/images/loading.gif differ
diff --git a/querulous/public/images/pattern.png b/querulous/public/images/pattern.png
new file mode 100644
index 0000000..a8eea3d
Binary files /dev/null and b/querulous/public/images/pattern.png differ
diff --git a/querulous/public/javascripts/backbone-min.js b/querulous/public/javascripts/backbone-min.js
new file mode 100644
index 0000000..3f0d495
--- /dev/null
+++ b/querulous/public/javascripts/backbone-min.js
@@ -0,0 +1,33 @@
+// Backbone.js 0.5.3
+// (c) 2010 Jeremy Ashkenas, DocumentCloud Inc.
+// Backbone may be freely distributed under the MIT license.
+// For all details and documentation:
+// http://documentcloud.github.com/backbone
+(function(){var h=this,p=h.Backbone,e;e=typeof exports!=="undefined"?exports:h.Backbone={};e.VERSION="0.5.3";var f=h._;if(!f&&typeof require!=="undefined")f=require("underscore")._;var g=h.jQuery||h.Zepto;e.noConflict=function(){h.Backbone=p;return this};e.emulateHTTP=!1;e.emulateJSON=!1;e.Events={bind:function(a,b,c){var d=this._callbacks||(this._callbacks={});(d[a]||(d[a]=[])).push([b,c]);return this},unbind:function(a,b){var c;if(a){if(c=this._callbacks)if(b){c=c[a];if(!c)return this;for(var d=
+0,e=c.length;d/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")},has:function(a){return this.attributes[a]!=null},set:function(a,b){b||(b={});if(!a)return this;if(a.attributes)a=a.attributes;var c=this.attributes,d=this._escapedAttributes;if(!b.silent&&this.validate&&!this._performValidation(a,b))return!1;if(this.idAttribute in a)this.id=a[this.idAttribute];
+var e=this._changing;this._changing=!0;for(var g in a){var h=a[g];if(!f.isEqual(c[g],h))c[g]=h,delete d[g],this._changed=!0,b.silent||this.trigger("change:"+g,this,h,b)}!e&&!b.silent&&this._changed&&this.change(b);this._changing=!1;return this},unset:function(a,b){if(!(a in this.attributes))return this;b||(b={});var c={};c[a]=void 0;if(!b.silent&&this.validate&&!this._performValidation(c,b))return!1;delete this.attributes[a];delete this._escapedAttributes[a];a==this.idAttribute&&delete this.id;this._changed=
+!0;b.silent||(this.trigger("change:"+a,this,void 0,b),this.change(b));return this},clear:function(a){a||(a={});var b,c=this.attributes,d={};for(b in c)d[b]=void 0;if(!a.silent&&this.validate&&!this._performValidation(d,a))return!1;this.attributes={};this._escapedAttributes={};this._changed=!0;if(!a.silent){for(b in c)this.trigger("change:"+b,this,void 0,a);this.change(a)}return this},fetch:function(a){a||(a={});var b=this,c=a.success;a.success=function(d,e,f){if(!b.set(b.parse(d,f),a))return!1;c&&
+c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"read",this,a)},save:function(a,b){b||(b={});if(a&&!this.set(a,b))return!1;var c=this,d=b.success;b.success=function(a,e,f){if(!c.set(c.parse(a,f),b))return!1;d&&d(c,a,f)};b.error=i(b.error,c,b);var f=this.isNew()?"create":"update";return(this.sync||e.sync).call(this,f,this,b)},destroy:function(a){a||(a={});if(this.isNew())return this.trigger("destroy",this,this.collection,a);var b=this,c=a.success;a.success=function(d){b.trigger("destroy",
+b,b.collection,a);c&&c(b,d)};a.error=i(a.error,b,a);return(this.sync||e.sync).call(this,"delete",this,a)},url:function(){var a=k(this.collection)||this.urlRoot||l();if(this.isNew())return a;return a+(a.charAt(a.length-1)=="/"?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this)},isNew:function(){return this.id==null},change:function(a){this.trigger("change",this,a);this._previousAttributes=f.clone(this.attributes);this._changed=!1},hasChanged:function(a){if(a)return this._previousAttributes[a]!=
+this.attributes[a];return this._changed},changedAttributes:function(a){a||(a=this.attributes);var b=this._previousAttributes,c=!1,d;for(d in a)f.isEqual(b[d],a[d])||(c=c||{},c[d]=a[d]);return c},previous:function(a){if(!a||!this._previousAttributes)return null;return this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},_performValidation:function(a,b){var c=this.validate(a);if(c)return b.error?b.error(this,c,b):this.trigger("error",this,c,b),!1;return!0}});
+e.Collection=function(a,b){b||(b={});if(b.comparator)this.comparator=b.comparator;f.bindAll(this,"_onModelEvent","_removeReference");this._reset();a&&this.reset(a,{silent:!0});this.initialize.apply(this,arguments)};f.extend(e.Collection.prototype,e.Events,{model:e.Model,initialize:function(){},toJSON:function(){return this.map(function(a){return a.toJSON()})},add:function(a,b){if(f.isArray(a))for(var c=0,d=a.length;c').hide().appendTo("body")[0].contentWindow,this.navigate(a);
+this._hasPushState?g(window).bind("popstate",this.checkUrl):"onhashchange"in window&&!b?g(window).bind("hashchange",this.checkUrl):setInterval(this.checkUrl,this.interval);this.fragment=a;m=!0;a=window.location;b=a.pathname==this.options.root;if(this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),window.location.replace(this.options.root+"#"+this.fragment),!0;else if(this._wantsPushState&&this._hasPushState&&b&&a.hash)this.fragment=a.hash.replace(j,""),window.history.replaceState({},
+document.title,a.protocol+"//"+a.host+this.options.root+this.fragment);if(!this.options.silent)return this.loadUrl()},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a==this.fragment&&this.iframe&&(a=this.getFragment(this.iframe.location.hash));if(a==this.fragment||a==decodeURIComponent(this.fragment))return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(window.location.hash)},loadUrl:function(a){var b=this.fragment=this.getFragment(a);
+return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){var c=(a||"").replace(j,"");if(!(this.fragment==c||this.fragment==decodeURIComponent(c))){if(this._hasPushState){var d=window.location;c.indexOf(this.options.root)!=0&&(c=this.options.root+c);this.fragment=c;window.history.pushState({},document.title,d.protocol+"//"+d.host+c)}else if(window.location.hash=this.fragment=c,this.iframe&&c!=this.getFragment(this.iframe.location.hash))this.iframe.document.open().close(),
+this.iframe.location.hash=c;b&&this.loadUrl(a)}}});e.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.delegateEvents();this.initialize.apply(this,arguments)};var u=/^(\S+)\s*(.*)$/,n=["model","collection","el","id","attributes","className","tagName"];f.extend(e.View.prototype,e.Events,{tagName:"div",$:function(a){return g(a,this.el)},initialize:function(){},render:function(){return this},remove:function(){g(this.el).remove();return this},make:function(a,
+b,c){a=document.createElement(a);b&&g(a).attr(b);c&&g(a).html(c);return a},delegateEvents:function(a){if(a||(a=this.events))for(var b in f.isFunction(a)&&(a=a.call(this)),g(this.el).unbind(".delegateEvents"+this.cid),a){var c=this[a[b]];if(!c)throw Error('Event "'+a[b]+'" does not exist');var d=b.match(u),e=d[1];d=d[2];c=f.bind(c,this);e+=".delegateEvents"+this.cid;d===""?g(this.el).bind(e,c):g(this.el).delegate(d,e,c)}},_configure:function(a){this.options&&(a=f.extend({},this.options,a));for(var b=
+0,c=n.length;b=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a))return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,b){var d={};j(a,function(a,f){var g=b(a,f);(d[g]||(d[g]=[])).push(a)});return d};b.sortedIndex=function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})};
+b.difference=function(a,c){return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after=function(a,b){return function(){if(--a<1)return b.apply(this,
+arguments)}};b.keys=H||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var b=[],d;for(d in a)l.call(a,d)&&(b[b.length]=d);return b};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)b[d]!==void 0&&(a[d]=b[d])});return a};b.defaults=function(a){j(i.call(arguments,1),function(b){for(var d in b)a[d]==null&&(a[d]=
+b[d])});return a};b.clone=function(a){return b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return q(a,b,[])};b.isEmpty=function(a){if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(l.call(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=o||function(a){return u.call(a)==="[object Array]"};b.isObject=function(a){return a===Object(a)};b.isArguments=function(a){return!(!a||!l.call(a,"callee"))};
+b.isFunction=function(a){return!(!a||!a.constructor||!a.call||!a.apply)};b.isString=function(a){return!!(a===""||a&&a.charCodeAt&&a.substr)};b.isNumber=function(a){return!!(a===0||a&&a.toExponential&&a.toFixed)};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||u.call(a)=="[object Boolean]"};b.isDate=function(a){return!(!a||!a.getTimezoneOffset||!a.setUTCFullYear)};b.isRegExp=function(a){return!(!a||!a.test||!a.exec||!(a.ignoreCase||a.ignoreCase===false))};b.isNull=
+function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.noConflict=function(){r._=F;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.mixin=function(a){j(b.functions(a),function(c){I(c,b[c]=a[c])})};var J=0;b.uniqueId=function(a){var b=
+J++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};b.template=function(a,c){var d=b.templateSettings,d="var __p=[],print=function(){__p.push.apply(__p,arguments);};with(obj||{}){__p.push('"+a.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(d.escape,function(a,b){return"',_.escape("+b.replace(/\\'/g,"'")+"),'"}).replace(d.interpolate,function(a,b){return"',"+b.replace(/\\'/g,"'")+",'"}).replace(d.evaluate||null,function(a,
+b){return"');"+b.replace(/\\'/g,"'").replace(/[\r\n\t]/g," ")+"__p.push('"}).replace(/\r/g,"\\r").replace(/\n/g,"\\n").replace(/\t/g,"\\t")+"');}return __p.join('');",d=new Function("obj",d);return c?d(c):d};var m=function(a){this._wrapped=a};b.prototype=m.prototype;var t=function(a,c){return c?b(a).chain():a},I=function(a,c){m.prototype[a]=function(){var a=i.call(arguments);G.call(a,this._wrapped);return t(c.apply(b,a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),
+function(a){var b=k[a];m.prototype[a]=function(){b.apply(this._wrapped,arguments);return t(this._wrapped,this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];m.prototype[a]=function(){return t(b.apply(this._wrapped,arguments),this._chain)}});m.prototype.chain=function(){this._chain=true;return this};m.prototype.value=function(){return this._wrapped}})();
diff --git a/querulous/test/IntegrationSpec.scala b/querulous/test/IntegrationSpec.scala
new file mode 100644
index 0000000..2ffd48a
--- /dev/null
+++ b/querulous/test/IntegrationSpec.scala
@@ -0,0 +1,37 @@
+package test
+
+import org.specs2.mutable._
+
+import play.api.test._
+import play.api.test.Helpers._
+
+class IntegrationSpec extends Specification {
+
+ "Application" should {
+
+ "work from within a browser" in {
+ running(TestServer(3333), HTMLUNIT) { browser =>
+ browser.goTo("http://localhost:3333/")
+ browser.$("header a").first.getText must equalTo("Zentasks")
+ browser.$("#email").text("guillaume@sample.com")
+ browser.$("#password").text("secret111")
+ browser.$("#loginbutton").click()
+ browser.pageSource must contain("Invalid email or password")
+
+ browser.$("#email").text("guillaume@sample.com")
+ browser.$("#password").text("secret")
+ browser.$("#loginbutton").click()
+ browser.$("dl.error").size must equalTo(0)
+ browser.pageSource must not contain("Sign in")
+ browser.pageSource must contain("guillaume@sample.com")
+ browser.pageSource must contain("Logout")
+
+ val items = browser.$("li")
+ items.size must equalTo(15)
+ items.get(3).getText must contain("Website Delete")
+ }
+ }
+
+ }
+
+}