620 lines
13 KiB
Plaintext
620 lines
13 KiB
Plaintext
|
|
maquette = require "maquette"
|
|
nmd = require "nano-markdown"
|
|
|
|
{create-projector, h} = maquette
|
|
|
|
projector = create-projector!
|
|
|
|
bulma = require "./bulma.ls"
|
|
|
|
get-previous = (collection, element) ->
|
|
var previous
|
|
|
|
for item in collection
|
|
if item == element
|
|
return previous
|
|
|
|
previous = item
|
|
|
|
get-next = (collection, element) ->
|
|
var found-element
|
|
|
|
for item in collection
|
|
if found-element
|
|
return item
|
|
|
|
if item == element
|
|
found-element := true
|
|
|
|
Task = (self, project) ->
|
|
self.render = ->
|
|
author = model.users[self.author]
|
|
if typeof(author) != "object" and author != "request sent"
|
|
model.users[self.author] = "request sent"
|
|
# FIXME: This should go directly to authd.
|
|
socket.get-user self.author
|
|
|
|
assigned_to = model.users[self.assigned_to]
|
|
if self.assigned_to and typeof(assigned_to) != "object" and assigned_to != "request sent"
|
|
model.users[self.assigned_to] = "request sent"
|
|
# FIXME: This should go directly to authd.
|
|
socket.get-user self.assigned_to
|
|
|
|
is-selected = model.selected == self.id
|
|
|
|
h (\div.card.is- + (self.color || "dark")), {
|
|
key: self.id
|
|
classes: {
|
|
"is-selected": is-selected
|
|
}
|
|
onclick: ->
|
|
model.selected := self.id
|
|
} [
|
|
h \div.card-content [
|
|
h \div.media [
|
|
h \div.media-left [
|
|
h \img.image.is-48x48.avatar {
|
|
alt: "user image"
|
|
src: if typeof(assigned_to) == "object"
|
|
assigned_to.avatar
|
|
else
|
|
"https://bulma.io/images/placeholders/96x96.png"
|
|
}
|
|
]
|
|
h \div.media-content [
|
|
if model.editing == self.id + ".title"
|
|
h \input.input {
|
|
value: self.title
|
|
onchange: (e) ->
|
|
model.editing := undefined
|
|
socket.edit-task project.id, self.id, {
|
|
title: e.target.value
|
|
}
|
|
} [ self.title ]
|
|
else
|
|
h \a [
|
|
bulma.title 4 self.title
|
|
]
|
|
|
|
if typeof(model.users[self.assigned_to]) == "object"
|
|
user = model.users[self.assigned_to]
|
|
|
|
h \div.subtitle.is-6 [
|
|
"@" + (user.full_name || user.login)
|
|
]
|
|
]
|
|
if ! is-selected && self.description != ""
|
|
h \div.media-right {key: "description-icon"} [
|
|
h \span.icon.is-size-1 [ "🗎" ]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.media-right {key: "edit"} [
|
|
h \a.small {
|
|
onclick: ->
|
|
if model.editing == self.id + ".title"
|
|
model.editing := undefined
|
|
else
|
|
model.editing := self.id + ".title"
|
|
} [
|
|
"Edit"
|
|
]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.media-right {key: "delete"} [
|
|
h \a.small {
|
|
onclick: ->
|
|
model.editing := self.id + ".delete"
|
|
} [
|
|
"Delete"
|
|
]
|
|
]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.content {
|
|
key: self.description
|
|
after-create: (dom) ->
|
|
dom.innerHTML = nmd self.description
|
|
} [
|
|
if model.editing == self.id + ".description"
|
|
h \form.form [
|
|
h \textarea.textarea {
|
|
value: model.editing-data
|
|
oninput: (e) ->
|
|
model.editing-data := e.target.value
|
|
}
|
|
h \div.button.is-fullwidth {
|
|
onclick: ->
|
|
socket.edit-task project.id, self.id, {
|
|
description: model.editing-data
|
|
}
|
|
model.editing-data := undefined
|
|
model.editing := undefined
|
|
} [ "Update" ]
|
|
]
|
|
]
|
|
|
|
if is-selected
|
|
h \span.button.is-small {
|
|
onclick: ->
|
|
model.editing-data := self.description
|
|
model.editing := self.id + ".description"
|
|
} [
|
|
"edit"
|
|
]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.card-footer {key: "assign"} [
|
|
if model.editing == self.id + ".assigned_to"
|
|
h \div.card-footer-item {
|
|
key: "assign.clicked"
|
|
} [
|
|
h \input.input {
|
|
onchange: (e) ->
|
|
model.editing := undefined
|
|
socket.edit-task project.id, self.id, {
|
|
assigned_to: Number e.target.value
|
|
}
|
|
}
|
|
]
|
|
else
|
|
h \a.card-footer-item {
|
|
key: "assign"
|
|
onclick: ->
|
|
model.editing := self.id + ".assigned_to"
|
|
} [ "Assign" ]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.card-footer {key: "color"} [
|
|
if model.editing == self.id + ".color"
|
|
h \div.card-footer-item {
|
|
key: "color.clicked"
|
|
} [
|
|
h \input.input {
|
|
onchange: (e) ->
|
|
model.editing := undefined
|
|
socket.edit-task project.id, self.id, {
|
|
color: e.target.value
|
|
}
|
|
}
|
|
]
|
|
else
|
|
h \a.card-footer-item {
|
|
key: "assign"
|
|
onclick: ->
|
|
model.editing := self.id + ".color"
|
|
} [ "Change Color" ]
|
|
]
|
|
|
|
if is-selected
|
|
h \div.card-footer {key: "move"} [
|
|
h \a.card-footer-item {
|
|
key: "⇐"
|
|
onclick: ->
|
|
socket.edit-task project.id, self.id, {
|
|
column: get-previous project.columns.map((.id)), self.column
|
|
}
|
|
} [ "⇐" ]
|
|
|
|
if model.editing == self.id + ".delete"
|
|
h \a.card-footer-item {
|
|
key: "delete"
|
|
} [
|
|
h \div.button.is-danger {
|
|
onclick: ->
|
|
socket.delete-task project.id, self.id
|
|
} [ "Delete! For real!" ]
|
|
]
|
|
|
|
h \a.card-footer-item {
|
|
key: "⇒"
|
|
onclick: ->
|
|
socket.edit-task project.id, self.id, {
|
|
column: get-next project.columns.map((.id)), self.column
|
|
}
|
|
} [ "⇒" ]
|
|
]
|
|
]
|
|
|
|
self
|
|
|
|
Project = (self) ->
|
|
self.tasks = self.tasks.map (e) -> Task e, self
|
|
|
|
self.render-column = (column) ->
|
|
h \div.column.is-3 {
|
|
key: column.id
|
|
} [
|
|
h \div.card.is-column-header {
|
|
key: column.id
|
|
} [
|
|
h \div.card-header [
|
|
if model.editing == column.id + ".title"
|
|
h \input.input {
|
|
type: "text",
|
|
value: column.name
|
|
onchange: (e) ->
|
|
console.log "onchange??"
|
|
model.editing := undefined
|
|
|
|
socket.edit-column self.id, column.id, {
|
|
name: e.target.value
|
|
}
|
|
}
|
|
else
|
|
h \div.card-header-title [
|
|
bulma.title 3 column.name
|
|
]
|
|
|
|
h \a.card-header-icon {
|
|
key: "edit"
|
|
onclick: ->
|
|
if model.editing == column.id + ".title"
|
|
model.editing := undefined
|
|
else
|
|
model.editing := column.id + ".title"
|
|
} [
|
|
"Edit"
|
|
]
|
|
|
|
if self.tasks.filter((.column == column.id)).length == 0
|
|
h \a.card-header-icon {
|
|
key: "delete"
|
|
onclick: ->
|
|
model.editing := column.id + ".delete"
|
|
} [
|
|
"Delete"
|
|
]
|
|
]
|
|
if model.editing == column.id + ".delete"
|
|
h \div.card-content [
|
|
h \div.button.is-fullwidth.is-danger {
|
|
onclick: ->
|
|
socket.delete-column self.id, column.id
|
|
} [ "Delete me!"]
|
|
]
|
|
]
|
|
|
|
for task in self.tasks
|
|
continue if task.column != column.id
|
|
|
|
task.render!
|
|
|
|
h \div.button.is-fullwidth {
|
|
onclick: ->
|
|
socket.new-task self.id, column.id, {
|
|
title: "General Kenobi…"
|
|
description: ""
|
|
}
|
|
} [ "New task" ]
|
|
]
|
|
|
|
self.render = ->
|
|
h \div.project {
|
|
key: self.id
|
|
} [
|
|
h \div.hero.is-dark { key: "title" } [
|
|
h \div.hero-body [
|
|
# FIXME: Consider using a .level for this.
|
|
h \div.is-pulled-right {
|
|
onclick: ->
|
|
model.editing := self.id + ".name"
|
|
} [
|
|
"Edit"
|
|
]
|
|
|
|
if model.editing == self.id + ".name"
|
|
h \input.input {
|
|
onchange: (e) ->
|
|
model.editing := undefined
|
|
socket.edit-project self.id, {
|
|
name: e.target.value
|
|
}
|
|
value: self.name
|
|
}
|
|
else
|
|
h \div.title [ self.name ]
|
|
]
|
|
]
|
|
|
|
h \div.columns [
|
|
for dom in self.columns.map((column) -> self.render-column(column))
|
|
dom
|
|
|
|
h \div.column.is-2 {
|
|
key: "new-column"
|
|
} [
|
|
h \div.button.is-fullwidth {
|
|
onclick: ->
|
|
socket.new-column self.id, "Hello, there!"
|
|
} [ "New Column" ]
|
|
]
|
|
]
|
|
]
|
|
|
|
self
|
|
|
|
model = {
|
|
current-view: "login"
|
|
editing: undefined
|
|
selected: undefined
|
|
users: {}
|
|
projects: {}
|
|
projects-list: []
|
|
}
|
|
|
|
protocol = if location.protocol == 'https' then 'wss' else 'ws'
|
|
port = 9999
|
|
socket-url = protocol + '://' + location.hostname + \: + port + "/kanban.JSON"
|
|
|
|
console.log socket-url
|
|
|
|
KanbanSocket = (socket-url) ->
|
|
self = {}
|
|
|
|
self.open-socket = ->
|
|
console.log "Opening socket to #{socket-url}"
|
|
self.socket := new WebSocket socket-url
|
|
|
|
self.socket.onerror = (event) ->
|
|
console.log "WebSocket error.", event
|
|
model.state := "websocket-error"
|
|
self.socket.close!
|
|
projector.schedule-render!
|
|
|
|
self.socket.onclose = (event) ->
|
|
# Exporting the error in case the UI is able to deal with it.
|
|
model.previous-state := model.state
|
|
model.state := "websocket-error"
|
|
model.websocket-error := event.reason
|
|
projector.schedule-render!
|
|
|
|
self.socket.onmessage = (event) ->
|
|
payload = JSON.parse(event.data).payload
|
|
|
|
self.on-message {data: payload}
|
|
|
|
self.reopen = ->
|
|
self.socket.close!
|
|
self.open-socket!
|
|
|
|
self.open-socket!
|
|
|
|
self.send = (opts) ->
|
|
console.log JSON.stringify {
|
|
mtype: 0,
|
|
payload: opts
|
|
}
|
|
|
|
self.socket.send JSON.stringify {
|
|
mtype: 0,
|
|
payload: opts
|
|
}
|
|
|
|
self.get-project = (project-id) ->
|
|
self.send JSON.stringify {
|
|
type: "project",
|
|
project: project-id
|
|
}
|
|
|
|
self.login = (login, password) ->
|
|
self.send JSON.stringify {
|
|
type: "login",
|
|
login: login
|
|
password: password
|
|
}
|
|
|
|
self.get-user = (uid) ->
|
|
self.send JSON.stringify {
|
|
type: "get-user"
|
|
uid: uid
|
|
}
|
|
|
|
self.new-project = (name) ->
|
|
self.send JSON.stringify {
|
|
type: "new-project"
|
|
name: name
|
|
}
|
|
|
|
self.edit-task = (project-id, task-id, options) ->
|
|
payload = {
|
|
type: "edit-task"
|
|
project: project-id
|
|
task: task-id
|
|
}
|
|
|
|
for key, value of options
|
|
payload[key] = value
|
|
|
|
self.send JSON.stringify payload
|
|
|
|
self.delete-task = (project-id, task-id) ->
|
|
self.send JSON.stringify {
|
|
type: "delete-task"
|
|
project: project-id
|
|
task: task-id
|
|
}
|
|
|
|
self.edit-column = (project-id, column-id, options) ->
|
|
payload = {
|
|
type: "edit-column"
|
|
project: project-id
|
|
column: column-id
|
|
}
|
|
|
|
for key, value of options
|
|
payload[key] = value
|
|
|
|
self.send JSON.stringify payload
|
|
|
|
self.delete-column = (project-id, column-id) ->
|
|
self.send JSON.stringify {
|
|
type: "delete-column"
|
|
project: project-id
|
|
column: column-id
|
|
}
|
|
|
|
self.new-column = (project-id, name) ->
|
|
self.send JSON.stringify {
|
|
type: "new-column"
|
|
project: project-id
|
|
name: name
|
|
}
|
|
|
|
self.new-task = (project-id, column-id, options) ->
|
|
payload = {
|
|
type: "new-task"
|
|
project: project-id
|
|
column: column-id
|
|
}
|
|
|
|
for key, value of options
|
|
payload[key] = value
|
|
|
|
self.send JSON.stringify payload
|
|
|
|
self.edit-project = (project-id, options) ->
|
|
payload = {
|
|
type: "edit-project"
|
|
project: project-id
|
|
}
|
|
|
|
for key, value of options
|
|
payload[key] = value
|
|
|
|
self.send JSON.stringify payload
|
|
|
|
self
|
|
|
|
socket = KanbanSocket socket-url
|
|
|
|
socket.on-close = ->
|
|
model.current-view := "login"
|
|
socket.reopen!
|
|
|
|
socket.on-message = (event) ->
|
|
message = JSON.parse event.data
|
|
|
|
switch message.type
|
|
when "login"
|
|
model.current-view := "projects-list"
|
|
when "list-projects"
|
|
for project in message.projects
|
|
model.projects[project.id] = Project project
|
|
model.projects-list := message.projects
|
|
when "project"
|
|
model.projects[message.project.id] = Project message.project
|
|
when "user"
|
|
if message.user
|
|
model.users[message.user.uid] := message.user
|
|
when "login-error"
|
|
model.login-error = message.error
|
|
else
|
|
console.log "RECEIVED UNKNOWN MESSAGE TYPE: #{message.type}"
|
|
|
|
projector.schedule-render!
|
|
|
|
console.log message
|
|
|
|
renderer = ->
|
|
render-navbar = ->
|
|
h \div.navbar [
|
|
h \div.navbar-start [
|
|
h \a.navbar-item.is-size-1 {
|
|
onclick: ->
|
|
model.viewed-project := undefined
|
|
model.current-view := "projects-list"
|
|
} [ "⌂" ]
|
|
]
|
|
|
|
h \div.navbar-end [
|
|
h \a.navbar-item {
|
|
onclick: ->
|
|
model.current-view := "login"
|
|
socket.reopen!
|
|
} [ "Logout" ]
|
|
]
|
|
]
|
|
|
|
h \div.section [
|
|
switch model.current-view
|
|
when "login"
|
|
h \div.container [
|
|
h \div.box [
|
|
if model.login-error
|
|
h \div.notification.is-danger [
|
|
model.login-error
|
|
]
|
|
h \form [
|
|
bulma.field [
|
|
bulma.label "Login"
|
|
bulma.input {
|
|
oninput: (e) ->
|
|
model.login = e.target.value
|
|
name: \login
|
|
id: \login-input
|
|
}
|
|
]
|
|
bulma.field [
|
|
bulma.label "Password"
|
|
bulma.input {
|
|
oninput: (e) ->
|
|
model.password = e.target.value
|
|
name: \password
|
|
type: \password
|
|
id: \password-input
|
|
}
|
|
]
|
|
|
|
h \button.button.is-fullwidth.is-primary {
|
|
onclick: (e) ->
|
|
e.prevent-default!
|
|
|
|
model.login-error = undefined
|
|
socket.login model.login, model.password
|
|
} [ "Letsu go!" ]
|
|
]
|
|
]
|
|
]
|
|
when "project"
|
|
h \div [
|
|
render-navbar!
|
|
|
|
if model.projects[model.viewed-project]
|
|
model.projects[model.viewed-project].render!
|
|
]
|
|
when "projects-list"
|
|
h \div#projects-list [
|
|
render-navbar!
|
|
|
|
h \div.section (model.projects-list || []).map (project) ->
|
|
h \a.box {
|
|
key: project.id
|
|
onclick: ->
|
|
model.current-view := "project"
|
|
model.viewed-project := project.id
|
|
socket.get-project project.id
|
|
} [
|
|
bulma.title 3 project.name
|
|
]
|
|
|
|
h \div.button.is-primary.is-large.is-fullwidth {
|
|
onclick: ->
|
|
socket.new-project "Hello, there!"
|
|
} [ "New project! (random atm)" ]
|
|
]
|
|
else
|
|
h \div.notification.is-error [
|
|
"Wait, what? Internal error!"
|
|
]
|
|
]
|
|
|
|
document.add-event-listener 'DOMContentLoaded' ->
|
|
projector.append document.body, renderer
|
|
|