commit 660e7a18601f33df15fa02ce09b13ae344c0ba7c Author: Philippe PITTOLI Date: Wed Nov 20 19:22:51 2019 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41cb870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/* +package-lock.json + +# generated files +*.js +*.css diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..855a0da --- /dev/null +++ b/Makefile @@ -0,0 +1,153 @@ +PACKAGE = 'todo-webclient' +VERSION = '0.1' + +PREFIX := /usr/local +BINDIR := $(PREFIX)/bin +LIBDIR := $(PREFIX)/lib +SHAREDIR := $(PREFIX)/share +INCLUDEDIR := $(PREFIX)/include +MANDIR := $(SHAREDIR)/man + +Q := @ + +all: main.js style.css + @: + +main.js: main.bundle.js + @echo ' MIN > main.js' + $(Q)npx babel --minified main.bundle.js -o main.js + + +main.bundle.js: client/index.ls client/authd.ls client/bulma.ls client/todod.ls + @echo ' BUN > main.bundle.js' + $(Q)npx browserify -t browserify-livescript client/index.ls -o main.bundle.js + + +main.js.install: main.js + @echo ' IN > $(SHAREDIR)/todo-webclient/main.js' + $(Q)mkdir -p '$(DESTDIR)$(SHAREDIR)/todo-webclient' + $(Q)install -m644 main.js $(DESTDIR)$(SHAREDIR)/todo-webclient/main.js + +main.js.clean: + @echo ' RM > main.js' + $(Q)rm -f main.js + @echo ' RM > main.bundle.js' + $(Q)rm -f main.bundle.js + + +main.js.uninstall: + @echo ' RM > $(SHAREDIR)/todo-webclient/main.js' + $(Q)rm -f '$(DESTDIR)$(SHAREDIR)/todo-webclient/main.js' + +style.css: client/style.sass + @echo ' CSS > style.css' + $(Q)sassc client/style.sass > style.css + + +style.css.install: style.css + @echo ' IN > $(SHAREDIR)/todo-webclient/style.css' + $(Q)mkdir -p '$(DESTDIR)$(SHAREDIR)/todo-webclient' + $(Q)install -m644 style.css $(DESTDIR)$(SHAREDIR)/todo-webclient/style.css + +style.css.clean: + @echo ' RM > style.css' + $(Q)rm -f style.css + +style.css.uninstall: + @echo ' RM > $(SHAREDIR)/todo-webclient/style.css' + $(Q)rm -f '$(DESTDIR)$(SHAREDIR)/todo-webclient/style.css' + +$(DESTDIR)$(PREFIX): + @echo ' DIR > $(PREFIX)' + $(Q)mkdir -p $(DESTDIR)$(PREFIX) +$(DESTDIR)$(BINDIR): + @echo ' DIR > $(BINDIR)' + $(Q)mkdir -p $(DESTDIR)$(BINDIR) +$(DESTDIR)$(LIBDIR): + @echo ' DIR > $(LIBDIR)' + $(Q)mkdir -p $(DESTDIR)$(LIBDIR) +$(DESTDIR)$(SHAREDIR): + @echo ' DIR > $(SHAREDIR)' + $(Q)mkdir -p $(DESTDIR)$(SHAREDIR) +$(DESTDIR)$(INCLUDEDIR): + @echo ' DIR > $(INCLUDEDIR)' + $(Q)mkdir -p $(DESTDIR)$(INCLUDEDIR) +$(DESTDIR)$(MANDIR): + @echo ' DIR > $(MANDIR)' + $(Q)mkdir -p $(DESTDIR)$(MANDIR) +install: main.js.install style.css.install + @: + +uninstall: main.js.uninstall style.css.uninstall + @: + +clean: main.js.clean style.css.clean +distclean: clean +dist: dist-gz dist-xz dist-bz2 + $(Q)rm -- $(PACKAGE)-$(VERSION) + +distdir: + $(Q)rm -rf -- $(PACKAGE)-$(VERSION) + $(Q)ln -s -- . $(PACKAGE)-$(VERSION) + +dist-gz: $(PACKAGE)-$(VERSION).tar.gz +$(PACKAGE)-$(VERSION).tar.gz: distdir + @echo ' TAR > $(PACKAGE)-$(VERSION).tar.gz' + $(Q)tar czf $(PACKAGE)-$(VERSION).tar.gz \ + $(PACKAGE)-$(VERSION)/client/index.ls \ + $(PACKAGE)-$(VERSION)/client/style.sass \ + $(PACKAGE)-$(VERSION)/client/authd.ls \ + $(PACKAGE)-$(VERSION)/client/bulma.ls \ + $(PACKAGE)-$(VERSION)/client/todod.ls + +dist-xz: $(PACKAGE)-$(VERSION).tar.xz +$(PACKAGE)-$(VERSION).tar.xz: distdir + @echo ' TAR > $(PACKAGE)-$(VERSION).tar.xz' + $(Q)tar cJf $(PACKAGE)-$(VERSION).tar.xz \ + $(PACKAGE)-$(VERSION)/client/index.ls \ + $(PACKAGE)-$(VERSION)/client/style.sass \ + $(PACKAGE)-$(VERSION)/client/authd.ls \ + $(PACKAGE)-$(VERSION)/client/bulma.ls \ + $(PACKAGE)-$(VERSION)/client/todod.ls + +dist-bz2: $(PACKAGE)-$(VERSION).tar.bz2 +$(PACKAGE)-$(VERSION).tar.bz2: distdir + @echo ' TAR > $(PACKAGE)-$(VERSION).tar.bz2' + $(Q)tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \ + $(PACKAGE)-$(VERSION)/client/index.ls \ + $(PACKAGE)-$(VERSION)/client/style.sass \ + $(PACKAGE)-$(VERSION)/client/authd.ls \ + $(PACKAGE)-$(VERSION)/client/bulma.ls \ + $(PACKAGE)-$(VERSION)/client/todod.ls + +help: + @echo ' :: todo-webclient-0.1' + @echo '' + @echo 'Generic targets:' + @echo ' - help  Prints this help message.' + @echo ' - all  Builds all targets.' + @echo ' - dist  Creates tarballs of the files of the project.' + @echo ' - install  Installs the project.' + @echo ' - clean  Removes compiled files.' + @echo ' - uninstall  Deinstalls the project.' + @echo '' + @echo 'CLI-modifiable variables:' + @echo ' - PREFIX  ${PREFIX}' + @echo ' - BINDIR  ${BINDIR}' + @echo ' - LIBDIR  ${LIBDIR}' + @echo ' - SHAREDIR  ${SHAREDIR}' + @echo ' - INCLUDEDIR  ${INCLUDEDIR}' + @echo ' - MANDIR  ${MANDIR}' + @echo '' + @echo 'Project targets: ' + @echo ' - main.js  livescript' + @echo ' - style.css  sass' + @echo '' + @echo 'Makefile options:' + @echo ' - gnu: false' + @echo ' - colors: true' + @echo '' + @echo 'Rebuild the Makefile with:' + @echo ' zsh ./build.zsh -c' +.PHONY: all clean distclean dist install uninstall help + diff --git a/client/authd.ls b/client/authd.ls new file mode 100644 index 0000000..a415ebe --- /dev/null +++ b/client/authd.ls @@ -0,0 +1,84 @@ +bulma = require "./bulma.ls" +h = require 'maquette' .h + + +module.exports = { + + login-widget: (model, socket) -> + 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-primary { + onclick: (e) -> + e.prevent-default! + + model.login-error = undefined + socket.login model.login, model.password + } [ "Connexion" ] + ] + ] + ] + + login-page: (model, socket) -> + 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 + } [ "Connexion" ] + ] + ] + ] +} diff --git a/client/bulma.ls b/client/bulma.ls new file mode 100644 index 0000000..ad315a6 --- /dev/null +++ b/client/bulma.ls @@ -0,0 +1,39 @@ + +h = require 'maquette' .h + +module.exports = { + box: (args, children) -> + h \div.box args, children + title: (level, args, label) -> + if not label + label = args + args = {} + + h "div.title.is-#{level}", args, [label] + label: (args, label) -> + if not label + label = args + args = {} + + h \label.label args, [label] + input: (args, children) -> + h \input.input args, children + + # FIXME: Use only args and add args.label and args.input? + # Or maybe args.name and args.type could be used directly? + field: (args, children) -> + h \div.field args, children + + modal: (args, content) -> + h \div.modal args, [ + h \div.modal-background args.background + h \div.modal-content [args.content] + ] + + form: (method, url, content) -> + h \form.form { + action: url + method: method + }, content +} + diff --git a/client/index.ls b/client/index.ls new file mode 100644 index 0000000..ee4c835 --- /dev/null +++ b/client/index.ls @@ -0,0 +1,462 @@ + +# first: +# connection to authd +# display the authd widget +# +# second: +# connection to todod +# rewrite the whole body component +# + +maquette = require "maquette" +nmd = require "nano-markdown" +authd = require "./authd.ls" +console.log authd + +{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: {} + list-todos: [] +} + +protocol = if location.protocol == 'https' then 'wss' else 'ws' +port = 9999 +# socket-url = protocol + '://' + location.hostname + \: + port + "/kanban.JSON" +socket-url = 'ws://www.junkos.netlib.re:' + port + "/kanban.JSON" + +console.log socket-url + + +socket = todod.create-socket 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 := "list-todos" + when "list-projects" + for project in message.projects + model.projects[project.id] = Project project + model.list-todos := 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 := "list-todos" + } [ "βŒ‚" ] + ] + h \div.navbar-end [ + h \a.navbar-item { + onclick: -> + model.current-view := "login" + socket.reopen! + } [ "Logout" ] + ] + authd.login-widget model, socket + ] + + h \div.section [ + switch model.current-view + when "login" + authd.login-page model, socket + + when "list-todos" + h \div#list-todos [ + render-navbar! + + h \div.section (model.list-todos || []).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)" ] + ] + + when "project" + h \div [ + render-navbar! + + if model.projects[model.viewed-project] + model.projects[model.viewed-project].render! + ] + + else + h \div.notification.is-error [ + "Wait, what? Internal error!" + ] + ] + +document.add-event-listener 'DOMContentLoaded' -> + projector.append document.body, renderer + diff --git a/client/style.sass b/client/style.sass new file mode 100644 index 0000000..5c50be7 --- /dev/null +++ b/client/style.sass @@ -0,0 +1,34 @@ +@charset "utf-8" + +@import "../node_modules/bulmaswatch/superhero/_variables.scss" + +// Import Bulma core +@import "../node_modules/bulma/bulma" + +@import "../node_modules/bulmaswatch/superhero/_overrides.scss" + +.columns + overflow-x: scroll + +.avatar + border-radius: 4px + +.card.is-selected + border-width: 4px + +.is-column-header + .card, .card + .button + margin-top: 12px + +.project > .hero + margin-bottom: 12px + +.project + margin-top: 12px + +@each $name, $pair in $colors + $color: nth($pair, 1) + $color-invert: nth($pair, 2) + + .card.is-#{$name} + background-color: darken($color, 30) + diff --git a/client/todod.ls b/client/todod.ls new file mode 100644 index 0000000..2c8d585 --- /dev/null +++ b/client/todod.ls @@ -0,0 +1,144 @@ +# todod module +# 1. create a socket +# 2. the socket parses and serialize data +# 3. the socket handles network errors + +module.exports = { + + create-socket: (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 +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..237f9ee --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + Kanban + + + + + + + + diff --git a/project.zsh b/project.zsh new file mode 100644 index 0000000..d539963 --- /dev/null +++ b/project.zsh @@ -0,0 +1,12 @@ + +package=todo-webclient +version=0.1 + +targets+=(main.js) +type[main.js]=livescript +sources[main.js]=client/index.ls +depends[main.js]="$(ls client/*.ls | grep -v /index.ls$ | tr '\n' ' ')" + +targets+=(style.css) +type[style.css]=sass +sources[style.css]=client/style.sass