From 9a164b984a32aa94b49de03c2d611d548f51792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 29 Nov 2024 09:15:42 +0100 Subject: [PATCH] wip --- authentication/pkg.go | 5 +- main.go | 22 +- sql/00001.sql | 62 +- sql/00002.sql | 31 +- sql/00003.sql | 10 + sql/00004.sql | 1 + sql/00005.sql | 2 + sql/00006.sql | 5 + sql/00007.sql | 10 + sql/00008.sql | 2 + sql/00009.sql | 1 + sql/00010.sql | 1 + sql/00011.sql | 5 + sql/00012.sql | 2 + sql/00013.sql | 1 + sql/00014.sql | 18 + sql/00015.sql | 14 + sql/00016.sql | 1 + sql/00017.sql | 11 + sql/00018.sql | 2 + sql/00019.sql | 1 + sql/00020.sql | 2 + sql/00021.sql | 1 + sql/00022.sql | 21 + static/css/notes2.css | 38 ++ static/images/collapsed.svg | 74 +++ static/images/expanded.svg | 65 ++ static/images/leaf.svg | 57 ++ static/js/app.mjs | 151 ++++- static/js/checklist.mjs | 472 +++++++++++++++ static/js/crypto.mjs | 72 +++ static/js/key.mjs | 241 ++++++++ static/js/node.mjs | 1117 +++++++++++++++++++++++++++++++++++ static/less/notes2.less | 52 ++ views/layouts/main.gotmpl | 6 +- views/pages/notes2.gotmpl | 1 + 36 files changed, 2500 insertions(+), 77 deletions(-) create mode 100644 sql/00003.sql create mode 100644 sql/00004.sql create mode 100644 sql/00005.sql create mode 100644 sql/00006.sql create mode 100644 sql/00007.sql create mode 100644 sql/00008.sql create mode 100644 sql/00009.sql create mode 100644 sql/00010.sql create mode 100644 sql/00011.sql create mode 100644 sql/00012.sql create mode 100644 sql/00013.sql create mode 100644 sql/00014.sql create mode 100644 sql/00015.sql create mode 100644 sql/00016.sql create mode 100644 sql/00017.sql create mode 100644 sql/00018.sql create mode 100644 sql/00019.sql create mode 100644 sql/00020.sql create mode 100644 sql/00021.sql create mode 100644 sql/00022.sql create mode 100644 static/css/notes2.css create mode 100644 static/images/collapsed.svg create mode 100644 static/images/expanded.svg create mode 100644 static/images/leaf.svg create mode 100644 static/js/checklist.mjs create mode 100644 static/js/crypto.mjs create mode 100644 static/js/key.mjs create mode 100644 static/js/node.mjs create mode 100644 static/less/notes2.less diff --git a/authentication/pkg.go b/authentication/pkg.go index 2806114..bcd84ed 100644 --- a/authentication/pkg.go +++ b/authentication/pkg.go @@ -189,7 +189,7 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool } // }}} func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{ _, err = mngr.db.Exec(` - INSERT INTO public.user(username, password, name, totp) + INSERT INTO public.user(username, password, name) VALUES( $1, public.password_hash( @@ -199,8 +199,7 @@ func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists /* password */ $2::bytea ), - $3, - '' + $3 ) `, username, diff --git a/main.go b/main.go index 4fc380e..f789a30 100644 --- a/main.go +++ b/main.go @@ -163,6 +163,10 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{ Webengine.StaticResource(w, r) } // }}} +func httpError(w http.ResponseWriter, err error) {// {{{ + j, _ := json.Marshal(struct { OK bool; Error string }{false, err.Error()}) + w.Write(j) +}// }}} func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{ w.Header().Add("Content-Type", "text/javascript; charset=utf-8") @@ -209,13 +213,19 @@ func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{ } // }}} func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ - user, _ := r.Context().Value(CONTEXT_USER).(User) + user := getUser(r) + + nodes, err := NodeTree(user.ID, 0) + if err != nil { + httpError(w, err) + return + } j, _ := json.Marshal(struct { OK bool - Foo string - User User - }{true, "FOO", user}) + Nodes []Node + }{true, nodes}) + Log.Debug("tree", "nodes", nodes) w.Write(j) } // }}} @@ -261,3 +271,7 @@ func changePassword(username string) { // {{{ fmt.Printf("\nPassword changed\n") } // }}} +func getUser(r *http.Request) User { // {{{ + user, _ := r.Context().Value(CONTEXT_USER).(User) + return user +} // }}} diff --git a/sql/00001.sql b/sql/00001.sql index 0ed91f4..c08f6b1 100644 --- a/sql/00001.sql +++ b/sql/00001.sql @@ -1,43 +1,29 @@ -CREATE TABLE public.user ( - id serial NOT NULL, - "name" varchar NOT NULL, - "username" varchar NOT NULL, - "password" char(96) NOT NULL, - totp varchar NOT NULL, - last_login timestamp with time zone NOT NULL DEFAULT '1970-01-01 00:00:00', - CONSTRAINT user_pk PRIMARY KEY (id), - CONSTRAINT user_un UNIQUE (username) +CREATE TABLE public."user" ( + id SERIAL NOT NULL, + username VARCHAR NOT NULL, + name VARCHAR NOT NULL, + "password" VARCHAR NOT NULL, + last_login TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT newtable_pk PRIMARY KEY (id) ); -CREATE TABLE public.session ( - id serial NOT NULL, - user_id int4 NULL, - "uuid" char(36) NOT NULL, - created timestamp with time zone NOT NULL DEFAULT NOW(), - last_used timestamp with time zone NOT NULL DEFAULT NOW(), - CONSTRAINT session_pk PRIMARY KEY (id), - CONSTRAINT session_un UNIQUE ("uuid"), - CONSTRAINT session_user_fk FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE ON UPDATE CASCADE +CREATE TABLE public."session" ( + uuid UUID NOT NULL, + user_id INT4 NULL, + created TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT session_pk PRIMARY KEY (uuid), + CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA public; +CREATE TABLE public.node ( + id SERIAL NOT NULL, + user_id INT4 NOT NULL, + parent_id INT4 NULL, + "name" VARCHAR(256) NOT NULL DEFAULT '', + "content" TEXT NOT NULL DEFAULT '', + CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0), + CONSTRAINT node_pk PRIMARY KEY (id), + CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); -CREATE FUNCTION password_hash(salt_hex char(32), pass bytea) -RETURNS char(96) -LANGUAGE plpgsql -AS -$$ -BEGIN - RETURN ( - SELECT - salt_hex || - encode( - sha256( - decode(salt_hex, 'hex') || /* salt in binary */ - pass /* password */ - ), - 'hex' - ) - ); -END; -$$; diff --git a/sql/00002.sql b/sql/00002.sql index cf148a7..95907f6 100644 --- a/sql/00002.sql +++ b/sql/00002.sql @@ -1,33 +1,4 @@ -CREATE EXTENSION pg_trgm; - -CREATE TABLE public.crypto_key ( - id serial4 NOT NULL, - user_id int4 NOT NULL, - description varchar(255) DEFAULT ''::character varying NOT NULL, - "key" bpchar(144) NOT NULL, - CONSTRAINT crypto_key_pk PRIMARY KEY (id), - CONSTRAINT crypto_user_description_un UNIQUE (user_id, description), - CONSTRAINT crypto_key_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT -); - -CREATE TABLE public.node ( - id serial4 NOT NULL, - user_id int4 NOT NULL, - parent_id int4 NULL, - "name" varchar(256) DEFAULT ''::character varying NOT NULL, - "content" text DEFAULT ''::text NOT NULL, - updated timestamptz DEFAULT now() NOT NULL, - crypto_key_id int4 NULL, - content_encrypted text DEFAULT ''::text NOT NULL, - markdown bool DEFAULT false NOT NULL, - CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)), - CONSTRAINT node_pk PRIMARY KEY (id), - CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT, - CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT, - CONSTRAINT node_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT -); -CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops); - +ALTER TABLE node ADD COLUMN updated TIMESTAMP NOT NULL DEFAULT NOW(); CREATE OR REPLACE FUNCTION node_update_timestamp() RETURNS TRIGGER diff --git a/sql/00003.sql b/sql/00003.sql new file mode 100644 index 0000000..f1bec37 --- /dev/null +++ b/sql/00003.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.file ( + id serial NOT NULL, + user_id int4 NOT NULL, + filename varchar(256) NOT NULL DEFAULT '', + "size" int4 NOT NULL DEFAULT 0, + mime varchar(256) NOT NULL DEFAULT '', + uploaded timestamp NOT NULL DEFAULT NOW(), + CONSTRAINT file_pk PRIMARY KEY (id), + CONSTRAINT file_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..f5a5a5e --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1 @@ +ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT '' diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..fe21e55 --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.file ADD node_id int4 NOT NULL; +ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/sql/00006.sql b/sql/00006.sql new file mode 100644 index 0000000..8d886ad --- /dev/null +++ b/sql/00006.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.file DROP CONSTRAINT file_node_fk; +ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE public.file DROP CONSTRAINT file_fk; +ALTER TABLE public.file ADD CONSTRAINT file_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/sql/00007.sql b/sql/00007.sql new file mode 100644 index 0000000..73ac4c6 --- /dev/null +++ b/sql/00007.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.crypto_key ( + id serial NOT NULL, + user_id int4 NOT NULL, + description varchar(255) NOT NULL DEFAULT '', + "key" char(144) NOT NULL, + CONSTRAINT crypto_key_pk PRIMARY KEY (id), + CONSTRAINT crypto_key_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +COMMENT ON COLUMN public.crypto_key.key IS 'salt(16 bytes) + [key encrypted with pbkdf2(pass, salt)]'; diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..d85edc2 --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.node ADD crypto_key_id int4 NULL; +ALTER TABLE public.node ADD CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/sql/00009.sql b/sql/00009.sql new file mode 100644 index 0000000..7b172b5 --- /dev/null +++ b/sql/00009.sql @@ -0,0 +1 @@ +CREATE SEQUENCE aes_ccm_counter AS int8 INCREMENT BY 1 NO CYCLE; diff --git a/sql/00010.sql b/sql/00010.sql new file mode 100644 index 0000000..c461d9d --- /dev/null +++ b/sql/00010.sql @@ -0,0 +1 @@ +ALTER TABLE public.crypto_key ADD CONSTRAINT crypto_user_description_un UNIQUE (user_id, description); diff --git a/sql/00011.sql b/sql/00011.sql new file mode 100644 index 0000000..f6107f1 --- /dev/null +++ b/sql/00011.sql @@ -0,0 +1,5 @@ +ALTER TABLE node ADD COLUMN content_encrypted TEXT NOT NULL DEFAULT ''; +UPDATE node SET content_encrypted = content, content = '' WHERE crypto_key_id IS NOT NULL; + +CREATE EXTENSION pg_trgm; +CREATE INDEX node_content_index ON node USING gin (content gin_trgm_ops); diff --git a/sql/00012.sql b/sql/00012.sql new file mode 100644 index 0000000..80ed44f --- /dev/null +++ b/sql/00012.sql @@ -0,0 +1,2 @@ +DROP INDEX node_content_index; +CREATE INDEX node_search_index ON node USING gin (name gin_trgm_ops, content gin_trgm_ops); diff --git a/sql/00013.sql b/sql/00013.sql new file mode 100644 index 0000000..5ba26b9 --- /dev/null +++ b/sql/00013.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ADD COLUMN markdown bool NOT NULL DEFAULT false; diff --git a/sql/00014.sql b/sql/00014.sql new file mode 100644 index 0000000..3a1123c --- /dev/null +++ b/sql/00014.sql @@ -0,0 +1,18 @@ +CREATE TABLE checklist_group ( + id serial NOT NULL, + node_id int4 NOT NULL, + "order" int NOT NULL DEFAULT 0, + label varchar NOT NULL, + CONSTRAINT checklist_group_pk PRIMARY KEY (id), + CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE checklist_item ( + id serial NOT NULL, + checklist_group_id int4 NOT NULL, + "order" int NOT NULL DEFAULT 0, + label varchar NOT NULL, + checked bool NOT NULL DEFAULT false, + CONSTRAINT checklist_item_pk PRIMARY KEY (id), + CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE +) diff --git a/sql/00015.sql b/sql/00015.sql new file mode 100644 index 0000000..06d7ad0 --- /dev/null +++ b/sql/00015.sql @@ -0,0 +1,14 @@ +CREATE TABLE public.schedule ( + id SERIAL NOT NULL, + user_id INT4 NOT NULL, + node_id INT4 NOT NULL, + schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL, + "time" TIMESTAMP NOT NULL, + description VARCHAR DEFAULT '' NOT NULL, + acknowledged BOOL DEFAULT false NOT NULL, + + CONSTRAINT schedule_pk PRIMARY KEY (id), + CONSTRAINT schedule_uuid UNIQUE (schedule_uuid), + CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/sql/00016.sql b/sql/00016.sql new file mode 100644 index 0000000..8a98bf1 --- /dev/null +++ b/sql/00016.sql @@ -0,0 +1 @@ +ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description); diff --git a/sql/00017.sql b/sql/00017.sql new file mode 100644 index 0000000..16181d1 --- /dev/null +++ b/sql/00017.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.notification ( + id SERIAl NOT NULL, + user_id INT4 NOT NULL, + service VARCHAR DEFAULT 'NTFY' NOT NULL, + "configuration" JSONB DEFAULT '{}' NOT NULL, + prio INT DEFAULT 0 NOT NULL, + + CONSTRAINT notification_pk PRIMARY KEY (id), + CONSTRAINT notification_unique UNIQUE (user_id,prio), + CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/sql/00018.sql b/sql/00018.sql new file mode 100644 index 0000000..261a5b8 --- /dev/null +++ b/sql/00018.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz; + diff --git a/sql/00019.sql b/sql/00019.sql new file mode 100644 index 0000000..6b170a3 --- /dev/null +++ b/sql/00019.sql @@ -0,0 +1 @@ +ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0; diff --git a/sql/00020.sql b/sql/00020.sql new file mode 100644 index 0000000..21cd914 --- /dev/null +++ b/sql/00020.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."user" ADD timezone varchar DEFAULT 'UTC' NOT NULL; +ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamp USING "time"::timestamp; diff --git a/sql/00021.sql b/sql/00021.sql new file mode 100644 index 0000000..88d7364 --- /dev/null +++ b/sql/00021.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz; diff --git a/sql/00022.sql b/sql/00022.sql new file mode 100644 index 0000000..5f678e1 --- /dev/null +++ b/sql/00022.sql @@ -0,0 +1,21 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea) +RETURNS char(96) +LANGUAGE plpgsql +AS +$$ +BEGIN + RETURN ( + SELECT + salt_hex || + encode( + sha256( + decode(salt_hex, 'hex') || /* salt in binary */ + pass /* password */ + ), + 'hex' + ) + ); +END; +$$; diff --git a/static/css/notes2.css b/static/css/notes2.css new file mode 100644 index 0000000..45bf1d3 --- /dev/null +++ b/static/css/notes2.css @@ -0,0 +1,38 @@ +#tree { + grid-area: tree; + padding: 16px; + background-color: #333; + color: #ddd; + z-index: 100; +} +#tree .node { + display: grid; + grid-template-columns: 24px min-content; + grid-template-rows: min-content 1fr; + margin-top: 12px; +} +#tree .node .expand-toggle img { + width: 16px; + height: 16px; +} +#tree .node .name { + white-space: nowrap; + cursor: pointer; + user-select: none; +} +#tree .node .name:hover { + color: #fe5f55; +} +#tree .node .name.selected { + color: #fe5f55; + font-weight: bold; +} +#tree .node .children { + padding-left: 24px; + margin-left: 8px; + border-left: 1px solid #555; + grid-column: 1 / -1; +} +#tree .node .children.collapsed { + display: none; +} diff --git a/static/images/collapsed.svg b/static/images/collapsed.svg new file mode 100644 index 0000000..8bd376f --- /dev/null +++ b/static/images/collapsed.svg @@ -0,0 +1,74 @@ + + + +image/svg+xml diff --git a/static/images/expanded.svg b/static/images/expanded.svg new file mode 100644 index 0000000..e1a6f66 --- /dev/null +++ b/static/images/expanded.svg @@ -0,0 +1,65 @@ + + + +image/svg+xml diff --git a/static/images/leaf.svg b/static/images/leaf.svg new file mode 100644 index 0000000..ed44541 --- /dev/null +++ b/static/images/leaf.svg @@ -0,0 +1,57 @@ + + + +image/svg+xml diff --git a/static/js/app.mjs b/static/js/app.mjs index 1e4cb22..a0a90c4 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1,23 +1,172 @@ import { h, Component, createRef } from 'preact' +import { signal } from 'preact/signals' import htm from 'htm' import { API } from 'api' +import { Node } from 'node' const html = htm.bind(h) export class Notes2 { constructor() {//{{{ + this.startNode = null + this.setStartNode() }//}}} render() {//{{{ return html` + <${Tree} app=${this} /> ` }//}}} + setStartNode() {//{{{ + let urlParams = new URLSearchParams(window.location.search) + let nodeID = urlParams.get('node') + this.startNode = new Node(this, nodeID ? parseInt(nodeID) : 0) + }//}}} treeGet() { const req = {} - API.query('POST', '/tree/get', req) + API.query('POST', '/node/tree', req) .then(response => { console.log(response) }) .catch(e => console.log(e.type, e.error)) } } + +class Tree extends Component { + constructor(props) {//{{{ + super(props) + this.treeNodes = {} + this.treeNodeComponents = {} + this.treeTrunk = [] + this.selectedTreeNode = null + + this.retrieve() + }//}}} + render({ app }) {//{{{ + let renderedTreeTrunk = this.treeTrunk.map(node => { + this.treeNodeComponents[node.ID] = createRef() + return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${this} node=${node} ref=${this.treeNodeComponents[node.ID]} selected=${node.ID == app.startNode.ID} />` + }) + return html`
${renderedTreeTrunk}
` + }//}}} + + retrieve(callback = null) {//{{{ + const req = { StartNodeID: 0 } + API.query('POST', '/node/tree', req) + .then(res => { + this.treeNodes = {} + this.treeNodeComponents = {} + this.treeTrunk = [] + this.selectedTreeNode = null + + // A tree of nodes is built. This requires the list of nodes + // returned from the server to be sorted in such a way that + // a parent node always appears before a child node. + // The server uses a recursive SQL query delivering this. + res.Nodes.forEach(nodeData => { + let node = new Node( + this, + nodeData.ID, + ) + node.Children = [] + node.Crumbs = [] + node.Files = [] + node.Level = nodeData.Level + node.Name = nodeData.Name + node.ParentID = nodeData.ParentID + node.Updated = nodeData.Updated + node.UserID = nodeData.UserID + + this.treeNodes[node.ID] = node + + if (node.ParentID == 0) + this.treeTrunk.push(node) + else if (this.treeNodes[node.ParentID] !== undefined) + this.treeNodes[node.ParentID].Children.push(node) + }) + // When starting with an explicit node value, expanding all nodes + // on its path gives the user a sense of location. Not necessarily working + // as the start node isn't guaranteed to have returned data yet. + // XXX this.crumbsUpdateNodes() + this.forceUpdate() + + if (callback) + callback() + + }) + .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) + }//}}} + setSelected(node) {//{{{ + if (this.selectedTreeNode) + this.selectedTreeNode.selected.value = false + + this.selectedTreeNode = this.treeNodeComponents[node.ID].current + this.selectedTreeNode.selected.value = true + this.selectedTreeNode.expanded.value = true + this.expandToTrunk(node.ID) + }//}}} + crumbsUpdateNodes(node) {//{{{ + this.props.app.startNode.Crumbs.forEach(crumb => { + // Start node is loaded before the tree. + let node = this.treeNodes[crumb.ID] + if (node) + node._expanded = true + + // Tree is done before the start node. + let component = this.treeNodeComponents[crumb.ID] + if (component && component.current) + component.current.expanded.value = true + }) + + // Will be undefined when called from tree initialization + // (as tree nodes aren't rendered yet) + if (node !== undefined) + this.setSelected(node) + }//}}} + expandToTrunk(nodeID) {//{{{ + let node = this.treeNodes[nodeID] + if (node === undefined) + return + + node = this.treeNodes[node.ParentID] + while (node !== undefined) { + this.treeNodeComponents[node.ID].current.expanded.value = true + node = this.treeNodes[node.ParentID] + } + }//}}} +} + +class TreeNode extends Component { + constructor(props) {//{{{ + super(props) + this.selected = signal(props.selected) + this.expanded = signal(this.props.node._expanded) + }//}}} + render({ tree, node }) {//{{{ + + let children = node.Children.map(node => { + tree.treeNodeComponents[node.ID] = createRef() + return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${tree} node=${node} ref=${tree.treeNodeComponents[node.ID]} selected=${node.ID == tree.props.app.startNode.ID} />` + }) + + let expandImg = '' + if (node.Children.length == 0) + expandImg = html`` + else { + if (this.expanded.value) + expandImg = html`` + else + expandImg = html`` + } + + + let selected = (this.selected.value ? 'selected' : '') + + return html` +
+
this.expanded.value ^= true}>${expandImg}
+
window._app.current.nodeUI.current.goToNode(node.ID)}>${node.Name}
+
${children}
+
` + }//}}} +} diff --git a/static/js/checklist.mjs b/static/js/checklist.mjs new file mode 100644 index 0000000..b84cd8b --- /dev/null +++ b/static/js/checklist.mjs @@ -0,0 +1,472 @@ +import { h, Component, createRef } from 'preact' +import htm from 'htm' +import { signal } from 'preact/signals' +const html = htm.bind(h) + +export class ChecklistGroup { + static sort(a, b) {//{{{ + if (a.Order < b.Order) return -1 + if (a.Order > b.Order) return 1 + return 0 + }//}}} + constructor(data) {//{{{ + Object.keys(data).forEach(key => { + if (key == 'Items') + this.items = data[key].map(itemData => { + let item = new ChecklistItem(itemData) + item.checklistGroup = this + return item + }) + else + this[key] = data[key] + }) + }//}}} + addItem(label, okCallback) {//{{{ + window._app.current.request('/node/checklist_group/item_add', { + ChecklistGroupID: this.ID, + Label: label, + }) + .then(json => { + let item = new ChecklistItem(json.Item) + item.checklistGroup = this + this.items.push(item) + okCallback() + }) + .catch(window._app.current.responseError) + return + }//}}} + updateLabel(newLabel, okCallback, errCallback) {//{{{ + window._app.current.request('/node/checklist_group/label', { + ChecklistGroupID: this.ID, + Label: newLabel, + }) + .then(okCallback) + .catch(errCallback) + }//}}} + delete(okCallback, errCallback) {//{{{ + window._app.current.request('/node/checklist_group/delete', { + ChecklistGroupID: this.ID, + }) + .then(() => { + okCallback() + }) + .catch(errCallback) + }//}}} +} + +export class ChecklistItem { + static sort(a, b) {//{{{ + if (a.Order < b.Order) return -1 + if (a.Order > b.Order) return 1 + return 0 + }//}}} + constructor(data) {//{{{ + Object.keys(data).forEach(key => { + this[key] = data[key] + }) + }//}}} + updateState(newState, okCallback, errCallback) {//{{{ + window._app.current.request('/node/checklist_item/state', { + ChecklistItemID: this.ID, + State: newState, + }) + .then(okCallback) + .catch(errCallback) + }//}}} + updateLabel(newLabel, okCallback, errCallback) {//{{{ + window._app.current.request('/node/checklist_item/label', { + ChecklistItemID: this.ID, + Label: newLabel, + }) + .then(okCallback) + .catch(errCallback) + }//}}} + delete(okCallback, errCallback) {//{{{ + window._app.current.request('/node/checklist_item/delete', { + ChecklistItemID: this.ID, + }) + .then(() => { + this.checklistGroup.items = this.checklistGroup.items.filter(item => item.ID != this.ID) + okCallback() + }) + .catch(errCallback) + }//}}} + move(to, okCallback) {//{{{ + window._app.current.request('/node/checklist_item/move', { + ChecklistItemID: this.ID, + AfterItemID: to.ID, + }) + .then(okCallback) + .catch(_app.current.responseError) + }//}}} +} + +export class Checklist extends Component { + constructor() {//{{{ + super() + this.edit = signal(false) + this.dragItemSource = null + this.dragItemTarget = null + this.groupElements = {} + this.state = { + confirmDeletion: true, + continueAddingItems: true, + } + window._checklist = this + }//}}} + render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{ + this.groupElements = {} + if (groups.length == 0 && !ui.node.value.ShowChecklist.value) + return + + if (typeof groups.sort != 'function') + groups = [] + + groups.sort(ChecklistGroup.sort) + let groupElements = groups.map(group => { + this.groupElements[group.ID] = createRef() + return html`<${ChecklistGroupElement} ref=${this.groupElements[group.ID]} key="group-${group.ID}" ui=${this} group=${group} />` + }) + + let edit = 'edit-list-gray.svg' + let confirmDeletionEl = '' + if (this.edit.value) { + edit = 'edit-list.svg' + confirmDeletionEl = html` +
+ this.setState({ confirmDeletion: !confirmDeletion })} /> + +
+
+ this.setState({ continueAddingItems: !continueAddingItems })} /> + +
+ ` + } + + let addGroup = () => { + if (this.edit.value) + return html` this.addGroup()} />` + } + + return html` +
+
+

Checklist

+ this.toggleEdit()} /> + <${addGroup} /> +
+ ${confirmDeletionEl} + ${groupElements} +
+ ` + }//}}} + + toggleEdit() {//{{{ + this.edit.value = !this.edit.value + }//}}} + addGroup() {//{{{ + let label = prompt("Create a new group") + if (label === null) + return + label = label.trim() + if (label == '') + return + + window._app.current.request('/node/checklist_group/add', { + NodeID: window._app.current.nodeUI.current.node.value.ID, + Label: label, + }) + .then(json => { + let group = new ChecklistGroup(json.Group) + this.props.groups.push(group) + this.forceUpdate() + }) + .catch(window._app.current.responseError) + return + }//}}} + dragTarget(target) {//{{{ + if (this.dragItemTarget) + this.dragItemTarget.setDragTarget(false) + this.dragItemTarget = target + target.setDragTarget(true) + }//}}} + dragReset() {//{{{ + if (this.dragItemTarget) { + this.dragItemTarget.setDragTarget(false) + this.dragItemTarget = null + } + }//}}} +} + +class InputElement extends Component { + render({ placeholder, label }) {//{{{ + return html` + +
+
${label}
+ +
+
+ + +
+
+
+ ` + }//}}} + componentDidMount() {//{{{ + const dlg = document.getElementById('input-text') + const input = document.getElementById('input-text-el') + dlg.showModal() + dlg.addEventListener("keydown", evt => this.keyhandler(evt)) + input.addEventListener("keydown", evt => this.keyhandler(evt)) + input.focus() + }//}}} + ok() {//{{{ + const input = document.getElementById('input-text-el') + this.props.callback(true, input.value) + }//}}} + cancel() {//{{{ + this.props.callback(false) + }//}}} + keyhandler(evt) {//{{{ + let handled = true + switch (evt.key) { + case 'Enter': + this.ok() + break; + + case 'Escape': + this.cancel() + break; + default: + handled = false + } + if (handled) { + evt.stopPropagation() + evt.preventDefault() + } + }//}}} +} + +class ChecklistGroupElement extends Component { + constructor() {//{{{ + super() + this.label = createRef() + this.addingItem = signal(false) + }//}}} + render({ ui, group }) {//{{{ + let items = ({ ui, group }) => + group.items + .sort(ChecklistItem.sort) + .map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`) + + let label = () => html`
this.editLabel()}>${group.Label}
` + let addItem = () => { + if (this.addingItem.value) + return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />` + } + + return html` + <${addItem} /> +
+
+
+ this.delete()} /> + <${label} /> + this.addingItem.value = true} /> +
+ <${items} ui=${ui} group=${group} /> +
+ ` + }//}}} + addItem(ok, label) {//{{{ + if (!ok) { + this.addingItem.value = false + return + } + + label = label.trim() + if (label == '') { + this.addingItem.value = false + return + } + + this.props.group.addItem(label, () => { + this.forceUpdate() + }) + + if (!this.props.ui.state.continueAddingItems) + this.addingItem.value = false + }//}}} + editLabel() {//{{{ + let label = prompt('Edit label', this.props.group.Label) + if (label === null) + return + + label = label.trim() + if (label == '') { + alert(`A label can't be empty.`) + return + } + + this.label.current.classList.remove('error') + this.props.group.updateLabel(label, () => { + this.props.group.Label = label + this.label.current.innerHTML = label + this.label.current.classList.add('ok') + this.forceUpdate() + setTimeout(() => this.label.current.classList.remove('ok'), 500) + }, () => { + this.label.current.classList.add('error') + }) + + }//}}} + delete() {//{{{ + if (this.props.ui.state.confirmDeletion) { + if (!confirm(`Delete '${this.props.group.Label}'?`)) + return + } + + this.props.group.delete(() => { + this.props.ui.props.groups = this.props.ui.props.groups.filter(g => g.ID != this.props.group.ID) + this.props.ui.forceUpdate() + }, err => { + console.log(err) + console.log('error') + }) + }//}}} +} + +class ChecklistItemElement extends Component { + constructor(props) {//{{{ + super(props) + this.state = { + checked: props.item.Checked, + dragTarget: false, + } + this.checkbox = createRef() + this.label = createRef() + }//}}} + render({ ui, item }, { checked, dragTarget }) {//{{{ + let checkbox = () => { + if (ui.edit.value) + return html`` + else + return html` + this.update(evt.target.checked)} /> + + ` + } + return html` +
+
+ this.delete()} /> + <${checkbox} /> +
+ ` + }//}}} + componentDidMount() {//{{{ + this.base.addEventListener('dragstart', evt => this.dragStart(evt)) + this.base.addEventListener('dragend', () => this.dragEnd()) + this.base.addEventListener('dragenter', evt => this.dragEnter(evt)) + }//}}} + + update(checked) {//{{{ + this.setState({ checked }) + this.checkbox.current.classList.remove('error') + this.props.item.updateState(checked, () => { + this.checkbox.current.classList.add('ok') + setTimeout(() => this.checkbox.current.classList.remove('ok'), 500) + }, () => { + this.checkbox.current.classList.add('error') + }) + }//}}} + editLabel() {//{{{ + let label = prompt('Edit label', this.props.item.Label) + if (label === null) + return + + label = label.trim() + if (label == '') { + alert(`A label can't be empty.`) + return + } + + this.label.current.classList.remove('error') + this.props.item.updateLabel(label, () => { + this.props.item.Label = label + this.label.current.innerHTML = label + this.label.current.classList.add('ok') + setTimeout(() => this.label.current.classList.remove('ok'), 500) + }, () => { + this.label.current.classList.add('error') + }) + + }//}}} + delete() {//{{{ + if (this.props.ui.state.confirmDeletion) { + if (!confirm(`Delete '${this.props.item.Label}'?`)) + return + } + + this.props.item.delete(() => { + this.props.group.forceUpdate() + }, err => { + console.log(err) + console.log('error') + }) + }//}}} + + setDragTarget(state) {//{{{ + this.setState({ dragTarget: state }) + }//}}} + dragStart(evt) {//{{{ + // Shouldn't be needed, but in case the previous drag was bungled up, we reset. + this.props.ui.dragReset() + this.props.ui.dragItemSource = this + + const img = new Image(); + evt.dataTransfer.setDragImage(img, 10, 10); + }//}}} + dragEnter(evt) {//{{{ + evt.preventDefault() + this.props.ui.dragTarget(this) + }//}}} + dragEnd() {//{{{ + let groups = this.props.ui.props.groups + let from = this.props.ui.dragItemSource.props.item + let to = this.props.ui.dragItemTarget.props.item + + this.props.ui.dragReset() + + if (from.ID == to.ID) + return + + let fromGroup = groups.find(g => g.ID == from.GroupID) + let toGroup = groups.find(g => g.ID == to.GroupID) + + + from.Order = to.Order + from.GroupID = toGroup.ID + toGroup.items.forEach(i => { + if (i.ID == from.ID) + return + if (i.Order <= to.Order) + i.Order-- + }) + + if (fromGroup.ID != toGroup.ID) { + fromGroup.items = fromGroup.items.filter(i => i.ID != from.ID) + toGroup.items.push(from) + } + + this.props.ui.groupElements[fromGroup.ID].current.forceUpdate() + this.props.ui.groupElements[toGroup.ID].current.forceUpdate() + + from.move(to, () => {}) + }//}}} +} + +// vim: foldmethod=marker diff --git a/static/js/crypto.mjs b/static/js/crypto.mjs new file mode 100644 index 0000000..9aab255 --- /dev/null +++ b/static/js/crypto.mjs @@ -0,0 +1,72 @@ +export default class Crypto { + constructor(key) {//{{{ + if(key === null) + throw new Error("No key provided") + + if(typeof key === 'string') + this.key = sjcl.codec.base64.toBits(base64_key) + else + this.key = key + + this.aes = new sjcl.cipher.aes(this.key) + }//}}} + + static generate_key() {//{{{ + return sjcl.random.randomWords(8) + }//}}} + static pass_to_key(pass, salt = null) {//{{{ + if(salt === null) + salt = sjcl.random.randomWords(4) // 128 bits (16 bytes) + let key = sjcl.misc.pbkdf2(pass, salt, 10000) + + return { + salt, + key, + } + }//}}} + + encrypt(plaintext_data_in_bits, counter, return_encoded = true) {//{{{ + // 8 bytes of random data, (1 word = 4 bytes) * 2 + // with 8 bytes of byte encoded counter is used as + // IV to guarantee a non-repeated IV (which is a catastrophe). + // Assumes counter value is kept unique. Counter is taken from + // Postgres sequence. + let random_bits = sjcl.random.randomWords(2) + let iv_bytes = sjcl.codec.bytes.fromBits(random_bits) + for (let i = 0; i < 8; ++i) { + let mask = 0xffn << BigInt(i*8) + let counter_i_byte = (counter & mask) >> BigInt(i*8) + iv_bytes[15-i] = Number(counter_i_byte) + } + let iv = sjcl.codec.bytes.toBits(iv_bytes) + + let encrypted = sjcl.mode['ccm'].encrypt( + this.aes, + plaintext_data_in_bits, + iv, + ) + + // Returning 16 bytes (4 words) IV + encrypted data. + if(return_encoded) + return sjcl.codec.base64.fromBits( + iv.concat(encrypted) + ) + else + return iv.concat(encrypted) + }//}}} + decrypt(encrypted_base64_data) {//{{{ + try { + let encoded = sjcl.codec.base64.toBits(encrypted_base64_data) + let iv = encoded.slice(0, 4) // in words (4 bytes), not bytes + let encrypted_data = encoded.slice(4) + return sjcl.mode['ccm'].decrypt(this.aes, encrypted_data, iv) + } catch(err) { + if(err.message == `ccm: tag doesn't match`) + throw('Decryption failed') + else + throw(err) + } + }//}}} +} + +// vim: foldmethod=marker diff --git a/static/js/key.mjs b/static/js/key.mjs new file mode 100644 index 0000000..4cc8f28 --- /dev/null +++ b/static/js/key.mjs @@ -0,0 +1,241 @@ +import 'preact/devtools' +import { h, Component } from 'preact' +import htm from 'htm' +import Crypto from 'crypto' +const html = htm.bind(h) + +export class Keys extends Component { + constructor(props) {//{{{ + super(props) + this.state = { + create: false, + } + + props.nodeui.retrieveKeys() + }//}}} + render({ nodeui }, { create }) {//{{{ + let keys = nodeui.keys.value + .sort((a,b)=>{ + if(a.description < b.description) return -1 + if(a.description > b.description) return 1 + return 0 + }) + .map(key=> + html`<${KeyComponent} key=${`key-${key.ID}`} model=${key} />` + ) + + let createButton = '' + let createComponents = '' + if(create) { + createComponents = html` +
+

New key

+ +
+ + + + + + + +
+ + +
+
+
+ ` + } else { + createButton = html`
` + } + + return html` +
+

Encryption keys

+

+ Unlock a key by clicking its name. Lock it by clicking it again. +

+ +

+ Copy the key and store it in a very secure place to have a way to access notes + in case the password is forgotten, or database is corrupted. +

+ +

Click "View key" after unlocking it.

+ + ${createButton} + ${createComponents} + +

Keys

+
+ ${keys} +
+
` + }//}}} + + generateKey() {//{{{ + let keyTextarea = document.getElementById('key-key') + let key = sjcl.codec.hex.fromBits(Crypto.generate_key()).replace(/(....)/g, '$1 ').trim() + keyTextarea.value = key + }//}}} + validateNewKey() {//{{{ + let keyDescription = document.getElementById('key-description').value + let keyTextarea = document.getElementById('key-key').value + let pass1 = document.getElementById('key-pass1').value + let pass2 = document.getElementById('key-pass2').value + + if(keyDescription.trim() == '') + throw new Error('The key has to have a description') + + if(pass1.trim() == '' || pass1.length < 4) + throw new Error('The password has to be at least 4 characters long.') + + if(pass1 != pass2) + throw new Error(`Passwords doesn't match`) + + let cleanKey = keyTextarea.replace(/\s+/g, '') + if(!cleanKey.match(/^[0-9a-f]{64}$/i)) + throw new Error('Invalid key - has to be 64 characters of 0-9 and A-F') + }//}}} + createKey() {//{{{ + try { + this.validateNewKey() + + let description = document.getElementById('key-description').value + let keyAscii = document.getElementById('key-key').value + let pass1 = document.getElementById('key-pass1').value + + // Key in hex taken from user. + let actual_key = sjcl.codec.hex.toBits(keyAscii.replace(/\s+/g, '')) + + // Key generated from password, used to encrypt the actual key. + let pass_gen = Crypto.pass_to_key(pass1) + + let crypto = new Crypto(pass_gen.key) + let encrypted_actual_key = crypto.encrypt(actual_key, 0x1n, false) + + // Database value is salt + actual key, needed to generate the same key from the password. + let db_encoded = sjcl.codec.hex.fromBits( + pass_gen.salt.concat(encrypted_actual_key) + ) + + // Create on server. + window._app.current.request('/key/create', { + description, + key: db_encoded, + }) + .then(res=>{ + let key = new Key(res.Key, this.props.nodeui.keyCounter) + this.props.nodeui.keys.value = this.props.nodeui.keys.value.concat(key) + }) + .catch(window._app.current.responseError) + } catch(err) { + alert(err.message) + return + } + }//}}} +} + +export class Key { + constructor(data, counter_callback) {//{{{ + this.ID = data.ID + this.description = data.Description + this.encryptedKey = data.Key + this.key = null + + this._counter_cbk = counter_callback + + let hex_key = window.sessionStorage.getItem(`key-${this.ID}`) + if(hex_key) + this.key = sjcl.codec.hex.toBits(hex_key) + }//}}} + status() {//{{{ + if(this.key === null) + return 'locked' + return 'unlocked' + }//}}} + lock() {//{{{ + this.key = null + window.sessionStorage.removeItem(`key-${this.ID}`) + }//}}} + unlock(password) {//{{{ + let db = sjcl.codec.hex.toBits(this.encryptedKey) + let salt = db.slice(0, 4) + let pass_key = Crypto.pass_to_key(password, salt) + let crypto = new Crypto(pass_key.key) + this.key = crypto.decrypt(sjcl.codec.base64.fromBits(db.slice(4))) + window.sessionStorage.setItem(`key-${this.ID}`, sjcl.codec.hex.fromBits(this.key)) + }//}}} + async counter() {//{{{ + return this._counter_cbk() + }//}}} +} + +export class KeyComponent extends Component { + constructor({ model }) {//{{{ + super({ model }) + this.state = { + show_key: false, + } + }//}}} + render({ model }, { show_key }) {//{{{ + let status = '' + switch(model.status()) { + case 'locked': + status = html`
` + break + + case 'unlocked': + status = html`
` + break + } + + let hex_key = '' + if(show_key) { + if(model.status() == 'locked') + hex_key = html`
Unlock key first
` + else { + let key = sjcl.codec.hex.fromBits(model.key) + key = key.replace(/(....)/g, "$1 ").trim() + hex_key = html`
${key}
` + } + } + + let unlocked = model.status()=='unlocked' + + return html` +
this.toggle()}>${status}
+
this.toggle()}>${model.description}
+
this.toggleViewKey()}>${unlocked ? 'View key' : ''}
+ ${hex_key} + ` + }//}}} + toggle() {//{{{ + if(this.props.model.status() == 'locked') + this.unlock() + else + this.lock() + }//}}} + lock() {//{{{ + this.props.model.lock() + this.forceUpdate() + }//}}} + unlock() {//{{{ + let pass = prompt("Password") + if(!pass) + return + + try { + this.props.model.unlock(pass) + this.forceUpdate() + } catch(err) { + alert(err) + } + }//}}} + toggleViewKey() {//{{{ + this.setState({ show_key: !this.state.show_key }) + }//}}} +} + +// vim: foldmethod=marker diff --git a/static/js/node.mjs b/static/js/node.mjs new file mode 100644 index 0000000..710d07b --- /dev/null +++ b/static/js/node.mjs @@ -0,0 +1,1117 @@ +import { h, Component, createRef } from 'preact' +import htm from 'htm' +import { signal } from 'preact/signals' +import { Keys, Key } from 'key' +import Crypto from 'crypto' +import { Checklist, ChecklistGroup } from 'checklist' +const html = htm.bind(h) + +export class NodeUI extends Component { + constructor(props) {//{{{ + super(props) + this.menu = signal(false) + this.node = signal(null) + this.nodeContent = createRef() + this.nodeProperties = createRef() + this.keys = signal([]) + this.page = signal('node') + window.addEventListener('popstate', evt => { + if (evt.state && evt.state.hasOwnProperty('nodeID')) + this.goToNode(evt.state.nodeID, true) + else + this.goToNode(0, true) + }) + + window.addEventListener('keydown', evt => this.keyHandler(evt)) + }//}}} + render() {//{{{ + if (this.node.value === null) + return + + let node = this.node.value + document.title = `N: ${node.Name}` + + let crumbs = [ + html`
this.goToNode(0)}>Start
` + ] + + crumbs = crumbs.concat(node.Crumbs.slice(0).map(node => + html`
this.goToNode(node.ID)}>${node.Name}
` + ).reverse()) + + let children = node.Children.sort((a, b) => { + if (a.Name.toLowerCase() > b.Name.toLowerCase()) return 1 + if (a.Name.toLowerCase() < b.Name.toLowerCase()) return -1 + return 0 + }).map(child => html` +
this.goToNode(child.ID)}>${child.Name}
+ `) + + let modified = '' + if (this.props.app.nodeModified.value) + modified = 'modified' + + + // Page to display + let page = '' + switch (this.page.value) { + case 'node': + if (node.ID == 0) { + page = html` +
this.page.value = 'schedule-events'}>Schedule events
+ ${children.length > 0 ? html`
${children}
Notes version ${window._VERSION}
` : html``} + ` + } else { + let padlock = '' + if (node.CryptoKeyID > 0) + padlock = html`` + + page = html` + ${children.length > 0 ? html`
${children}
` : html``} +
+ ${node.Name} ${padlock} +
+ <${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} /> + <${NodeEvents} events=${node.ScheduleEvents.value} /> + <${Checklist} ui=${this} groups=${node.ChecklistGroups} /> + <${NodeFiles} node=${this.node.value} /> + ` + } + break + + case 'upload': + page = html`<${UploadUI} nodeui=${this} />` + break + + case 'node-properties': + page = html`<${NodeProperties} ref=${this.nodeProperties} nodeui=${this} />` + break + + case 'keys': + page = html`<${Keys} nodeui=${this} />` + break + + case 'profile-settings': + page = html`<${ProfileSettings} nodeui=${this} />` + break + + case 'search': + page = html`<${Search} nodeui=${this} />` + break + + case 'schedule-events': + page = html`<${ScheduleEventList} nodeui=${this} />` + break + } + + let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null) + let checklist = () => + html` +
{ evt.stopPropagation(); this.toggleChecklist() }}> + +
` + + return html` + <${menu} /> +
this.saveNode()}> +
document.getElementById('app').classList.toggle('toggle-tree')} />
+
Notes
+
{ evt.stopPropagation(); this.toggleMarkdown() }}>
+ <${checklist} /> + +
this.createNode(evt)}>
+
{ evt.stopPropagation(); this.showPage('keys') }}>
+ +
+ +
+
${crumbs} +
+ + ${page} + ` + }//}}} + async componentDidMount() {//{{{ + // When rendered and fetching the node, keys could be needed in order to + // decrypt the content. + await this.retrieveKeys() + + this.props.app.startNode.retrieve(node => { + this.node.value = node + + // The tree isn't guaranteed to have loaded yet. This is also run from + // the tree code, in case the node hasn't loaded. + this.props.app.tree.crumbsUpdateNodes(node) + }) + }//}}} + + keyHandler(evt) {//{{{ + let handled = true + + // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. + // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. + // Thus, the exception is acceptable to consequent use of alt+shift. + if (!(evt.shiftKey && evt.altKey) && !(evt.key.toUpperCase() == 'S' && evt.ctrlKey)) + return + + switch (evt.key.toUpperCase()) { + case 'C': + this.showPage('node') + break + + case 'E': + this.showPage('keys') + break + + case 'M': + this.toggleMarkdown() + break + + case 'N': + this.createNode() + break + + case 'P': + this.showPage('node-properties') + break + + case 'S': + if (this.page.value == 'node') + this.saveNode() + else if (this.page.value == 'node-properties') + this.nodeProperties.current.save() + break + + case 'U': + this.showPage('upload') + break + + case 'F': + this.showPage('search') + break + + default: + handled = false + } + + if (handled) { + evt.preventDefault() + evt.stopPropagation() + } + }//}}} + showMenu(evt) {//{{{ + evt.stopPropagation() + this.menu.value = true + }//}}} + logout() {//{{{ + window.localStorage.removeItem('session.UUID') + location.href = '/' + }//}}} + + goToNode(nodeID, dontPush) {//{{{ + if (this.props.app.nodeModified.value) { + if (!confirm("Changes not saved. Do you want to discard changes?")) + return + } + + if (!dontPush) + history.pushState({ nodeID }, '', `/?node=${nodeID}`) + + // New node is fetched in order to retrieve content and files. + // Such data is unnecessary to transfer for tree/navigational purposes. + let node = new Node(this.props.app, nodeID) + node.retrieve(node => { + this.props.app.nodeModified.value = false + this.node.value = node + this.showPage('node') + + // Tree needs to know another node is selected, in order to render any + // previously selected node not selected. + this.props.app.tree.setSelected(node) + + // Hide tree toggle, as this would be the next natural action to do manually anyway. + // At least in mobile mode. + document.getElementById('app').classList.remove('toggle-tree') + }) + }//}}} + createNode(evt) {//{{{ + if (evt) + evt.stopPropagation() + let name = prompt("Name") + if (!name) + return + this.node.value.create(name, nodeID => { + console.log('before', this.props.app.startNode) + this.props.app.startNode = new Node(this.props.app, nodeID) + console.log('after', this.props.app.startNode) + this.props.app.tree.retrieve(() => { + this.goToNode(nodeID) + }) + }) + }//}}} + saveNode() {//{{{ + let content = this.node.value.content() + this.node.value.setContent(content) + this.node.value.save(() => { + this.props.app.nodeModified.value = false + this.node.value.retrieve() + }) + }//}}} + renameNode() {//{{{ + let name = prompt("New name") + if (!name) + return + + this.node.value.rename(name, () => { + this.goToNode(this.node.value.ID) + this.menu.value = false + }) + }//}}} + deleteNode() {//{{{ + if (!confirm("Do you want to delete this note and all sub-notes?")) + return + this.node.value.delete(() => { + this.goToNode(this.node.value.ParentID) + this.menu.value = false + }) + }//}}} + + async retrieveKeys() {//{{{ + return new Promise((resolve, reject) => { + this.props.app.request('/key/retrieve', {}) + .then(res => { + this.keys.value = res.Keys.map(keyData => new Key(keyData, this.keyCounter)) + resolve(this.keys.value) + }) + .catch(reject) + }) + }//}}} + keyCounter() {//{{{ + return window._app.current.request('/key/counter', {}) + .then(res => BigInt(res.Counter)) + .catch(window._app.current.responseError) + }//}}} + getKey(id) {//{{{ + let keys = this.keys.value + for (let i = 0; i < keys.length; i++) + if (keys[i].ID == id) + return keys[i] + return null + }//}}} + + showPage(pg) {//{{{ + this.page.value = pg + }//}}} + showChecklist() {//{{{ + return (this.node.value.ChecklistGroups && this.node.value.ChecklistGroups.length > 0) | this.node.value.ShowChecklist.value + }//}}} + toggleChecklist() {//{{{ + this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value + }//}}} + toggleMarkdown() {//{{{ + this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value + }//}}} +} + +class NodeContent extends Component { + constructor(props) {//{{{ + super(props) + this.contentDiv = createRef() + this.state = { + modified: false, + } + }//}}} + render({ node }) {//{{{ + let content = '' + try { + content = node.content() + } catch (err) { + return html` +
${err.message}
+ ` + } + + var element + if (node.RenderMarkdown.value) + element = html`<${MarkdownContent} key='markdown-content' content=${content} />` + else + element = html` +
+ +
+ ` + + return element + }//}}} + componentDidMount() {//{{{ + this.resize() + window.addEventListener('resize', () => this.resize()) + }//}}} + componentDidUpdate() {//{{{ + this.resize() + }//}}} + contentChanged(evt) {//{{{ + window._app.current.nodeModified.value = true + const content = evt.target.value + this.props.node.setContent(content) + this.resize() + }//}}} + resize() {//{{{ + let textarea = document.getElementById('node-content') + if (textarea) + textarea.parentNode.dataset.replicatedValue = textarea.value + }//}}} + unlock() {//{{{ + let pass = prompt(`Password for "${this.props.model.description}"`) + if (!pass) + return + + try { + this.props.model.unlock(pass) + this.forceUpdate() + } catch (err) { + alert(err) + } + }//}}} +} + +class MarkdownContent extends Component { + render({ content }) {//{{{ + return html`
` + }//}}} + componentDidMount() {//{{{ + const markdown = document.getElementById('markdown') + if (markdown) + markdown.innerHTML = marked.parse(this.props.content) + }//}}} +} + +class NodeEvents extends Component { + render({ events }) {//{{{ + if (events.length == 0) + return html`` + + const eventElements = events.map(evt => { + const dt = evt.Time.split('T') + return html`
${dt[0]} ${dt[1].slice(0, 5)}
` + }) + return html` +
+
Schedule events
+ ${eventElements} +
+ ` + }//}}} +} + +class NodeFiles extends Component { + render({ node }) {//{{{ + if (node.Files === null || node.Files.length == 0) + return + + let files = node.Files + .sort((a, b) => { + if (a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1 + if (a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1 + return 0 + }) + .map(file => + html` +
node.download(file.ID)}>${file.Filename}
+
${this.formatSize(file.Size)}
+ ` + ) + + return html` +
+
Files
+
+ ${files} +
+
+ ` + }//}}} + formatSize(size) {//{{{ + if (size < 1048576) { + return `${Math.round(size / 1024)} KiB` + } else { + return `${Math.round(size / 1048576)} MiB` + } + }//}}} +} + +export class Node { + constructor(app, nodeID) {//{{{ + this.app = app + this.ID = nodeID + this.ParentID = 0 + this.UserID = 0 + this.CryptoKeyID = 0 + this.Name = '' + this.RenderMarkdown = signal(false) + this.Markdown = false + this.ShowChecklist = signal(false) + this._content = '' + this.Children = [] + this.Crumbs = [] + this.Files = [] + this._decrypted = false + this._expanded = false // start value for the TreeNode component, + this.ChecklistGroups = {} + this.ScheduleEvents = signal([]) + // it doesn't control it afterwards. + // Used to expand the crumbs upon site loading. + }//}}} + retrieve(callback) {//{{{ + this.app.request('/schedule/list', { NodeID: this.ID }) + .then(res => { + this.ScheduleEvents.value = res.ScheduleEvents + }) + + this.app.request('/node/retrieve', { ID: this.ID }) + .then(res => { + this.ParentID = res.Node.ParentID + this.UserID = res.Node.UserID + this.CryptoKeyID = res.Node.CryptoKeyID + this.Name = res.Node.Name + this._content = res.Node.Content + this.Children = res.Node.Children + this.Crumbs = res.Node.Crumbs + this.Files = res.Node.Files + this.Markdown = res.Node.Markdown + this.RenderMarkdown.value = this.Markdown + this.initChecklist(res.Node.ChecklistGroups) + callback(this) + }) + .catch(this.app.responseError) + }//}}} + delete(callback) {//{{{ + this.app.request('/node/delete', { + NodeID: this.ID, + }) + .then(callback) + .catch(this.app.responseError) + }//}}} + create(name, callback) {//{{{ + this.app.request('/node/create', { + Name: name.trim(), + ParentID: this.ID, + }) + .then(res => { + callback(res.Node.ID) + }) + .catch(this.app.responseError) + }//}}} + async save(callback) {//{{{ + try { + await this.#encrypt() + + let req = { + NodeID: this.ID, + Content: this._content, + CryptoKeyID: this.CryptoKeyID, + Markdown: this.Markdown, + TimeOffset: -(new Date().getTimezoneOffset()), + } + this.app.request('/node/update', req) + .then(callback) + .catch(this.app.responseError) + } catch (err) { + this.app.responseError(err) + } + }//}}} + rename(name, callback) {//{{{ + this.app.request('/node/rename', { + Name: name.trim(), + NodeID: this.ID, + }) + .then(callback) + .catch(this.app.responseError) + }//}}} + download(fileID) {//{{{ + let headers = { + 'Content-Type': 'application/json', + } + + if (this.app.session.UUID !== '') + headers['X-Session-Id'] = this.app.session.UUID + + let fname = "" + fetch("/node/download", { + method: 'POST', + headers, + body: JSON.stringify({ + NodeID: this.ID, + FileID: fileID, + }), + }) + .then(response => { + let match = response.headers.get('content-disposition').match(/filename="([^"]*)"/) + fname = match[1] + return response.blob() + }) + .then(blob => { + let url = window.URL.createObjectURL(blob) + let a = document.createElement('a') + a.href = url + a.download = fname + document.body.appendChild(a) // we need to append the element to the dom -> otherwise it will not work in firefox + a.click() + a.remove() //afterwards we remove the element again + }) + }//}}} + content() {//{{{ + if (this.CryptoKeyID != 0 && !this._decrypted) + this.#decrypt() + return this._content + }//}}} + setContent(new_content) {//{{{ + this._content = new_content + if (this.CryptoKeyID == 0) + // Logic behind plaintext not being decrypted is that + // only encrypted values can be in a decrypted state. + this._decrypted = false + else + this._decrypted = true + }//}}} + async setCryptoKey(new_key) {//{{{ + return this.#encrypt(true, new_key) + }//}}} + #decrypt() {//{{{ + if (this.CryptoKeyID == 0 || this._decrypted) + return + + let obj_key = this.app.nodeUI.current.getKey(this.CryptoKeyID) + if (obj_key === null || obj_key.ID != this.CryptoKeyID) + throw ('Invalid key') + + // Ask user to unlock key first + var pass = null + while (pass || obj_key.status() == 'locked') { + pass = prompt(`Password for "${obj_key.description}"`) + if (!pass) + throw new Error(`Key "${obj_key.description}" is locked`) + + try { + obj_key.unlock(pass) + } catch (err) { + alert(err) + } + pass = null + } + + if (obj_key.status() == 'locked') + throw new Error(`Key "${obj_key.description}" is locked`) + + let crypto = new Crypto(obj_key.key) + this._decrypted = true + this._content = sjcl.codec.utf8String.fromBits( + crypto.decrypt(this._content) + ) + }//}}} + async #encrypt(change_key = false, new_key = null) {//{{{ + // Nothing to do if not changing key and already encrypted. + if (!change_key && this.CryptoKeyID != 0 && !this._decrypted) + return this._content + + let content = this.content() + + // Changing key to no encryption or already at no encryption - + // set to not decrypted (only encrypted values can be + // decrypted) and return plain value. + if ((change_key && new_key === null) || (!change_key && this.CryptoKeyID == 0)) { + this._decrypted = false + this.CryptoKeyID = 0 + return content + } + + let key_id = change_key ? new_key.ID : this.CryptoKeyID + let obj_key = this.app.nodeUI.current.getKey(key_id) + if (obj_key === null || obj_key.ID != key_id) + throw ('Invalid key') + + if (obj_key.status() == 'locked') + throw new Error(`Key "${obj_key.description}" is locked`) + + let crypto = new Crypto(obj_key.key) + let content_bits = sjcl.codec.utf8String.toBits(content) + let counter = await this.app.nodeUI.current.keyCounter() + this.CryptoKeyID = obj_key.ID + this._content = crypto.encrypt(content_bits, counter, true) + this._decrypted = false + return this._content + }//}}} + initChecklist(checklistData) {//{{{ + if (checklistData === undefined || checklistData === null) + return + this.ChecklistGroups = checklistData.map(groupData => { + return new ChecklistGroup(groupData) + }) + }//}}} +} + +class Menu extends Component { + render({ nodeui }) {//{{{ + return html` +
nodeui.menu.value = false}>
+ + ` + }//}}} +} +class UploadUI extends Component { + constructor(props) {//{{{ + super(props) + this.file = createRef() + this.filelist = signal([]) + this.fileRefs = [] + this.progressRefs = [] + }//}}} + render({ nodeui }) {//{{{ + let filelist = this.filelist.value + let files = [] + for (let i = 0; i < filelist.length; i++) { + files.push(html`
${filelist.item(i).name}
`) + } + + return html` +
nodeui.showPage('node')}>
+
+ this.upload()} multiple /> +
+ ${files} +
+
+ ` + }//}}} + componentDidMount() {//{{{ + this.file.current.focus() + }//}}} + + upload() {//{{{ + let nodeID = this.props.nodeui.node.value.ID + this.fileRefs = [] + this.progressRefs = [] + + let input = this.file.current + this.filelist.value = input.files + for (let i = 0; i < input.files.length; i++) { + this.fileRefs.push(createRef()) + this.progressRefs.push(createRef()) + + this.postFile( + input.files[i], + nodeID, + progress => { + this.progressRefs[i].current.innerHTML = `${progress}%` + }, + res => { + this.props.nodeui.node.value.Files.push(res.File) + this.props.nodeui.forceUpdate() + + this.fileRefs[i].current.classList.add("done") + this.progressRefs[i].current.classList.add("done") + + this.props.nodeui.showPage('node') + }) + } + }//}}} + postFile(file, nodeID, progressCallback, doneCallback) {//{{{ + var formdata = new FormData() + formdata.append('file', file) + formdata.append('NodeID', nodeID) + + var request = new XMLHttpRequest() + + request.addEventListener("error", () => { + window._app.current.responseError({ upload: "An unknown error occured" }) + }) + + request.addEventListener("loadend", () => { + if (request.status != 200) { + window._app.current.responseError({ upload: request.statusText }) + return + } + + let response = JSON.parse(request.response) + if (!response.OK) { + window._app.current.responseError({ upload: response.Error }) + return + } + + doneCallback(response) + }) + + request.upload.addEventListener('progress', evt => { + var fileSize = file.size + + if (evt.loaded <= fileSize) + progressCallback(Math.round(evt.loaded / fileSize * 100)) + if (evt.loaded == evt.total) + progressCallback(100) + }) + + request.open('post', '/node/upload') + request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) + //request.timeout = 45000 + request.send(formdata) + }//}}} +} +class NodeProperties extends Component { + constructor(props) {//{{{ + super(props) + this.props.nodeui.retrieveKeys() + this.selected_key_id = 0 + }//}}} + render({ nodeui }) {//{{{ + let keys = nodeui.keys.value + .sort((a, b) => { + if (a.description < b.description) return -1 + if (a.description > b.description) return 1 + return 0 + }) + .map(key => { + this.props.nodeui.keys.value.some(uikey => { + if (uikey.ID == nodeui.node.value.ID) { + this.selected_key_id = nodeui.node.value.ID + return true + } + }) + + if (nodeui.node.value.CryptoKeyID == key.ID) + this.selected_key_id = key.ID + + return html` +
+ this.selected_key_id = key.ID} /> + +
` + }) + + return html` +
+

Note properties

+ +
These properties are only for this note.
+ +
+ nodeui.node.value.Markdown = evt.target.checked} /> + + +
+ +

Encryption

+
+ this.selected_key_id = 0} /> + +
+ ${keys} + + +
+ ` + }//}}} + async save() {//{{{ + let nodeui = this.props.nodeui + let node = nodeui.node.value + + // Find the actual key object used for encryption + let new_key = nodeui.getKey(this.selected_key_id) + let current_key = nodeui.getKey(node.CryptoKeyID) + + if (current_key && current_key.status() == 'locked') { + alert("Decryption key is locked and can not be used.") + return + } + + if (new_key && new_key.status() == 'locked') { + alert("Key is locked and can not be used.") + return + } + + await node.setCryptoKey(new_key) + + if (node.Markdown != node.RenderMarkdown.value) + node.RenderMarkdown.value = node.Markdown + + node.save(() => this.props.nodeui.showPage('node')) + }//}}} +} + +class Search extends Component { + constructor() {//{{{ + super() + this.state = { + matches: [], + results_returned: false, + } + }//}}} + render({ nodeui }, { matches, results_returned }) {//{{{ + let match_elements = [ + html`

Results

`, + ] + let matched_nodes = matches.map(node => html` +
nodeui.goToNode(node.ID)}> + ${node.Name} +
+ `) + match_elements.push(html`
${matched_nodes}
`) + + return html` + ` + }//}}} + componentDidMount() {//{{{ + document.getElementById('search-for').focus() + }//}}} + + keyHandler(evt) {//{{{ + let handled = true + + switch (evt.key.toUpperCase()) { + case 'ENTER': + this.search() + break + + default: + handled = false + } + + if (handled) { + evt.preventDefault() + evt.stopPropagation() + } + }//}}} + search() {//{{{ + let Search = document.getElementById('search-for').value + + window._app.current.request('/node/search', { Search }) + .then(res => { + this.setState({ + matches: res.Nodes, + results_returned: true, + + }) + }) + .catch(window._app.current.responseError) + }//}}} +} + +class ProfileSettings extends Component { + render({ nodeui }, { }) {//{{{ + return html` +
+

User settings

+ +

Password

+
+
Current
+ this.keyHandler(evt)} /> + +
New
+ this.keyHandler(evt)} /> + +
Repeat
+ this.keyHandler(evt)} /> +
+ + +
` + }//}}} + componentDidMount() {//{{{ + document.getElementById('current-password').focus() + }//}}} + + keyHandler(evt) {//{{{ + let handled = true + + switch (evt.key.toUpperCase()) { + case 'ENTER': + this.updatePassword() + break + + default: + handled = false + } + + if (handled) { + evt.preventDefault() + evt.stopPropagation() + } + }//}}} + updatePassword() {//{{{ + let curr_pass = document.getElementById('current-password').value + let pass1 = document.getElementById('new-password1').value + let pass2 = document.getElementById('new-password2').value + + try { + if (pass1.length < 4) { + throw new Error('Password has to be at least 4 characters long') + } + + if (pass1 != pass2) { + throw new Error(`Passwords don't match`) + } + + window._app.current.request('/user/password', { + CurrentPassword: curr_pass, + NewPassword: pass1, + }) + .then(res => { + if (res.CurrentPasswordOK) + alert('Password is changed successfully') + else + alert('Current password is invalid') + }) + } catch (err) { + alert(err.message) + } + + }//}}} +} + +class ScheduleEventList extends Component { + static CALENDAR = Symbol('CALENDAR') + static LIST = Symbol('LIST') + constructor() {//{{{ + super() + this.tab = signal(ScheduleEventList.CALENDAR) + }//}}} + render() {//{{{ + var tab + switch (this.tab.value) { + case ScheduleEventList.CALENDAR: + tab = html`<${ScheduleCalendarTab} />` + break; + case ScheduleEventList.LIST: + tab = html`<${ScheduleEventListTab} />` + break; + } + + return html` +
+
+
+
this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar
+
this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List
+
+
+
+ ${tab} +
+
+
+ ` + }//}}} +} + +class ScheduleEventListTab extends Component { + constructor() {//{{{ + super() + this.events = signal(null) + this.retrieveFutureEvents() + }//}}} + render() {//{{{ + if (this.events.value === null) + return + + let events = this.events.value.sort((a, b) => { + if (a.Time < b.Time) return -1 + if (a.Time > b.Time) return 1 + return 0 + }).map(evt => { + const dt = evt.Time.split('T') + const remind = () => { + if (evt.RemindMinutes > 0) + return html`${evt.RemindMinutes} min` + } + const nodeLink = () => html`${evt.Node.Name}` + + + return html` +
${dt[0]}
+
${dt[1].slice(0, 5)}
+
<${remind} />
+
${evt.Description}
+
<${nodeLink} />
+ ` + }) + + return html` +
+
Date
+
Time
+
Reminder
+
Event
+
Node
+ ${events} +
+ ` + }//}}} + retrieveFutureEvents() {//{{{ + _app.current.request('/schedule/list') + .then(data => { + this.events.value = data.ScheduleEvents + }) + }//}}} +} + +class ScheduleCalendarTab extends Component { + constructor() {//{{{ + super() + }//}}} + componentDidMount() { + let calendarEl = document.getElementById('fullcalendar'); + this.calendar = new FullCalendar.Calendar(calendarEl, { + initialView: 'dayGridMonth', + events: this.events, + eventTimeFormat: { + hour12: false, + hour: '2-digit', + minute: '2-digit', + }, + firstDay: 1, + aspectRatio: 2.5, + }); + this.calendar.render(); + } + render() { + return html`
` + } + events(info, successCallback, failureCallback) { + const req = { + StartDate: info.startStr, + EndDate: info.endStr, + } + _app.current.request('/schedule/list', req) + .then(data => { + const fullcalendarEvents = data.ScheduleEvents.map(sch => { + return { + title: sch.Description, + start: sch.Time, + url: `/?node=${sch.Node.ID}`, + } + }) + successCallback(fullcalendarEvents) + }) + .catch(err=>failureCallback(err)) + } +} + + +// vim: foldmethod=marker diff --git a/static/less/notes2.less b/static/less/notes2.less new file mode 100644 index 0000000..8b728ff --- /dev/null +++ b/static/less/notes2.less @@ -0,0 +1,52 @@ +@import "theme.less"; + +#tree { + grid-area: tree; + padding: 16px; + background-color: #333; + color: #ddd; + z-index: 100; // Over crumbs shadow + + .node { + display: grid; + grid-template-columns: 24px min-content; + grid-template-rows: + min-content + 1fr; + margin-top: 12px; + + + .expand-toggle { + img { + width: 16px; + height: 16px; + } + } + + .name { + white-space: nowrap; + cursor: pointer; + user-select: none; + + &:hover { + color: @color1; + } + &.selected { + color: @color1; + font-weight: bold; + } + + } + + .children { + padding-left: 24px; + margin-left: 8px; + border-left: 1px solid #555; + grid-column: 1 / -1; + + &.collapsed { + display: none; + } + } + } +} diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index d1ca4d7..98bd8d0 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -21,7 +21,11 @@ "preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs", "htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs", - "api": "/js/{{ .VERSION }}/api.mjs" + "api": "/js/{{ .VERSION }}/api.mjs", + "key": "/js/{{ .VERSION }}/key.mjs", + "checklist": "/js/{{ .VERSION }}/checklist.mjs", + "crypto": "/js/{{ .VERSION }}/crypto.mjs", + "node": "/js/{{ .VERSION }}/node.mjs" {{/* "session": "/js/{{ .VERSION }}/session.mjs", "node": "/js/{{ .VERSION }}/node.mjs", diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index bcbde8c..b3e37e0 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,5 @@ {{ define "page" }} +