diff --git a/main.go b/main.go index 96f08aa..a9ac728 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( const VERSION = "v1" const CONTEXT_USER = 1 -const SYNC_PAGINATION = 200 +const SYNC_PAGINATION = 100 var ( FlagGenerate bool @@ -269,11 +269,9 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - /* Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq) foo, _ := json.Marshal(nodes) os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644) - */ j, _ := json.Marshal(struct { OK bool @@ -290,6 +288,7 @@ func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) + Log.Debug("FOO", "UUID", user.ClientUUID, "changedFrom", changedFrom) count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID) if err != nil { Log.Error("/sync/from_server/count", "error", err) @@ -335,14 +334,9 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) - if err != nil { - Log.Error("sync", "error", err) - httpError(w, err) - return - } + db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) - responseData(w, map[string]any{ + responseData(w, map[string]interface{}{ "OK": true, }) } // }}} diff --git a/sql/00006.sql b/sql/00006.sql index 453b260..6b0ea9b 100644 --- a/sql/00006.sql +++ b/sql/00006.sql @@ -1 +1,16 @@ -DROP INDEX public.node_uuid_idx; +CREATE TABLE public.node_history ( + id serial4 NOT NULL, + user_id int4 NOT NULL, + uuid bpchar(36) NOT NULL, + parents varchar[] NULL, + created timestamptz NOT NULL, + updated timestamptz NOT NULL, + name varchar(256) NOT NULL, + "content" text NOT NULL, + content_encrypted text NOT NULL, + markdown bool DEFAULT false NOT NULL, + client bpchar(36) DEFAULT ''::bpchar NOT NULL, + CONSTRAINT node_history_pk PRIMARY KEY (id), + CONSTRAINT node_history_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); +CREATE INDEX node_history_uuid_idx ON public.node USING btree (uuid); diff --git a/static/css/login.css b/static/css/login.css index 7e19cb8..88a9140 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -1,44 +1,37 @@ -@import "theme.css"; - #app { - display: grid; - justify-items: center; - margin-top: 128px; + display: grid; + justify-items: center; + margin-top: 128px; } - #logo { - margin-bottom: 48px; + margin-bottom: 48px; } - #box { - display: grid; - grid-gap: 16px 0; - justify-items: center; - width: 300px; - padding: 48px 0px; - background-color: #fff; - box-shadow: 0px 20px 52px -33px rgba(0,0,0,0.75); - border-left: 8px solid var(--color3); - - input { - padding: 4px 8px; - font-size: 1em; - width: calc(100% - 64px); - border: 1px solid #aaa; - border-radius: 4px; - } - - button { - padding: 6px 16px; - font-size: 1em; - border-radius: 4px; - border: none; - background-color: var(--color1); - color: #fff; - } - - #error { - color: #c33; - margin-top: 16px; - } + display: grid; + grid-gap: 16px 0; + justify-items: center; + width: 300px; + padding: 48px 0px; + background-color: #fff; + box-shadow: 0px 20px 52px -33px rgba(0, 0, 0, 0.75); + border-left: 8px solid #666; +} +#box input { + padding: 4px 8px; + font-size: 1em; + width: calc(100% - 64px); + border: 1px solid #aaa; + border-radius: 4px; +} +#box button { + padding: 6px 16px; + font-size: 1em; + border-radius: 4px; + border: none; + background-color: #fe5f55; + color: #fff; +} +#box #error { + color: #c33; + margin-top: 16px; } diff --git a/static/css/main.css b/static/css/main.css index a8924d9..75f1925 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,29 +1,23 @@ -@import "theme.css"; - html { - box-sizing: border-box; - background: var(--color2); - font-family: "Liberation Mono", monospace; - font-size: 14px; - margin: 0px; - padding: 0px; + box-sizing: border-box; + background: #efede8; + font-family: "Liberation Mono", monospace; + font-size: 14px; + margin: 0px; + padding: 0px; } - body { - margin: 0px; - padding: 0px; + margin: 0px; + padding: 0px; } - *, *:before, *:after { - box-sizing: inherit; + box-sizing: inherit; } - *:focus { - outline: none; + outline: none; } - [onClick] { - cursor: pointer; + cursor: pointer; } diff --git a/static/css/notes2.css b/static/css/notes2.css index e6d0ef6..b6b0963 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -1,361 +1,264 @@ -@import "theme.css"; - -:root { - --content-width: 900px; -} - html { - background-color: #fff; + background-color: #fff; } - #notes2 { - min-height: 100vh; - - display: grid; - grid-template-areas: - "tree crumbs" - "tree name" - "tree sync" - "tree content" - /* - "tree checklist" - "tree files" - */ - "tree blank" - ; - grid-template-columns: min-content 1fr; - grid-template-rows: - min-content min-content 48px 1fr; - - - @media only screen and (max-width: 600px) { - grid-template-areas: - "crumbs" - "sync" - "name" - "content" - /* - "checklist" - "files" - */ - "blank" - ; - grid-template-columns: 1fr; - - #tree { - display: none; - } - - n2-syncprogress { - .el-count { - top: 4px; - } - } - } - + min-height: 100vh; + display: grid; + grid-template-areas: "tree crumbs" "tree sync" "tree name" "tree content" "tree blank"; + grid-template-columns: min-content 1fr; + grid-template-rows: 48px 56px 48px min-content 1fr; +} +@media only screen and (max-width: 600px) { + #notes2 { + grid-template-areas: "crumbs" "sync" "name" "content" "blank"; + grid-template-columns: 1fr; + } + #notes2 #tree { + display: none; + } } - #tree { - grid-area: tree; - display: grid; - padding: 16px 0px 16px 16px; - color: #ddd; - z-index: 100; - /* Over crumbs shadow */ - border-left: 2px solid #333; - - &:focus { - border-left: 2px solid #FE5F55; - } - - #logo { - display: grid; - position: relative; - justify-items: center; - margin-top: 8px; - margin-bottom: 8px; - margin-left: 24px; - margin-right: 24px; - cursor: pointer; - - img { - width: 128px; - left: -20px; - - } - } - - .icons { - display: flex; - justify-content: center; - margin-bottom: 32px; - gap: 8px; - } - - .node { - display: grid; - grid-template-columns: 24px min-content; - grid-template-rows: - min-content 1fr; - margin-top: 12px; - - - .expand-toggle { - user-select: none; - - img { - width: 16px; - height: 16px; - } - } - - .name { - white-space: nowrap; - cursor: pointer; - user-select: none; - - &:hover { - color: var(--color1); - } - - &.selected { - color: var(--color1); - font-weight: bold; - } - - } - - .children { - padding-left: 24px; - margin-left: 8px; - border-left: 1px solid #444; - grid-column: 1 / -1; - - &.collapsed { - display: none; - } - } - } + grid-area: tree; + padding: 16px 32px; + background-color: #333; + color: #ddd; + z-index: 100; + border-left: 2px solid #333; } - -#tree-nodes { - padding: 16px 32px; - background-color: #333; - border-radius: 8px; - box-shadow: 5px 5px 10px -5px rgba(0, 0, 0, 0.75); +#tree:focus { + border-left: 2px solid #FE5F55; +} +#tree #logo { + display: grid; + position: relative; + justify-items: center; + margin-bottom: 8px; + margin-left: 24px; + margin-right: 24px; +} +#tree #logo img { + width: 128px; + left: -20px; +} +#tree .icons { + display: flex; + justify-content: center; + margin-bottom: 32px; + gap: 8px; +} +#tree .node { + display: grid; + grid-template-columns: 24px min-content; + grid-template-rows: min-content 1fr; + margin-top: 12px; +} +#tree .node .expand-toggle { + user-select: none; +} +#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 #444; + grid-column: 1 / -1; +} +#tree .node .children.collapsed { + display: none; } - #crumbs { - grid-area: crumbs; - display: grid; - align-items: start; - justify-items: center; - height: min-content; - margin: 16px 16px; - - n2-crumbs { - background: #e4e4e4; - display: flex; - flex-wrap: wrap; - padding: 8px 16px; - background: #e4e4e4; - color: #333; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; - - &.node-modified { - background-color: var(--color1); - color: var(--color2); - - .crumb:after { - color: var(--color2); - } - } - - n2-crumb { - margin-right: 8px; - cursor: pointer; - user-select: none; - -webkit-tap-highlight-color: transparent; - - a { - text-decoration: none; - color: inherit; - } - } - - n2-crumb:after { - content: ">"; - font-weight: bold; - color: var(--color1) - } - - n2-crumb:last-child { - margin-right: 0; - } - - n2-crumb:last-child:after { - content: ''; - margin-left: 0px; - } - - } - + grid-area: crumbs; + display: grid; + align-items: start; + justify-items: center; + margin: 0px 16px; } - -n2-syncprogress { - --radius: 8px; - - display: grid; - grid-area: sync; - display: grid; - justify-items: center; - align-items: center; - - position: relative; - - opacity: 0; - transition: height 0s 500ms, opacity 500ms linear, visibility 0s 500ms; - - &.show { - opacity: 1; - transition: visibility, height 0s, opacity 500ms linear; - } - - progress { - width: calc(100% - 32px); - max-width: var(--content-width); - height: 24px; - border-radius: 8px; - } - - .count { - position: absolute; - top: 16px; - width: 100%; - white-space: nowrap; - color: #888; - text-align: center; - font-size: 12pt; - font-weight: bold; - } - - progress[value]::-webkit-progress-bar { - background-color: #eee; - box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; - border-radius: var(--radius); - } - - progress[value]::-moz-progress-bar { - background-color: #eee; - box-shadow: 0 2px var(--radius) rgba(0, 0, 0, 0.25) inset; - border-radius: var(--radius); - } - - progress[value]::-webkit-progress-value { - background: rgb(186, 95, 89); - background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%); - border-radius: var(--radius); - } - - progress[value]::-moz-progress-value { - background: rgb(186, 95, 89); - background: linear-gradient(180deg, rgba(186, 95, 89, 1) 0%, rgba(254, 95, 85, 1) 50%, rgba(186, 95, 89, 1) 100%); - border-radius: var(--radius); - } - +#crumbs .crumbs { + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #e4e4e4; + color: #333; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; +} +#crumbs .crumbs.node-modified { + background-color: #fe5f55; + color: #efede8; +} +#crumbs .crumbs.node-modified .crumb:after { + color: #efede8; +} +#crumbs .crumbs .crumb { + margin-right: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; +} +#crumbs .crumbs .crumb:after { + content: "•"; + margin-left: 8px; + color: #fe5f55; +} +#crumbs .crumbs .crumb:last-child { + margin-right: 0; +} +#crumbs .crumbs .crumb:last-child:after { + content: ''; + margin-left: 0px; +} +#sync-progress { + grid-area: sync; + display: grid; + justify-items: center; + width: 100%; + height: 56px; + position: relative; +} +#sync-progress progress { + width: 100%; + padding: 0 7px; + max-width: 900px; + height: 16px; + border-radius: 4px; +} +#sync-progress progress[value]::-webkit-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; +} +#sync-progress progress[value]::-moz-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; +} +#sync-progress progress[value]::-webkit-progress-value { + background: #ba5f59; + background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%); + border-radius: 4px; +} +#sync-progress progress[value]::-moz-progress-value { + background: #ba5f59; + background: linear-gradient(180deg, #ba5f59 0%, #fe5f55 50%, #ba5f59 100%); + border-radius: 4px; +} +#sync-progress .count { + width: min-content; + white-space: nowrap; + margin-top: 0px; + color: #888; + position: absolute; + top: 22px; +} +#sync-progress.hidden { + visibility: hidden; + opacity: 0; + transition: visibility 0s 500ms, opacity 500ms linear; +} +#name { + color: #333; + font-weight: bold; + text-align: center; + font-size: 1.15em; + margin-top: 0px; + margin-bottom: 16px; +} +/* ============================================================= * + * Textarea replicates the height of an element expanding height * + * ============================================================= */ +.grow-wrap { + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ + display: grid; + grid-area: content; + font-size: 1em; +} +.grow-wrap::after { + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) " "; + /* This is how textarea text behaves */ + width: calc(100% - 32px); + max-width: 900px; + white-space: pre-wrap; + word-wrap: break-word; + background: rgba(0, 255, 255, 0.5); + justify-self: center; + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; +} +.grow-wrap > textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; +} +.grow-wrap > textarea, +.grow-wrap::after { + /* Identical styling required!! */ + padding: 0.5rem; + font: inherit; + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; } - /* ============================================================= */ - -n2-nodeui { - margin-bottom: 32px; - - .el-name { - color: #333; - font-weight: bold; - text-align: center; - font-size: 1.15em; - margin-top: 8px; - margin-bottom: 0px; - } - - .el-node-content { - justify-self: center; - word-wrap: break-word; - font-family: monospace; - color: #333; - - /* - width: 100%; - max-width: var(--content-width); - field-sizing: content; - */ - - width: calc(100% - 32px); - max-width: var(--content-width); - field-sizing: content; - - resize: none; - border: none; - outline: none; - - padding: 16px 0; - border-top: 1px solid #e0e0e0; - border-bottom: 1px solid #e0e0e0; - margin-bottom: 32px; - - &:invalid { - background: #f5f5f5; - padding-top: 16px; - } - } +#node-content { + justify-self: center; + word-wrap: break-word; + font-family: monospace; + color: #333; + width: calc(100% - 32px); + max-width: 900px; + resize: none; + border: none; + outline: none; +} +#node-content:invalid { + background: #f5f5f5; + padding-top: 16px; } - #blank { - grid-area: blank; - height: 32px; + grid-area: blank; + height: 32px; } - -dialog.op { - &::backdrop { - background: rgba(0, 0, 0, 0.5); - } - - .header { - font-weight: bold; - margin-top: 16px; - - &:first-child { - margin-top: 0px; - } - } - +dialog.op::backdrop { + background: rgba(0, 0, 0, 0.5); } - -#op-search { - .results { - display: grid; - grid-template-columns: min-content min-content; - grid-gap: 6px 16px; - - div { - white-space: nowrap; - } - - - .ancestors { - display: flex; - - .ancestor::after { - content: ">"; - margin: 0px 8px; - color: #a00; - } - - .ancestor:last-child::after { - content: ""; - } - } - } +dialog.op .header { + font-weight: bold; + margin-top: 16px; +} +dialog.op .header:first-child { + margin-top: 0px; +} +#op-search .results { + display: grid; + grid-template-columns: min-content min-content; + grid-gap: 6px 16px; +} +#op-search .results div { + white-space: nowrap; +} +#op-search .results .ancestors { + display: flex; +} +#op-search .results .ancestors .ancestor::after { + content: ">"; + margin: 0px 8px; + color: #a00; +} +#op-search .results .ancestors .ancestor:last-child::after { + content: ""; } diff --git a/static/css/theme.css b/static/css/theme.css index b9c47ed..e69de29 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -1,5 +0,0 @@ -:root { - --color1: #fe5f55; - --color2: #efede8; - --color3: #666; -} diff --git a/static/js/app.mjs b/static/js/app.mjs deleted file mode 100644 index 45cd85b..0000000 --- a/static/js/app.mjs +++ /dev/null @@ -1,329 +0,0 @@ -import { ROOT_NODE } from 'node_store' -import { CustomHTMLElement } from './lib/custom_html_element.mjs' -import { N2Tree } from 'tree' -import { Node } from 'node' - -export class App { - constructor() {// {{{ - this.currentNode = null - this.tree = new N2Tree() - this.crumbs = new N2Crumbs() - this.crumbsElement = document.getElementById('crumbs') - this.nodeUI = document.getElementById('note') - - _mbus.subscribe('TREE_TRUNK_FETCHED', async () => { - document.getElementById('tree').append(this.tree.render()) - document.getElementById('tree-nodes')?.focus() - - const startNode = await this.getStartNode() - this.goToNode(startNode.UUID, false, false) - }) - - _mbus.subscribe('TREE_NODE_SELECTED', event => { - const node = event.detail.data - this.goToNode(node.UUID, false, false) - }) - - _mbus.subscribe('GO_TO_NODE', event => { - const node = event.detail.data - this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) - }) - - window.addEventListener('keydown', event => this.keyHandler(event)) - window.addEventListener('popstate', event => this.popState(event)) - document.getElementById('notes2').addEventListener('click', event => { - if (event.target.id === 'notes2') - document.getElementById('node-content')?.focus() - }) - - window._sync = new Sync() - - // I think it is uncomfortable having the sync running as soon as the page load. - // I haven't gotten the time to look at the page before stuff jumps around. - // There a slight delay to initiate sync seems reasonable. - setTimeout(() => window._sync.run(), 1000) - }// }}} - - keyHandler(event) {//{{{ - 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 (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey)) - return - - switch (event.key.toUpperCase()) { - case 'T': - if (document.activeElement.id === 'tree-nodes') - this.nodeUI.takeFocus() - else - this.nodeUI.takeFocus() - break - - case 'F': - _mbus.dispatch('op-search') - break - /* - 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': - 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) { - event.preventDefault() - event.stopPropagation() - } - }//}}} - popState(event) {// {{{ - _mbus.dispatch("GO_TO_NODE", { nodeUUID: event.state.nodeUUID, dontPush: true, dontExpand: true }) - }// }}} - async getStartNode() {//{{{ - let nodeUUID = ROOT_NODE - - // Is a UUID provided on the URI as an anchor? - const parts = document.URL.split('#') - if (parts[1]?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) - nodeUUID = parts[1] - - return await nodeStore.get(nodeUUID) - }//}}} - async saveNode() {//{{{ - if (!this.currentNode.isModified()) - return - - /* The node history is a local store for node history. - * This could be provisioned from the server or cleared if - * deemed unnecessary. - * - * The send queue is what will be sent back to the server - * to have a recorded history of the notes. - * - * A setting to be implemented in the future could be to - * not save the history locally at all. */ - const node = this.currentNode - - // The node is still in its old state and will present - // the unmodified content to the node store. - const history = nodeStore.nodesHistory.add(node) - - // Prepares the node object for saving. - // Sets Updated value to current date and time. - await node.save() - - // Updated node is added to the send queue to be stored on server. - const sendQueue = nodeStore.sendQueue.add(node) - - // Updated node is saved to the primary node store. - const nodeStoreAdding = nodeStore.add([node]) - - await Promise.all([history, sendQueue, nodeStoreAdding]) - }//}}} - async createNode() {//{{{ - let name = prompt("Name") - if (!name) - return - - const nn = Node.create(name, this.currentNode.UUID) - nn.save() - - nodeStore.sendQueue.add(nn) - nodeStore.add([nn]) - - }//}}} - async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ - if (nodeUUID === null || nodeUUID === undefined) - return - - // Don't switch notes until saved. - if (this.nodeUI.isModified()) { - if (!confirm("Changes not saved. Do you want to discard changes?")) - return - } - - if (!dontPush) - history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`) - - const node = nodeStore.node(nodeUUID) - node.reset() // any modifications are discarded. - - this.currentNode = node - this.tree.setSelected(node, dontExpand) - - const ancestors = await nodeStore.getNodeAncestry(node) - _mbus.dispatch('CRUMBS_SET', ancestors, () => this.crumbsElement.replaceChildren(this.crumbs.render())) - _mbus.dispatch('NODE_UI_OPEN', node) - _mbus.dispatch('NODE_UNMODIFIED') - - // Scrolls node into view. - this.tree.makeVisible(node) - }//}}} -} - -class N2Crumbs extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` - ` - }// }}} - constructor() {// {{{ - super() - this.classList.add('crumbs') - - this.crumbs = [] - - _mbus.subscribe('CRUMBS_SET', event => { - this.crumbs = event.detail.data - }) - }// }}} - render() {// {{{ - const crumbs = this.crumbs.map(node => - new N2Crumb( - node.get('Name'), - node.UUID, - ) - ) - - const start = new N2Crumb('Start', ROOT_NODE) - crumbs.push(start) - - this.replaceChildren(...crumbs.reverse()) - return this - }// }}} -} -customElements.define('n2-crumbs', N2Crumbs) - -class N2Crumb extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` - - ` - }// }}} - constructor(label, uuid) {// {{{ - super() - this.classList.add('crumb') - - this.label = label - this.uuid = uuid - - this.elLink.href = `/notes2#${this.uuid}` - this.elLink.innerText = this.label - this.elLink.addEventListener('click', () => _mbus.dispatch("GO_TO_NODE", { nodeUUID: this.uuid, dontPush: false, dontExpand: true })) - }// }}} -} -customElements.define('n2-crumb', N2Crumb) - -function tmpl(html) {// {{{ - const el = document.createElement('template') - el.innerHTML = html - return el.content.children -}// }}} - -class Op { - constructor(id) {// {{{ - this.id = id - _mbus.subscribe(this.id, p => this.render(p)) - }// }}} - render(html) {// {{{ - const op = document.getElementById('op') - const t = document.createElement('template') - t.innerHTML = `${html}` - op.replaceChildren(t.content) - document.getElementById(this.id).showModal() - }// }}} - get(selector) {// {{{ - return document.querySelector(`#${this.id} ${selector}`) - }// }}} - bind(selector, event, fn) {// {{{ - this.get(selector).addEventListener(event, evt => fn(evt)) - }// }}} -} - -class OpSearch extends Op { - constructor() {// {{{ - super('op-search') - }// }}} - render() {// {{{ - super.render(` -
Search
-
- -
-
Results
-
- `) - - this.bind('input[type="text"]', 'keydown', evt => this.search(evt)) - }// }}} - search(event) {// {{{ - if (event.key !== 'Enter') - return - - const searchFor = document.querySelector('#op-search input').value - nodeStore.search(searchFor, ROOT_NODE) - .then(res => this.displayResults(res)) - }// }}} - displayResults(results) {// {{{ - const rs = [] - for (const r of results) { - const ancestors = r.ancestry.reverse().map(a => { - const div = tmpl(`
${a.data.Name}
`) - div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID)) - return div[0] - }) - - - const div = tmpl(`
${r.name}
`) - div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid)) - rs.push(...div) - - const ancDev = tmpl('
') - ancDev[0].append(...ancestors) - rs.push(ancDev[0]) - } - this.get('.results').replaceChildren(...rs) - }// }}} -} - -// vim: foldmethod=marker diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs deleted file mode 100644 index dedb5d8..0000000 --- a/static/js/lib/custom_html_element.mjs +++ /dev/null @@ -1,57 +0,0 @@ -export class CustomHTMLElement extends HTMLElement { - constructor() {// {{{ - super() - - this.appendChild(this.constructor.tmpl.content.cloneNode(true)) - - this.querySelectorAll('*').forEach(el => { - const field = el.dataset.field - if (field !== undefined) { - const fieldName = this.toElementName('field', field) - this[fieldName] = el - } - - const name = el.dataset.el - if (name !== undefined) { - const elName = this.toElementName('el', name) - this[elName] = el - el.classList.add('el-' + name) - } - }) - }// }}} - toElementName(prefix, str) {// {{{ - str = prefix + '-' + str - return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) - }// }}} -} - -export class StupidPreactCustomHTMLElement extends HTMLElement { - constructor() {// {{{ - super() - - // Stupid stuff because of Preact. - this.clonedNodes = this.constructor.tmpl.content.cloneNode(true) - this.clonedNodes.querySelectorAll('*').forEach(el => { - const field = el.dataset.field - if (field !== undefined) { - const fieldName = this.toElementName('field', field) - this[fieldName] = el - } - - const name = el.dataset.el - if (name !== undefined) { - const elName = this.toElementName('el', name) - this[elName] = el - el.classList.add('el-' + name) - } - }) - }// }}} - toElementName(prefix, str) {// {{{ - str = prefix + '-' + str - return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', '')) - }// }}} - connectedCallback() {// {{{ - // Stupid stuff because of Preact. - this.appendChild(this.clonedNodes) - }// }}} -} diff --git a/static/js/mbus.mjs b/static/js/mbus.mjs index 0daa63c..da5f098 100644 --- a/static/js/mbus.mjs +++ b/static/js/mbus.mjs @@ -1,48 +1,17 @@ export class MessageBus { constructor() { - this.log = false this.bus = new EventTarget() } subscribe(eventName, fn) { - if (this.log) { - console.groupCollapsed('MBUS subscribe - ', eventName); - console.trace(); // hidden in collapsed group - console.groupEnd(); - } - - this.bus.addEventListener(eventName, event=>{ - fn(event) - if (event.detail.callback !== undefined) - event.detail.callback(event) - }) + this.bus.addEventListener(eventName, fn) } unsubscribe(eventName, fn) { - if (this.log) { - console.groupCollapsed('MBUS unsubscribe - ', eventName); - console.trace(); // hidden in collapsed group - console.groupEnd(); - } - this.bus.removeEventListener(eventName, fn) } - dispatch(eventName, data, callback) { - if (this.log) { - console.groupCollapsed('MBUS dispatch - ', eventName); - console.log('data', data); - console.trace(); // hidden in collapsed group - console.groupEnd(); - } - - const event = new CustomEvent(eventName, { - detail: { - data, - callback, - } - }) - - this.bus.dispatchEvent(event) + dispatch(eventName, data) { + this.bus.dispatchEvent(new CustomEvent(eventName, { detail: data })) } } diff --git a/static/js/node.mjs b/static/js/node.mjs index d611c64..92c8879 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -1,52 +1,335 @@ +import { h, Component, createRef } from 'preact' +import htm from 'htm' +import { signal } from 'preact/signals' import { ROOT_NODE } from 'node_store' -import { CustomHTMLElement } from './lib/custom_html_element.mjs' +import { SyncProgress } from 'sync' +const html = htm.bind(h) -export class N2NodeUI extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` -
- - ` - }// }}} - - constructor() {// {{{ - super() - this.node = null - - this.style.display = 'contents' - - _mbus.subscribe('NODE_UI_OPEN', event => { - this.node = event.detail.data - this.render() +export class NodeUI extends Component { + constructor(props) {//{{{ + super(props) + this.menu = signal(false) + this.node = signal(null) + this.nodeContent = createRef() + this.nodeProperties = createRef() + this.nodeModified = signal(false) + this.keys = signal([]) + this.page = signal('node') + this.crumbs = [] + this.syncProgress = createRef() + window.addEventListener('popstate', evt => { + if (evt.state?.hasOwnProperty('nodeUUID')) + _notes2.current.goToNode(evt.state.nodeUUID, true) + else + _notes2.current.goToNode('00000000-0000-0000-0000-000000000000', true) }) - _mbus.subscribe('NODE_MODIFIED', () => { - document.querySelector('#crumbs .crumbs')?.classList.add('node-modified') - }) - - _mbus.subscribe('NODE_UNMODIFIED', () => { - document.querySelector('#crumbs .crumbs')?.classList.remove('node-modified') - }) - - this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) - }// }}} - render() {// {{{ - this.elName.innerText = this.node?.get('Name') ?? '' - this.elNodeContent.value = this.node?.get('Content') ?? '' - }// }}} - takeFocus() {// {{{ - this.elNodeContent.focus() - }// }}} - - contentChanged(event) {//{{{ - this.node.setContent(event.target.value) + window.addEventListener('keydown', evt => this.keyHandler(evt)) + }//}}} + render() {//{{{ + if (this.node.value === null) + return + + const node = this.node.value + document.title = node.get('Name') + + const nodeModified = this.nodeModified.value ? 'node-modified' : '' + + + const crumbDivs = [ + html`
_notes2.current.goToNode(ROOT_NODE)}>Start
` + ] + for (let i = this.crumbs.length - 1; i >= 0; i--) { + const crumbNode = this.crumbs[i] + crumbDivs.push(html`
_notes2.current.goToNode(crumbNode.UUID)}>${crumbNode.get('Name')}
`) + } + if (node.UUID !== ROOT_NODE) + crumbDivs.push( + html`
_notes2.current.goToNode(node.UUID)}>${node.get('Name')}
` + ) + + return html` +
this.saveNode()}> +
+ ${crumbDivs} +
+
+ <${SyncProgress} ref=${this.syncProgress} /> +
${node.get('Name')}
+ <${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> +
+ ` + + + + + return + + let crumbs = [ + html`
this.goToNode(0)}>Start
` + ] + + crumbs = crumbs.concat(node.Crumbs.slice(0).map(node => + html`
this.goToNode(node.ID)}>${node.Name}
` + ).reverse()) + + + // 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 + } + + const menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null) + const checklist = () => + html` +
{ evt.stopPropagation(); this.toggleChecklist() }}> + +
` + + return html` + <${menu} /> + + +
+
+
${crumbs} +
+
+ + ${page} + ` + }//}}} + async componentDidMount() {//{{{ + _notes2.current.goToNode(this.props.startNode.UUID, true) + _notes2.current.tree.expandToTrunk(this.props.startNode) + }//}}} + setNode(node) {//{{{ + this.nodeModified.value = false + this.node.value = node + }//}}} + setCrumbs(nodes) {//{{{ + this.crumbs = nodes + }//}}} + async saveNode() {//{{{ + if (!this.nodeModified.value) + return + + /* The node history is a local store for node history. + * This could be provisioned from the server or cleared if + * deemed unnecessary. + * + * The send queue is what will be sent back to the server + * to have a recorded history of the notes. + * + * A setting to be implemented in the future could be to + * not save the history locally at all. */ + const node = this.node.value + + // The node is still in its old state and will present + // the unmodified content to the node store. + const history = nodeStore.nodesHistory.add(node) + + // Prepares the node object for saving. + // Sets Updated value to current date and time. + await node.save() + + // Updated node is added to the send queue to be stored on server. + const sendQueue = nodeStore.sendQueue.add(this.node.value) + + // Updated node is saved to the primary node store. + const nodeStoreAdding = nodeStore.add([node]) + + await Promise.all([history, sendQueue, nodeStoreAdding]) + this.nodeModified.value = false + }//}}} + + 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 'T': + if (document.activeElement.id === 'tree') + document.getElementById('node-content').focus() + else + document.getElementById('tree').focus() + break + + case 'F': + _mbus.dispatch('op-search') + break + /* + 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() + } + }//}}} +} + +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}
+ ` + } + + /* + let element + if (node.RenderMarkdown.value) + element = html`<${MarkdownContent} key='markdown-content' content=${content} />` + else + */ + const element = html` +
+ +
+ ` + + return element + }//}}} + componentDidMount() {//{{{ + this.resize() + window.addEventListener('resize', () => this.resize()) + + const contentResizeObserver = new ResizeObserver(entries => { + for (const idx in entries) { + const w = entries[idx].contentRect.width + document.querySelector('#crumbs .crumbs').style.width = `${w}px` + } + }); + + const nodeContent = document.getElementById('node-content') + contentResizeObserver.observe(nodeContent); + + }//}}} + componentDidUpdate() {//{{{ + this.resize() + }//}}} + contentChanged(evt) {//{{{ + _notes2.current.nodeUI.current.nodeModified.value = true + this.props.node.setContent(evt.target.value) + this.resize() + }//}}} + resize() {//{{{ + const textarea = document.getElementById('node-content') + if (textarea) + textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} - isModified() {// {{{ - return this.node?.isModified() - }// }}} } -customElements.define('n2-nodeui', N2NodeUI) export class Node { static sort(a, b) {//{{{ @@ -54,22 +337,10 @@ export class Node { if (a.data.Name > b.data.Name) return 0 return 0 }//}}} - static create(name, parentUUID) {// {{{ - return new Node({ - UUID: uuidv7(), - Created: (new Date()).toISOString(), - Content: '', - Name: name, - ParentUUID: parentUUID, - Markdown: false, - History: false, - }) - }// }}} - constructor(nodeData, level) {//{{{ - this.Level = level this.data = nodeData + this.UUID = nodeData.UUID // Toplevel nodes are normalized to have the ROOT_NODE as parent. @@ -83,12 +354,13 @@ export class Node { this.Children = [] this.Ancestors = [] + this._content = this.data.Content + this._modified = false + this._sibling_before = null this._sibling_after = null this._parent = null - this.reset() - /* this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false @@ -105,10 +377,6 @@ export class Node { */ }//}}} - reset() {// {{{ - this._content = this.data.Content - this._modified = false - }// }}} get(prop) {//{{{ return this.data[prop] }//}}} @@ -116,13 +384,13 @@ export class Node { // '2024-12-17T17:33:48.85939Z return new Date(Date.parse(this.data.Updated)) }//}}} - isModified() {// {{{ - return this._modified - }// }}} hasFetchedChildren() {//{{{ return this._children_fetched }//}}} async fetchChildren() {//{{{ + if (this._children_fetched) + return this.Children + this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1) this._children_fetched = true @@ -140,8 +408,7 @@ export class Node { } // Notify the tree that all children are fetched and ready to process. - //_notes2.current.tree.fetchChildrenOn(this.UUID) - _mbus.dispatch(`NODE_CHILDREN_FETCHED_${this.UUID}`) + _notes2.current.tree.fetchChildrenOn(this.UUID) return this.Children }//}}} @@ -168,12 +435,12 @@ export class Node { if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() */ + this.modified = true return this._content }//}}} + setContent(new_content) {//{{{ this._content = new_content - this._modified = true - _mbus.dispatch('NODE_MODIFIED') /* TODO - implement crypto if (this.CryptoKeyID == 0) // Logic behind plaintext not being decrypted is that @@ -188,8 +455,6 @@ export class Node { this.data.Updated = new Date().toISOString() this._modified = false - _mbus.dispatch('NODE_UNMODIFIED') - // When stored into database and ancestry was changed, // the ancestry path could be interesting. const ancestors = await nodeStore.getNodeAncestry(this) @@ -197,30 +462,4 @@ export class Node { }//}}} } -function uuidv7() { - // random bytes - const value = new Uint8Array(16) - crypto.getRandomValues(value) - - // current timestamp in ms - const timestamp = BigInt(Date.now()) - - // timestamp - value[0] = Number((timestamp >> 40n) & 0xffn) - value[1] = Number((timestamp >> 32n) & 0xffn) - value[2] = Number((timestamp >> 24n) & 0xffn) - value[3] = Number((timestamp >> 16n) & 0xffn) - value[4] = Number((timestamp >> 8n) & 0xffn) - value[5] = Number(timestamp & 0xffn) - - // version and variant - value[6] = (value[6] & 0x0f) | 0x70 - value[8] = (value[8] & 0x3f) | 0x80 - - const str = Array.from(value) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}` -} - // vim: foldmethod=marker diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index e849e29..80aa758 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -13,7 +13,7 @@ export class NodeStore { this.sendQueue = null this.nodesHistory = null }//}}} - initializeDB() {//{{{ + async initializeDB() {//{{{ return new Promise((resolve, reject) => { const req = indexedDB.open('notes', 7) @@ -78,7 +78,7 @@ export class NodeStore { } }) }//}}} - initializeRootNode() {//{{{ + async initializeRootNode() {//{{{ return new Promise((resolve, reject) => { // The root node is a magical node which displays as the first node if none is specified. // If not already existing, it will be created. @@ -120,7 +120,7 @@ export class NodeStore { return n }//}}} - getAppState(key) {//{{{ + async getAppState(key) {//{{{ return new Promise((resolve, reject) => { const trx = this.db.transaction('app_state', 'readonly') const appState = trx.objectStore('app_state') @@ -135,7 +135,7 @@ export class NodeStore { getRequest.onerror = (event) => reject(event.target.error) }) }//}}} - setAppState(key, value) {//{{{ + async setAppState(key, value) {//{{{ return new Promise((resolve, reject) => { try { const t = this.db.transaction('app_state', 'readwrite') @@ -159,8 +159,30 @@ export class NodeStore { }) }//}}} - /* - upsertNodeRecords(records) {//{{{ + async storeNode(node) {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + resolve() + } + + const nodeReq = nodeStore.put(node.data) + nodeReq.onsuccess = () => { + console.debug(`Storing ${node.UUID} (${node.get('Name')})`) + } + queueReq.onerror = (event) => { + console.log(`Error storing ${node.UUID}`, event.target.error) + reject(event.target.error) + } + }) + }//}}} + + async upsertNodeRecords(records) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('nodes', 'readwrite') const nodeStore = t.objectStore('nodes') @@ -188,11 +210,17 @@ export class NodeStore { record.modified = 0 addReq = nodeStore.put(record) } + addReq.onsuccess = () => { + console.debug(`${op} ${record.UUID} (${record.Name})`) + } + addReq.onerror = (event) => { + console.log(`error ${op} ${record.UUID}`, event.target.error) + reject(event.target.error) + } } }) }//}}} - */ - getTreeNodes(parent, newLevel) {//{{{ + async getTreeNodes(parent, newLevel) {//{{{ return new Promise((resolve, reject) => { // Parent of toplevel nodes is ROOT_NODE in indexedDB. // Only the root node has '' as parent. @@ -214,7 +242,7 @@ export class NodeStore { req.onerror = (event) => reject(event.target.error) }) }//}}} - search(searchfor, parent) {//{{{ + async search(searchfor, parent) {//{{{ return new Promise((resolve, reject) => { const trx = this.db.transaction('nodes', 'readonly') const nodeStore = trx.objectStore('nodes') @@ -244,55 +272,46 @@ export class NodeStore { }) }//}}} - add(records, objstore) {//{{{ + async add(records) {//{{{ return new Promise((resolve, reject) => { try { - // A nodestore can be provided in order to - // avoid creating new transactions. - let nodeStore = objstore - let t - - if (nodeStore === undefined) { - t = this.db.transaction('nodes', 'readwrite') - nodeStore = t.objectStore('nodes') - - t.oncomplete = (_event) => { - resolve() - } - - t.onerror = (event) => { - console.error('transaction error', event.target.error) - reject(event.target.error) - } + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) } // records is an object, not an array. + const promises = [] for (const recordIdx in records) { const record = records[recordIdx] - nodeStore.put(record.data) + const addReq = nodeStore.put(record.data) + + const promise = new Promise((resolve, reject) => { + addReq.onsuccess = () => { + console.debug('OK!', record.ID, record.Name) + resolve() + } + addReq.onerror = (event) => { + console.log('Error!', event.target.error, record.ID) + reject(event.target.error) + } + }) + promises.push(promise) } - resolve() + Promise.all(promises).then(() => resolve()) } catch (e) { - console.error(e) - reject(e) + console.log(e) } }) }//}}} - get(uuid, suppliedNodestore) {//{{{ + async get(uuid) {//{{{ return new Promise((resolve, reject) => { - // A nodestore can be provided in order to - // avoid creating new transactions. - let trx - let nodeStore = suppliedNodestore - - if (nodeStore === undefined) { - trx = this.db.transaction('nodes', 'readonly') - nodeStore = trx.objectStore('nodes') - } - + const trx = this.db.transaction('nodes', 'readonly') + const nodeStore = trx.objectStore('nodes') const getRequest = nodeStore.get(uuid) - getRequest.onsuccess = (event) => { // Node not found in IndexedDB. if (event.target.result === undefined) { @@ -304,7 +323,7 @@ export class NodeStore { } }) }//}}} - getNodeAncestry(node, accumulated) {//{{{ + async getNodeAncestry(node, accumulated) {//{{{ return new Promise((resolve, reject) => { if (accumulated === undefined) accumulated = [] @@ -335,11 +354,8 @@ export class NodeStore { }) }//}}} - newTransaction(objectStore, mode) {// {{{ - return this.db.transaction(objectStore, mode) - }// }}} - nodeCount() {//{{{ + async nodeCount() {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('nodes', 'readwrite') const nodeStore = t.objectStore('nodes') @@ -356,7 +372,7 @@ class SimpleNodeStore { this.db = db this.storeName = storeName }//}}} - add(node) {//{{{ + async add(node) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction(['nodes', this.storeName], 'readwrite') const store = t.objectStore(this.storeName) @@ -376,7 +392,7 @@ class SimpleNodeStore { } }) }//}}} - retrieve(limit) {//{{{ + async retrieve(limit) {//{{{ return new Promise((resolve, reject) => { const cursorReq = this.db .transaction(['nodes', this.storeName], 'readonly') @@ -404,7 +420,7 @@ class SimpleNodeStore { } }) }//}}} - delete(keys) {//{{{ + async delete(keys) {//{{{ const store = this.db .transaction(['nodes', this.storeName], 'readwrite') .objectStore(this.storeName) @@ -421,7 +437,7 @@ class SimpleNodeStore { } return Promise.all(promises) }//}}} - count() {//{{{ + async count() {//{{{ const store = this.db .transaction(['nodes', this.storeName], 'readonly') .objectStore(this.storeName) diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 7bef2ad..d73ef53 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -3,7 +3,6 @@ import { signal } from 'preact/signals' import htm from 'htm' import { Node, NodeUI } from 'node' import { ROOT_NODE } from 'node_store' -import { TreeNative } from 'tree' const html = htm.bind(h) export class Notes2 extends Component { @@ -15,7 +14,6 @@ export class Notes2 extends Component { startNode: null, } this.op = signal('') - this.treeNative = new TreeNative() window._sync = new Sync() window._sync.run() @@ -78,7 +76,6 @@ export class Notes2 extends Component { this.nodeUI.current.setNode(node) this.nodeUI.current.setCrumbs(ancestors) this.tree.setSelected(node, dontExpand) - this.treeNative.setSelected(node, dontExpand) }//}}} logout() {//{{{ localStorage.removeItem('session.UUID') @@ -110,7 +107,6 @@ class Tree extends Component { this.treeNodeComponents[node.UUID] = createRef() return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` }) - return html`
@@ -122,7 +118,7 @@ class Tree extends Component {
` }//}}} componentDidMount() {//{{{ - //this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) + this.treeDiv.current.addEventListener('keydown', event => this.keyHandler(event)) // This will show and select the treenode that is selected in the node UI. const node = _notes2.current?.nodeUI.current?.node.value @@ -542,13 +538,13 @@ class OpSearch extends Op { for (const r of results) { const ancestors = r.ancestry.reverse().map(a => { const div = tmpl(`
${a.data.Name}
`) - div[0].addEventListener('click', () => _notes2.current.goToNode(a.UUID)) + div[0].addEventListener('click', ()=>_notes2.current.goToNode(a.UUID)) return div[0] }) const div = tmpl(`
${r.name}
`) - div[0].addEventListener('click', () => _notes2.current.goToNode(r.uuid)) + div[0].addEventListener('click', ()=>_notes2.current.goToNode(r.uuid)) rs.push(...div) const ancDev = tmpl('
') diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 9b58cf7..d66e27b 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,12 +1,31 @@ import { API } from 'api' import { Node } from 'node' -import { CustomHTMLElement } from './lib/custom_html_element.mjs' +import { h, Component } from 'preact' +import htm from 'htm' +const html = htm.bind(h) + +const SYNC_COUNT = 1 +const SYNC_HANDLED = 2 +const SYNC_DONE = 3 export class Sync { constructor() {//{{{ this.listeners = [] this.messagesReceived = [] }//}}} + addListener(fn, runMessageQueue) {//{{{ + // Some handlers won't be added until a time after sync messages have been added to the queue. + // This is an opportunity for the handler to receive the old messages in order. + if (runMessageQueue) + for (const msg of this.messagesReceived) + fn(msg) + this.listeners.push(fn) + }//}}} + pushMessage(msg) {//{{{ + this.messagesReceived.push(msg) + for (const fn of this.listeners) + fn(msg) + }//}}} async run() {//{{{ try { @@ -19,8 +38,8 @@ export class Sync { let nodeCount = await this.getNodeCount(oldMax) nodeCount += await nodeStore.sendQueue.count() - - _mbus.dispatch('SYNC_COUNT', { count: nodeCount }) + const msg = { op: SYNC_COUNT, count: nodeCount } + this.pushMessage(msg) await this.nodesFromServer(oldMax) .then(durationNodes => { @@ -30,7 +49,7 @@ export class Sync { await this.nodesToServer() } finally { - _mbus.dispatch('SYNC_DONE') + this.pushMessage({ op: SYNC_DONE }) } }//}}} async getNodeCount(oldMax) {//{{{ @@ -41,7 +60,6 @@ export class Sync { async nodesFromServer(oldMax) {//{{{ const syncStart = Date.now() let syncEnd - let handled = 0 try { let currMax = oldMax let offset = 0 @@ -65,24 +83,12 @@ export class Sync { * sync be preserved in the backend. */ let backendNode = null - - // Create a single transaction to be used in the chain of - // this sync. Otherwise it would take more time to create - // transactions for each node. - const trx = nodeStore.newTransaction('nodes', 'readwrite') - const objstore = trx.objectStore('nodes') - for (const i in res.Nodes) { backendNode = new Node(res.Nodes[i], -1) - await this.handleNode(backendNode, objstore) - - handled++ - if (handled % 100 === 0) - _mbus.dispatch('SYNC_HANDLED', { handled }) + await window._sync.handleNode(backendNode) } } while (res.Continue) - _mbus.dispatch('SYNC_HANDLED', { handled }) nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { @@ -95,16 +101,16 @@ export class Sync { } return (syncEnd - syncStart) }//}}} - async handleNode(backendNode, objstore) {//{{{ + async handleNode(backendNode) {//{{{ try { /* Retrieving the local copy of this node from IndexedDB. * The backend node can be discarded if it is older than * the local copy since it is considered history preserved * in the backend. */ - return nodeStore.get(backendNode.UUID, objstore) - .then(localNode => { + return nodeStore.get(backendNode.UUID) + .then(async localNode => { if (localNode.updated() >= backendNode.updated()) { - console.debug(`History from backend: ${backendNode.UUID}`) + console.log(`History from backend: ${backendNode.UUID}`) return } @@ -114,17 +120,17 @@ export class Sync { * * If the local node has seen change, the change is already * placed into the send_queue anyway. */ - return nodeStore.add([backendNode], objstore) + return nodeStore.add([backendNode]) }) - .catch(() => { + .catch(async () => { // Not found in IndexedDB - OK to just insert since it only exists in backend. - return nodeStore.add([backendNode], objstore) + return nodeStore.add([backendNode]) }) } catch (e) { console.error(e) } finally { - //_mbus.dispatch('SYNC_HANDLED', { count: 1 }) + this.pushMessage({ op: SYNC_HANDLED, count: 1 }) } }//}}} async nodesToServer() {//{{{ @@ -143,7 +149,7 @@ export class Sync { const res = await API.query('POST', '/sync/to_server', request) if (!res.OK) { // TODO - implement better error management here. - console.error(res) + console.log(res) alert(res) return } @@ -151,7 +157,7 @@ export class Sync { // Nodes are archived on server and can now be deleted from the send queue. const keys = nodesToSend.map(node => node.ClientSequence) await nodeStore.sendQueue.delete(keys) - _mbus.dispatch('SYNC_UPLOADED', { count: nodesToSend.length }) + this.pushMessage({ op: SYNC_HANDLED, count: nodesToSend.length }) } catch (e) { console.trace(e) @@ -162,66 +168,77 @@ export class Sync { }//}}} } -export class N2SyncProgress extends CustomHTMLElement { - static { - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` - -
0 / 0
- ` - } +export class SyncProgress extends Component { constructor() {//{{{ super() - this.reset() - _mbus.subscribe('SYNC_COUNT', event => this.progressHandler(event)) - _mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event)) - _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) }//}}} reset() {//{{{ + this.forceUpdateRequest = null this.state = { nodesToSync: 0, nodesSynced: 0, + syncedDone: false, + } + document.getElementById('sync-progress')?.classList.remove('hidden') + }//}}} + componentDidMount() {//{{{ + window._sync.addListener(msg => this.progressHandler(msg), true) + }//}}} + getSnapshotBeforeUpdate(_, prevState) {//{{{ + if (!prevState.syncedDone && this.state.syncedDone) + setTimeout(() => document.getElementById('sync-progress')?.classList.add('hidden'), 750) + }//}}} + componentDidUpdate() {//{{{ + if (!this.state.syncedDone) { + if (this.forceUpdateRequest !== null) + clearTimeout(this.forceUpdateRequest) + this.forceUpdateRequest = setTimeout( + () => { + this.forceUpdateRequest = null + this.forceUpdate() + }, + 50 + ) } }//}}} - progressHandler(event) {//{{{ - const eventData = event.detail.data - switch (event.type) { - case 'SYNC_COUNT': - this.state.nodesToSync = eventData.count - this.setSyncState(true) + progressHandler(msg) {//{{{ + switch (msg.op) { + case SYNC_COUNT: + this.setState({ nodesToSync: msg.count }) break - case 'SYNC_HANDLED': - this.state.nodesSynced = eventData.handled + case SYNC_HANDLED: + this.state.nodesSynced += msg.count break - case 'SYNC_DONE': + case SYNC_DONE: // Hides the progress bar. - this.setSyncState(false) + this.setState({ syncedDone: true }) // Don't update anything if nothing was synced. if (this.state.nodesSynced === 0) break // Reload the tree nodes to reflect the new/updated nodes. - window._app.tree.reset() + if (window._notes2?.current?.reloadTree.value !== null) { + nodeStore.purgeCache() + window._notes2.current.reloadTree.value = window._notes2.current.reloadTree.value + 1 + } break } - this.render() }//}}} - render() {//{{{ - this.elProgress.max = this.state.nodesToSync - this.elProgress.value = this.state.nodesSynced - this.elCount.innerText = `${this.state.nodesSynced} / ${this.state.nodesToSync}` + render(_, { nodesToSync, nodesSynced }) {//{{{ + if (nodesToSync === 0) + return html`
` + + return html` +
+ +
${nodesSynced} / ${nodesToSync}
+
+ ` }//}}} - setSyncState(state) {// {{{ - if (state) - this.classList.add('show') - else - setTimeout(() => this.classList.remove('show'), 1500) - }// }}} } -customElements.define('n2-syncprogress', N2SyncProgress) // vim: foldmethod=marker diff --git a/static/js/tree.mjs b/static/js/tree.mjs deleted file mode 100644 index 1da5dee..0000000 --- a/static/js/tree.mjs +++ /dev/null @@ -1,451 +0,0 @@ -import { ROOT_NODE } from 'node_store' -import { CustomHTMLElement } from './lib/custom_html_element.mjs' - -export class N2Tree extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` - -
- - -
-
- ` - }// }}} - - constructor() {// {{{ - super() - - this.id = 'tree-nodes' - this.tabIndex = 0 - - this.treeNodeComponents = {} - this.treeTrunk = [] - this.expandedNodes = {} // keyed on UUID - this.selectedNode = null - this.rendered = false - - this.addEventListener('keydown', event => this.keyHandler(event)) - this.elSearch.addEventListener('click', () => _mbus.dispatch('op-search')) - this.elSync.addEventListener('click', () => _sync.run()) - this.elLogo.addEventListener('click', () => _app.goToNode(ROOT_NODE, false, false)) - - this.populateFirstLevel() - }// }}} - render() {// {{{ - if (this.rendered) - alert('Tree should only be rendered once.') - - for (const node of this.treeTrunk) { - const treenode = new N2TreeNode(this, node) - this.treeNodeComponents[node.UUID] = treenode - this.elTreenodes.appendChild(treenode.render()) - } - - this.rendered = true - return this - }// }}} - reset() { - console.log('tree reset') - this.treeNodeComponents = {} - this.treeTrunk = [] - this.rendered = false - this.elTreenodes.replaceChildren() - this.populateFirstLevel() - } - populateFirstLevel() {//{{{ - nodeStore.get(ROOT_NODE) - .then(node => node.fetchChildren()) - .then(children => { - this.treeNodeComponents = {} - this.treeTrunk = [] - for (const node of children) { - // The root node isn't supposed to be shown in the tree. - if (node.UUID === ROOT_NODE) - continue - if (node.ParentUUID === ROOT_NODE) - this.treeTrunk.push(node) - } - _mbus.dispatch('TREE_TRUNK_FETCHED') - }) - .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) - }//}}} - getNodeExpanded(UUID) {//{{{ - if (this.expandedNodes[UUID] === undefined) - this.expandedNodes[UUID] = false - return this.expandedNodes[UUID] - }//}}} - setNodeExpanded(node, value) {//{{{ - let expanded = this.expandedNodes[node.UUID] - - if (expanded === undefined) { - this.expandedNodes[node.UUID] = false - expanded = false - } - - if (expanded === value) - return - - this.expandedNodes[node.UUID] = value - _mbus.dispatch(`NODE_EXPAND_${node.UUID}`, value) - }//}}} - setSelected(node, dontExpand) {//{{{ - if (node === undefined) - return - - // The previously selected node, if any, needs to be rerendered - // to not retain its 'selected' class. - const prevUUID = this.selectedNode?.UUID - this.selectedNode = node - if (prevUUID) - this.treeNodeComponents[prevUUID]?.render(true) - - // And now the newly selected node is rerendered. - this.treeNodeComponents[node.UUID]?.render(true) - - if (!dontExpand) - this.setNodeExpanded(node, true) - }//}}} - isSelected(node) {//{{{ - return this.selectedNode?.UUID === node.UUID - }//}}} - - async keyHandler(event) {//{{{ - let handled = true - const n = this.selectedNode - const Space = ' ' - - // This handler would otherwise react to stuff like Ctrl+L. - if (event.ctrlKey || event.altKey) - return - - switch (event.key) { - // Space and enter is toggling expansion. - // Holding shift down does it recursively. - case Space: - case 'Enter': - const expanded = this.getNodeExpanded(n.UUID) - if (event.shiftKey) { - this.recursiveExpand(n, !expanded) - } else { - this.setNodeExpanded(n, !expanded) - } - break - - case 'g': - case 'Home': - this.navigateTop() - break - - case 'G': - case 'End': - this.navigateBottom() - break - - case 'j': - case 'ArrowDown': - await this.navigateDown(this.selectedNode) - break - - case 'k': - case 'ArrowUp': - await this.navigateUp(this.selectedNode) - break - - case 'h': - case 'ArrowLeft': - await this.navigateLeft(this.selectedNode) - break - - case 'l': - case 'ArrowRight': - await this.navigateRight(this.selectedNode) - break - - default: - // nonsole.log(event.key) - handled = false - } - - if (handled) { - event.preventDefault() - event.stopPropagation() - } - }//}}} - async navigateLeft(n) {//{{{ - if (n === null || n === undefined) - return - - const expanded = this.getNodeExpanded(n.UUID) - if (expanded && n.hasChildren()) { - this.setNodeExpanded(n, false) - return - } - - if (n.isFirstSibling() && n.getParent().UUID !== ROOT_NODE) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getParent()?.UUID, dontPush: false, dontExpand: true }) - return - } - - const siblingBefore = n.getSiblingBefore() - const siblingExpanded = this.getNodeExpanded(siblingBefore?.UUID) - if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - const siblingAbove = this.getLastExpandedNode(siblingBefore) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingAbove?.UUID, dontPush: false, dontExpand: true }) - return - } - - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingBefore()?.UUID, dontPush: false, dontExpand: true }) - }//}}} - async navigateRight(n) {//{{{ - if (n === null || n === undefined) - return - - const siblingAfter = n.getSiblingAfter() - const expanded = this.getNodeExpanded(n.UUID) - - if (!expanded && n.hasChildren()) { - this.setNodeExpanded(n, true) - return - } - - if (expanded && n.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0]?.UUID, dontPush: false, dontExpand: true }) - return - } - - if (n.isLastSibling()) { - const nextNode = this.getParentWithNextSibling(n) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: nextNode?.UUID, dontPush: false, dontExpand: true }) - return - } - - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) - }//}}} - async navigateUp(n) {//{{{ - if (n === null || n === undefined) - return - - let parent = null - const siblingBefore = n.getSiblingBefore() - let siblingExpanded = false - if (siblingBefore !== null) - siblingExpanded = this.getNodeExpanded(siblingBefore.UUID) - - if (n.isFirstSibling()) { - parent = n.getParent() - if (parent?.UUID === ROOT_NODE) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: parent?.UUID, dontPush: false, dontExpand: true }) - return - } - - if (siblingBefore !== null && siblingExpanded && siblingBefore.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.Children[siblingBefore.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) - return - } - - if (siblingBefore) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: siblingBefore.UUID, dontPush: false, dontExpand: true }) - return - } - }//}}} - async navigateDown(n) {//{{{ - if (n === null || n === undefined) - return - - const nodeExpanded = this.getNodeExpanded(n.UUID) - - // Last node, not expanded, so it matters not whether it has children or not. - // Traverse upward to nearest parent with next sibling. - if (!nodeExpanded && n.isLastSibling()) { - const wantedNode = this.getParentWithNextSibling(n) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true }) - return - } - - if (nodeExpanded && n.isLastSibling() && !n.hasChildren()) { - const wantedNode = this.getParentWithNextSibling(n) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: wantedNode?.UUID, dontPush: false, dontExpand: true }) - return - } - - // Node not expanded. Go to this node's next sibling. - // GoToNode will abort if given null. - if (!nodeExpanded || !n.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) - return - } - - // Node is expanded. - // Children will be visually beneath this node, if any. - if (nodeExpanded && n.hasChildren()) { - _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.Children[0].UUID, dontPush: false, dontExpand: true }) - return - } - }//}}} - async navigateTop() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[0]?.UUID, dontPush: false, dontExpand: true }) - }//}}} - async navigateBottom() {//{{{ - const root = await nodeStore.get(ROOT_NODE) - if (root.Children.length === 0) - return - - const toplevel = root.Children[root.Children.length - 1] - const toplevelExpanded = this.getNodeExpanded(toplevel?.UUID) - - if (toplevelExpanded) { - const lastnode = this.getLastExpandedNode(toplevel) - _mbus.dispatch("GO_TO_NODE", { nodeUUID: lastnode?.UUID, dontPush: false, dontExpand: true }) - } else - _mbus.dispatch("GO_TO_NODE", { nodeUUID: root.Children[root.Children.length - 1]?.UUID, dontPush: false, dontExpand: true }) - }//}}} - - getParentWithNextSibling(node) {//{{{ - let currNode = node - while (currNode !== null && currNode.UUID !== ROOT_NODE && currNode.getSiblingAfter() === null) { - currNode = currNode.getParent() - } - return currNode?.getSiblingAfter() - }//}}} - getLastExpandedNode(node) {//{{{ - let currNode = node - while (this.getNodeExpanded(currNode.UUID) && currNode.hasChildren()) { - currNode = currNode.Children[currNode.Children.length - 1] - } - return currNode - }//}}} - async recursiveExpand(node, state) {//{{{ - if (state) - await this.setNodeExpanded(node, true) - - for (const child of node.Children) - await this.recursiveExpand(child, state) - - if (!state) - await this.setNodeExpanded(node, false) - }//}}} - async makeVisible(node) {// {{{ - const treenode = this.treeNodeComponents[node.UUID] - - const ancestors = await nodeStore.getNodeAncestry(node) - for (const ancestor of ancestors.reverse()) { - this.setNodeExpanded(ancestor, true) - } - - // The ROOT_NODE for example hasn't got a treenode. - treenode?.scrollIntoView({ block: 'nearest' }) - }// }}} -} -customElements.define('n2-tree', N2Tree) - -export class N2TreeNode extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` -
- -
-
-
- ` - }// }}} - - constructor(tree, node, parent) {//{{{ - super() - this.classList.add('node') - - this.tree = tree - this.node = node - this.parent = parent - - this.children_populated = false - this.rendered = false - - this.elExpandToggle.addEventListener('click', () => this.tree.setNodeExpanded(this.node, !this.tree.getNodeExpanded(this.node.UUID))) - this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) - - _mbus.subscribe(`NODE_CHILDREN_FETCHED_${node.UUID}`, () => { - this.render(true) - }) - - _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, state => { - this.render(true) - }) - - if (this.node.Level === 0 || this.tree.getNodeExpanded(this.node.UUID)) - this.fetchChildren() - }// }}} - async fetchChildren() {//{{{ - await this.node.fetchChildren() - this.children_populated = true - }//}}} - render(force_update) {//{{{ - if (this.rendered && force_update !== true) - return this - - // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible. - const expanded = this.node.Children.length > 0 && this.tree.getNodeExpanded(this.node.UUID) - - if (!this.children_populated && this.tree.getNodeExpanded(this.parent?.node.UUID)) { - this.node.fetchChildren().then(() => this.children_populated = true) - } - - // Update the name and selected status - this.elName.innerText = this.node.get('Name') - if (this.tree.isSelected(this.node)) - this.elName.classList.add('selected') - else - this.elName.classList.remove('selected') - - // Update expansion state - if (expanded) { - this.elChildren.classList.add('expanded') - this.elChildren.classList.remove('collapsed') - } else { - this.elChildren.classList.remove('expanded') - this.elChildren.classList.add('collapsed') - } - - // The expand icon is only changed to not get a flickering when re-rendering. - if (this.node.Children.length === 0) - this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) - else if (this.tree.getNodeExpanded(this.node.UUID)) - this.setImgSrc(this.elExpand, `/images/${window._VERSION}/expanded.svg`) - else - this.setImgSrc(this.elExpand, `/images/${window._VERSION}/collapsed.svg`) - - // Should children be rendered? - this.elChildren.innerHTML = '' - let children = [] - if (expanded) - children = this.node.Children.map(node => { - let treenode = this.tree.treeNodeComponents[node.UUID] - if (treenode === undefined) { - treenode = new N2TreeNode(this.tree, node, this) - this.tree.treeNodeComponents[node.UUID] = treenode - } - return treenode - }) - - for (const c of children) - this.elChildren.appendChild(c.render()) - - this.rendered = true - return this - }//}}} - - setImgSrc(img, newSrc) {// {{{ - if (img.getAttribute('src') === newSrc) - return - img.setAttribute('src', newSrc) - }// }}} -} -customElements.define('n2-treenode', N2TreeNode) - -// vim: foldmethod=marker diff --git a/static/less/notes2.less b/static/less/notes2.less new file mode 100644 index 0000000..a1ae783 --- /dev/null +++ b/static/less/notes2.less @@ -0,0 +1,350 @@ +@import "theme.less"; + +html { + background-color: #fff; +} + +#notes2 { + min-height: 100vh; + + display: grid; + grid-template-areas: + "tree crumbs" + "tree sync" + "tree name" + "tree content" + //"tree checklist" + //"tree schedule" + //"tree files" + "tree blank" + ; + grid-template-columns: min-content 1fr; + grid-template-rows: + 48px + 56px + 48px + min-content + 1fr; + + + @media only screen and (max-width: 600px) { + grid-template-areas: + "crumbs" + "sync" + "name" + "content" + //"checklist" + //"schedule" + //"files" + "blank" + ; + grid-template-columns: 1fr; + + #tree { + display: none; + } + } +} + +#tree { + grid-area: tree; + padding: 16px 32px; + background-color: #333; + color: #ddd; + z-index: 100; // Over crumbs shadow + border-left: 2px solid #333; + + &:focus { + border-left: 2px solid #FE5F55; + } + + #logo { + display: grid; + position: relative; + justify-items: center; + margin-bottom: 8px; + margin-left: 24px; + margin-right: 24px; + img { + width: 128px; + left: -20px; + + } + } + + .icons { + display: flex; + justify-content: center; + margin-bottom: 32px; + gap: 8px; + } + + .node { + display: grid; + grid-template-columns: 24px min-content; + grid-template-rows: + min-content + 1fr; + margin-top: 12px; + + + .expand-toggle { + user-select: none; + 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 #444; + grid-column: 1 / -1; + + &.collapsed { + display: none; + } + } + } +} + +#crumbs { + grid-area: crumbs; + display: grid; + align-items: start; + justify-items: center; + margin: 0px 16px; + + .crumbs { + background: #e4e4e4; + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #e4e4e4; + color: #333; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + + &.node-modified { + background-color: @color1; + color: @color2; + .crumb:after { + color: @color2; + } + } + + .crumb { + margin-right: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + + .crumb:after { + content: "•"; + margin-left: 8px; + color: @color1 + } + + .crumb:last-child { + margin-right: 0; + } + .crumb:last-child:after { + content: ''; + margin-left: 0px; + } + + } + +} + +#sync-progress { + grid-area: sync; + display: grid; + justify-items: center; + + width: 100%; + height: 56px; + position: relative; + + progress { + width: 100%; + padding: 0 7px; + max-width: 900px; + height: 16px; + border-radius: 4px; + } + + progress[value]::-webkit-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; + } + + progress[value]::-moz-progress-bar { + background-color: #eee; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset; + border-radius: 4px; + } + + progress[value]::-webkit-progress-value { + background: rgb(186,95,89); + background: linear-gradient(180deg, rgba(186,95,89,1) 0%, rgba(254,95,85,1) 50%, rgba(186,95,89,1) 100%); + border-radius: 4px; + } + + // TODO: style the progress value for Firefox + progress[value]::-moz-progress-value { + background: rgb(186,95,89); + background: linear-gradient(180deg, rgba(186,95,89,1) 0%, rgba(254,95,85,1) 50%, rgba(186,95,89,1) 100%); + border-radius: 4px; + } + + .count { + width: min-content; + white-space: nowrap; + margin-top: 0px; + color: #888; + position: absolute; + top: 22px; + } + + &.hidden { + visibility: hidden; + opacity: 0; + transition: visibility 0s 500ms, opacity 500ms linear; + } + +} + +#name { + color: #333; + font-weight: bold; + text-align: center; + font-size: 1.15em; + margin-top: 0px; + margin-bottom: 16px; +} + +/* ============================================================= * + * Textarea replicates the height of an element expanding height * + * ============================================================= */ +.grow-wrap { + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ + display: grid; + grid-area: content; + font-size: 1.0em; +} +.grow-wrap::after { + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) " "; + + /* This is how textarea text behaves */ + width: calc(100% - 32px); + max-width: 900px; + white-space: pre-wrap; + word-wrap: break-word; + background: rgba(0, 255, 255, 0.5); + justify-self: center; + + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; +} +.grow-wrap > textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; +} +.grow-wrap > textarea, +.grow-wrap::after { + /* Identical styling required!! */ + padding: 0.5rem; + font: inherit; + + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; +} +/* ============================================================= */ + +#node-content { + justify-self: center; + word-wrap: break-word; + font-family: monospace; + color: #333; + width: calc(100% - 32px); + max-width: 900px; + resize: none; + border: none; + outline: none; + + &:invalid { + background: #f5f5f5; + padding-top: 16px; + } +} + +#blank { + grid-area: blank; + height: 32px; +} + +dialog.op { + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + .header { + font-weight: bold; + margin-top: 16px; + + &:first-child { + margin-top: 0px; + } + } + +} + +#op-search { + .results { + display: grid; + grid-template-columns: min-content min-content; + grid-gap: 6px 16px; + + div { + white-space: nowrap; + } + + + .ancestors { + display: flex; + + .ancestor::after { + content: ">"; + margin: 0px 8px; + color: #a00; + } + + .ancestor:last-child::after { + content: ""; + } + } + } +} diff --git a/static/less/theme.less b/static/less/theme.less index b9c47ed..255a49c 100644 --- a/static/less/theme.less +++ b/static/less/theme.less @@ -1,5 +1,3 @@ -:root { - --color1: #fe5f55; - --color2: #efede8; - --color3: #666; -} +@color1: #fe5f55; +@color2: #efede8; +@color3: #666; diff --git a/static/service_worker.js b/static/service_worker.js index c48c162..6c77241 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -6,6 +6,13 @@ const CACHED_ASSETS = [ '/css/{{ .VERSION }}/main.css', '/css/{{ .VERSION }}/notes2.css', + '/js/{{ .VERSION }}/lib/preact/preact.mjs', + '/js/{{ .VERSION }}/lib/preact/devtools.mjs', + '/js/{{ .VERSION }}/lib/signals/signals.mjs', + '/js/{{ .VERSION }}/lib/signals/signals-core.mjs', + '/js/{{ .VERSION }}/lib/preact/hooks.mjs', + '/js/{{ .VERSION }}/lib/preact/debug.mjs', + '/js/{{ .VERSION }}/lib/htm/htm.mjs', '/js/{{ .VERSION }}/lib/fullcalendar.min.js', '/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js', '/js/{{ .VERSION }}/lib/sjcl.js', diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index b3b5514..123fc68 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -2,26 +2,8 @@ - + - + diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 77b74a6..f633692 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,33 +1,35 @@ {{ define "page" }} -
-
-
- - -
- +
{{ end }}