From d0150145edee02383c23c836ae59bb0ec62cf6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 18 Dec 2024 19:12:10 +0100 Subject: [PATCH] More sync operations --- main.go | 46 +++++++--- node.go | 101 ++++++++++++++++++---- sql/00004.sql | 2 + sql/00005.sql | 1 + static/css/notes2.css | 15 ++-- static/js/node.mjs | 28 ++++-- static/js/node_store.mjs | 181 ++++++++++++++++++++++++++++----------- static/js/notes2.mjs | 16 ++-- static/js/sync.mjs | 69 +++++++++++++-- static/less/notes2.less | 34 +++----- 10 files changed, 362 insertions(+), 131 deletions(-) create mode 100644 sql/00004.sql create mode 100644 sql/00005.sql diff --git a/main.go b/main.go index f86a8ab..34545e6 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log/slog" "net/http" "path" @@ -24,6 +25,7 @@ import ( const VERSION = "v1" const CONTEXT_USER = 1 +const SYNC_PAGINATION = 250 var ( FlagGenerate bool @@ -122,7 +124,8 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/node/tree/{timestamp}/{offset}", authenticated(actionNodeTree)) + http.HandleFunc("/sync/node/{sequence}/{offset}", authenticated(actionSyncNode)) + http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -201,7 +204,10 @@ func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{ return } - err = tmpl.Execute(w, struct{ VERSION string; DevMode bool }{VERSION, FlagDev}) + err = tmpl.Execute(w, struct { + VERSION string + DevMode bool + }{VERSION, FlagDev}) if err != nil { w.Write([]byte(err.Error())) return @@ -235,22 +241,40 @@ func pageSync(w http.ResponseWriter, r *http.Request) { // {{{ } } // }}} -func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) - changedFrom, _ := strconv.Atoi(r.PathValue("timestamp")) - offset, _ := strconv.Atoi(r.PathValue("offset")) - - nodes, maxSeq, moreRowsExist, err := NodeTree(user.ID, offset, uint64(changedFrom)) +func actionSyncNode(w http.ResponseWriter, r *http.Request) { // {{{ + // The purpose of the Client UUID is to avoid + // sending nodes back once again to a client that + // just created or modified it. + request := struct { + ClientUUID string + }{} + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &request) if err != nil { Log.Error("/node/tree", "error", err) httpError(w, err) return } + user := getUser(r) + changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) + offset, _ := strconv.Atoi(r.PathValue("offset")) + + nodes, maxSeq, moreRowsExist, err := Nodes(user.ID, offset, uint64(changedFrom), request.ClientUUID) + if err != nil { + Log.Error("/node/tree", "error", err) + httpError(w, err) + return + } + + Log.Debug("/node/tree", "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 - Nodes []TreeNode - MaxSeq uint64 + OK bool + Nodes []Node + MaxSeq uint64 Continue bool }{true, nodes, maxSeq, moreRowsExist}) w.Write(j) diff --git a/node.go b/node.go index 18f7c0a..83b1c5a 100644 --- a/node.go +++ b/node.go @@ -38,21 +38,23 @@ type TreeNode struct { } type Node struct { - UUID string - UserID int `db:"user_id"` - ParentUUID string `db:"parent_uuid"` - CryptoKeyID int `db:"crypto_key_id"` - Name string - Content string - Updated time.Time - Files []File - Complete bool - Level int - - ChecklistGroups []ChecklistGroup - + UUID string + UserID int `db:"user_id"` + ParentUUID string `db:"parent_uuid"` + Name string + Created time.Time + Updated time.Time + Deleted bool + CreatedSeq uint64 `db:"created_seq"` + UpdatedSeq uint64 `db:"updated_seq"` + DeletedSeq sql.NullInt64 `db:"deleted_seq"` + Content string ContentEncrypted string `db:"content_encrypted" json:"-"` Markdown bool + + // CryptoKeyID int `db:"crypto_key_id"` + //Files []File + //ChecklistGroups []ChecklistGroup } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -72,7 +74,8 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 FROM public.node WHERE - user_id = $1 AND ( + user_id = $1 AND + NOT history AND ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -91,11 +94,6 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 } defer rows.Close() - type resultRow struct { - Node - Level int - } - nodes = []TreeNode{} numNodes := 0 for rows.Next() { @@ -120,6 +118,71 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 return } // }}} +func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, maxSeq uint64, moreRowsExist bool, err error) { // {{{ + var rows *sqlx.Rows + rows, err = db.Queryx(` + SELECT + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, + name, + created, + updated, + deleted IS NOT NULL AS deleted, + created_seq, + updated_seq, + deleted_seq, + content, + content_encrypted, + markdown + FROM + public.node + WHERE + user_id = $1 AND + client != $5 AND + NOT history AND ( + created_seq > $4 OR + updated_seq > $4 OR + deleted_seq > $4 + ) + ORDER BY + id ASC + LIMIT $2 OFFSET $3 + `, + userID, + SYNC_PAGINATION+1, + offset, + synced, + clientUUID, + ) + if err != nil { + return + } + defer rows.Close() + + nodes = []Node{} + numNodes := 0 + for rows.Next() { + // Query selects up to one more row than the decided limit. + // Saves one SQL query for row counting. + // Thus if numNodes is larger than the limit, more rows exist for the next call. + numNodes++ + if numNodes > SYNC_PAGINATION { + moreRowsExist = true + return + } + + node := Node{} + if err = rows.StructScan(&node); err != nil { + return + } + nodes = append(nodes, node) + + // DeletedSeq will be 0 if invalid, and thus not be a problem for the max function. + maxSeq = max(maxSeq, node.CreatedSeq, node.UpdatedSeq, uint64(node.DeletedSeq.Int64)) + } + + return +} // }}} func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ var rows *sqlx.Row rows = db.QueryRowx(` diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..7ac464d --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.node ADD COLUMN history bool NOT NULL DEFAULT false; +CREATE INDEX node_history_idx ON public.node (history); diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..a366070 --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ADD COLUMN client bpchar(36) NOT NULL DEFAULT ''; diff --git a/static/css/notes2.css b/static/css/notes2.css index f31e3e9..bed7439 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -4,17 +4,13 @@ html { #notes2 { min-height: 100vh; display: grid; - grid-template-areas: "tree crumbs" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank"; + grid-template-areas: "tree crumbs" "tree name" "tree content" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* crumbs */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; - /* blank */ } @media only screen and (max-width: 600px) { #notes2 { - grid-template-areas: "crumbs" "name" "content" "checklist" "schedule" "files" "blank"; + grid-template-areas: "crumbs" "name" "content" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* crumbs */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; - /* blank */ } #notes2 #tree { display: none; @@ -45,6 +41,9 @@ html { 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; @@ -163,3 +162,7 @@ html { background: #f5f5f5; padding-top: 16px; } +#blank { + grid-area: blank; + height: 32px; +} diff --git a/static/js/node.mjs b/static/js/node.mjs index 98eef87..32572af 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -41,6 +41,7 @@ export class NodeUI extends Component {
${node.Name}
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> +
` @@ -228,9 +229,11 @@ class NodeContent extends Component { } let element + /* if (node.RenderMarkdown.value) element = html`<${MarkdownContent} key='markdown-content' content=${content} />` else + */ element = html`
@@ -273,15 +276,14 @@ class NodeContent extends Component { export class Node { constructor(nodeData, level) {//{{{ this.Level = level - - this._children_fetched = false - this.Children = [] + this.data = nodeData this.UUID = nodeData.UUID this.ParentUUID = nodeData.ParentUUID - this.UserID = nodeData.UserID - this.CryptoKeyID = nodeData.CryptoKeyID - this.Name = nodeData.Name + + this._children_fetched = false + this.Children = [] + /* this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false this.ShowChecklist = signal(false) @@ -294,6 +296,14 @@ export class Node { this.ScheduleEvents = signal([]) // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. + */ + }//}}} + get(prop) {//{{{ + return this.data[prop] + }//}}} + updated() {//{{{ + // '2024-12-17T17:33:48.85939Z + return new Date(Date.parse(this.data.Updated)) }//}}} hasFetchedChildren() {//{{{ return this._children_fetched @@ -311,7 +321,7 @@ export class Node { if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() */ - return this._content + return this.data.Content }//}}} setContent(new_content) {//{{{ this._content = new_content @@ -325,8 +335,8 @@ export class Node { */ }//}}} static sort(a, b) {//{{{ - if (a.Name < b.Name) return -1 - if (a.Name > b.Name) return 0 + if (a.data.Name < b.data.Name) return -1 + if (a.data.Name > b.data.Name) return 0 return 0 }//}}} } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 57909b7..b90a613 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,4 +1,3 @@ -import { API } from 'api' import { Node } from 'node' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' @@ -13,15 +12,14 @@ export class NodeStore { }//}}} async initializeDB() {//{{{ return new Promise((resolve, reject) => { - const req = indexedDB.open('notes', 3) - + const req = indexedDB.open('notes', 5) // Schema upgrades for IndexedDB. // These can start from different points depending on updates to Notes2 since a device was online. req.onupgradeneeded = (event) => { - let treeNodes let nodes let appState + let sendQueue const db = event.target.result const trx = event.target.transaction @@ -31,28 +29,35 @@ export class NodeStore { // The schema transformations. switch (i) { case 1: - treeNodes = db.createObjectStore('treeNodes', { keyPath: 'UUID' }) - treeNodes.createIndex('nameIndex', 'Name', { unique: false }) - nodes = db.createObjectStore('nodes', { keyPath: 'UUID' }) nodes.createIndex('nameIndex', 'Name', { unique: false }) break case 2: - trx.objectStore('treeNodes').createIndex('parentIndex', 'ParentUUID', { unique: false }) + trx.objectStore('nodes').createIndex('parentIndex', 'ParentUUID', { unique: false }) break case 3: - appState = db.createObjectStore('appState', { keyPath: 'key' }) + appState = db.createObjectStore('app_state', { keyPath: 'key' }) + break + + case 4: + trx.objectStore('nodes').createIndex('modifiedIndex', 'modified', { unique: false }) + break + + case 5: + sendQueue = db.createObjectStore('send_queue', { keyPath: ['UUID', 'Updated'] }) + sendQueue.createIndex('updated', 'Updated', { unique: false }) + break } } } req.onsuccess = (event) => { this.db = event.target.result - this.initializeRootNode().then(() => - resolve() - ) + this.initializeRootNode() + .then(() => this.initializeClientUUID()) + .then(() => resolve()) } req.onerror = (event) => { @@ -60,7 +65,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. @@ -89,11 +94,18 @@ export class NodeStore { getRequest.onerror = (event) => reject(event.target.error) }) }//}}} + async initializeClientUUID() {//{{{ + let clientUUID = await this.getAppState('client_uuid') + if (clientUUID !== null) + return + clientUUID = crypto.randomUUID() + return this.setAppState('client_uuid', clientUUID) + }//}}} async getAppState(key) {//{{{ return new Promise((resolve, reject) => { - const trx = this.db.transaction('appState', 'readonly') - const appState = trx.objectStore('appState') + const trx = this.db.transaction('app_state', 'readonly') + const appState = trx.objectStore('app_state') const getRequest = appState.get(key) getRequest.onsuccess = (event) => { if (event.target.result !== undefined) { @@ -108,8 +120,8 @@ export class NodeStore { async setAppState(key, value) {//{{{ return new Promise((resolve, reject) => { try { - const t = this.db.transaction('appState', 'readwrite') - const appState = t.objectStore('appState') + const t = this.db.transaction('app_state', 'readwrite') + const appState = t.objectStore('app_state') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -129,10 +141,72 @@ export class NodeStore { }) }//}}} - async upsertTreeRecords(records) {//{{{ + async moveToSendQueue(nodeToMove, replaceWithNode) {//{{{ return new Promise((resolve, reject) => { - const t = this.db.transaction('treeNodes', 'readwrite') - const nodeStore = t.objectStore('treeNodes') + const t = this.db.transaction(['nodes', 'send_queue'], 'readwrite') + const nodeStore = t.objectStore('nodes') + const sendQueue = t.objectStore('send_queue') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + resolve() + } + + // Node to be moved is first stored in the new queue. + const queueReq = sendQueue.put(nodeToMove.data) + queueReq.onsuccess = () => { + // When added to the send queue, the node is either deleted + // or replaced with a new node. + console.debug(`Queueing ${nodeToMove.UUID} (${nodeToMove.get('Name')})`) + let nodeReq + if (replaceWithNode) + nodeReq = nodeStore.put(replaceWithNode.data) + else + nodeReq = nodeStore.delete(nodeToMove.UUID) + nodeReq.onsuccess = () => { + resolve() + } + nodeReq.onerror = (event) => { + console.log(`Error moving ${nodeToMove.UUID}`, event.target.error) + reject(event.target.error) + } + + } + queueReq.onerror = (event) => { + console.log(`Error queueing ${nodeToMove.UUID}`, event.target.error) + reject(event.target.error) + } + }) + }//}}} + 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') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -152,23 +226,25 @@ export class NodeStore { addReq = nodeStore.delete(record.UUID) } else { op = 'upserting' + // 'modified' is a local property for tracking + // nodes needing to be synced to backend. + record.modified = 0 addReq = nodeStore.put(record) } addReq.onsuccess = () => { - console.log(`${op} ${record.UUID} (${record.Name})`) + console.debug(`${op} ${record.UUID} (${record.Name})`) } addReq.onerror = (event) => { console.log(`error ${op} ${record.UUID}`, event.target.error) reject(event.target.error) } } - }) }//}}} async getTreeNodes(parent, newLevel) {//{{{ return new Promise((resolve, reject) => { - const trx = this.db.transaction('treeNodes', 'readonly') - const nodeStore = trx.objectStore('treeNodes') + const trx = this.db.transaction('nodes', 'readonly') + const nodeStore = trx.objectStore('nodes') const index = nodeStore.index('parentIndex') const req = index.getAll(parent) req.onsuccess = (event) => { @@ -193,21 +269,29 @@ export class NodeStore { reject(event.target.error) } t.oncomplete = () => { - resolve() + console.log('OK') } // records is an object, not an array. + const promises = [] for (const recordIdx in records) { const record = records[recordIdx] - const addReq = nodeStore.put(record) - addReq.onsuccess = () => { - console.log('OK!', record.ID, record.Name) - } - addReq.onerror = (event) => { - console.log('Error!', event.target.error, record.ID) - } + 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) } + Promise.all(promises).then(() => resolve()) } catch (e) { console.log(e) } @@ -221,26 +305,23 @@ export class NodeStore { const nodeStore = trx.objectStore('nodes') const getRequest = nodeStore.get(uuid) getRequest.onsuccess = (event) => { - // Node found in IndexedDB and returned. - if (event.target.result !== undefined) { - const node = new Node(event.target.result, -1) - resolve(node) + // Node not found in IndexedDB. + if (event.target.result === undefined) { + reject("No such node") return } - - // Node not found and a request to the backend is made. - API.query("POST", `/node/retrieve/${uuid}`, {}) - .then(res => { - const trx = this.db.transaction('nodes', 'readwrite') - const nodeStore = trx.objectStore('nodes') - const putRequest = nodeStore.put(res.Node) - const node = new Node(res.Node, -1) - putRequest.onsuccess = () => resolve(node) - putRequest.onerror = (event) => { - reject(event.target.error) - } - }) - .catch(e => reject(e)) + const node = new Node(event.target.result, -1) + resolve(node) + } + }) + }//}}} + async nodeCount() {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + const countReq = nodeStore.count() + countReq.onsuccess = event => { + resolve(event.target.result) } }) }//}}} diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 9afec41..665568f 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -6,17 +6,21 @@ import { ROOT_NODE } from 'node_store' const html = htm.bind(h) export class Notes2 extends Component { - state = { - startNode: null, - } constructor() {//{{{ super() this.tree = createRef() this.nodeUI = createRef() + this.state = { + startNode: null, + } + + Sync.nodes().then(durationNodes => + console.log(`Total time: ${Math.round(100 * durationNodes) / 100}s`) + ) this.getStartNode() }//}}} - render({}, { startNode }) {//{{{ + render(_props, { startNode }) {//{{{ if (startNode === null) return @@ -81,7 +85,7 @@ class Tree extends Component { }) return html`
- + ${renderedTreeTrunk}
` }//}}} @@ -197,7 +201,7 @@ class TreeNode extends Component { return html`
{ this.expanded.value ^= true }}>${expandImg}
-
window._notes2.current.goToNode(node.UUID)}>${node.Name}
+
window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}
${children}
` }//}}} diff --git a/static/js/sync.mjs b/static/js/sync.mjs index a27817e..2814fe4 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,31 +1,84 @@ import { API } from 'api' +import { Node } from 'node' export class Sync { constructor() { this.foo = '' } - static async tree() { + static async nodes() { + let duration = 0 + const syncStart = Date.now() try { - const state = await nodeStore.getAppState('latest_sync') + // The latest sync node value is used to retrieve the changes + // from the backend. + const state = await nodeStore.getAppState('latest_sync_node') + const clientUUID = await nodeStore.getAppState('client_uuid') const oldMax = (state?.value ? state.value : 0) - let newMax = 0 + let currMax = oldMax let offset = 0 let res = { Continue: false } let batch = 0 do { batch++ - console.log(`Batch #${batch}`) - res = await API.query('POST', `/node/tree/${oldMax}/${offset}`, {}) + res = await API.query('POST', `/sync/node/${oldMax}/${offset}`, { ClientUUID: clientUUID.value }) + if (res.Nodes.length > 0) + console.log(`Node sync batch #${batch}`) offset += res.Nodes.length - newMax = res.MaxSeq - await nodeStore.upsertTreeRecords(res.Nodes) + currMax = Math.max(currMax, res.MaxSeq) + + /* Go through each node and determine if they are older than + * the node in IndexedDB. If they are, they are just history + * and can be ignored since history is currently not stored + * in the browser. + * + * If the backed node is newer, the local node is stored in + * a separate table in IndexedDB to at a later stage in the + * sync be preserved in the backend. */ + + let backendNode = null + for (const i in res.Nodes) { + backendNode = new Node(res.Nodes[i], -1) + await Sync.handleNode(backendNode) + } + } while (res.Continue) - nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax)) + nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { console.log('sync node tree', e) + } finally { + const syncEnd = Date.now() + duration = (syncEnd - syncStart) / 1000 + const count = await nodeStore.nodeCount() + console.log(`Node sync took ${duration}s`, count) + } + return duration + } + static 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) + .then(async localNode => { + if (localNode.updated() >= backendNode.updated()) { + console.log(`History from backend: ${backendNode.UUID}`) + return + } + + // local node is older than the backend node + // and moved into the send_queue table for later sync to backend. + return nodeStore.moveToSendQueue(localNode, backendNode) + }) + .catch(async e => { + // Not found in IndexedDB - OK to just insert since it only exists in backend. + return nodeStore.add([backendNode]) + }) + } catch (e) { + console.error(e) } } } diff --git a/static/less/notes2.less b/static/less/notes2.less index ae66833..52472ef 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -12,40 +12,24 @@ html { "tree crumbs" "tree name" "tree content" - "tree checklist" - "tree schedule" - "tree files" + //"tree checklist" + //"tree schedule" + //"tree files" "tree blank" ; grid-template-columns: min-content 1fr; - grid-template-rows: - min-content /* crumbs */ - min-content /* name */ - min-content /* content */ - min-content /* checklist */ - min-content /* schedule */ - min-content /* files */ - 1fr; /* blank */ @media only screen and (max-width: 600px) { grid-template-areas: "crumbs" "name" "content" - "checklist" - "schedule" - "files" + //"checklist" + //"schedule" + //"files" "blank" ; grid-template-columns: 1fr; - grid-template-rows: - min-content /* crumbs */ - min-content /* name */ - min-content /* content */ - min-content /* checklist */ - min-content /* schedule */ - min-content /* files */ - 1fr; /* blank */ #tree { display: none; @@ -84,6 +68,7 @@ html { .expand-toggle { + user-select: none; img { width: 16px; height: 16px; @@ -225,3 +210,8 @@ html { padding-top: 16px; } } + +#blank { + grid-area: blank; + height: 32px; +}