From f98a6ab8634163dc5eac84d656fe84584f7bcf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jan 2024 23:19:40 +0100 Subject: [PATCH] Added checklists to database, rendering and toggling items --- main.go | 24 +++++++++ node.go | 111 ++++++++++++++++++++++++++++++++++++++++ sql/00014.sql | 18 +++++++ static/css/main.css | 106 +++++++++++++++++++++++++++++++++----- static/index.html | 1 + static/js/checklist.mjs | 94 ++++++++++++++++++++++++++++++++++ static/js/node.mjs | 27 +++++----- static/less/main.less | 100 ++++++++++++++++++++++++++++++++++-- static/less/theme.less | 5 ++ 9 files changed, 455 insertions(+), 31 deletions(-) create mode 100644 sql/00014.sql create mode 100644 static/js/checklist.mjs diff --git a/main.go b/main.go index f6b8e01..64443a4 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,7 @@ func main() { // {{{ service.Register("/node/delete", true, true, nodeDelete) service.Register("/node/download", true, true, nodeDownload) service.Register("/node/search", true, true, nodeSearch) + service.Register("/node/checklist_item/state", true, true, nodeChecklistItemState) service.Register("/key/retrieve", true, true, keyRetrieve) service.Register("/key/create", true, true, keyCreate) service.Register("/key/counter", true, true, keyCounter) @@ -477,6 +478,29 @@ func nodeSearch(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{ "Nodes": nodes, }) } // }}} +func nodeChecklistItemState(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ + logger.Info("webserver", "request", "/node/checklist_item/state") + var err error + + req := struct { + ChecklistItemID int + State bool + }{} + if err = parseRequest(r, &req); err != nil { + responseError(w, err) + return + } + + err = ChecklistItemState(sess.UserID, req.ChecklistItemID, req.State) + if err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + }) +} // }}} func keyRetrieve(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/key/retrieve") diff --git a/node.go b/node.go index 12b3949..4cc55e8 100644 --- a/node.go +++ b/node.go @@ -8,6 +8,22 @@ import ( "time" ) +type ChecklistItem struct { + ID int + GroupID int `db:"checklist_group_id"` + Order int + Label string + Checked bool +} + +type ChecklistGroup struct { + ID int + NodeID int `db:"node_id"` + Order int + Label string + Items []ChecklistItem +} + type Node struct { ID int UserID int `db:"user_id"` @@ -22,6 +38,8 @@ type Node struct { Complete bool Level int + ChecklistGroups []ChecklistGroup + ContentEncrypted string `db:"content_encrypted" json:"-"` Markdown bool } @@ -211,6 +229,8 @@ func RetrieveNode(userID, nodeID int) (node Node, err error) { // {{{ } else { node.Content = row.Content } + + node.retrieveChecklist() } if row.Level == 1 { @@ -421,5 +441,96 @@ func SearchNodes(userID int, search string) (nodes []Node, err error) { // {{{ return } // }}} +func ChecklistItemState(userID, checklistItemID int, state bool) (err error) {// {{{ + _, err = service.Db.Conn.Exec( + ` + UPDATE checklist_item i + SET checked = $3 + FROM checklist_group g, node n + WHERE + i.checklist_group_id = g.id AND + g.node_id = n.id AND + n.user_id = $1 AND + i.id = $2; + `, + userID, + checklistItemID, + state, + ) + return +}// }}} + +func (node *Node) retrieveChecklist() (err error) { // {{{ + var rows *sqlx.Rows + rows, err = service.Db.Conn.Queryx(` + SELECT + g.id AS group_id, + g.order AS group_order, + g.label AS group_label, + + i.id AS item_id, + i.order AS item_order, + i.label AS item_label, + i.checked + + FROM public.checklist_group g + LEFT JOIN public.checklist_item i ON i.checklist_group_id = g.id + WHERE + g.node_id = $1 + ORDER BY + g.order DESC, + i.order DESC + `, node.ID) + if err != nil { + return + } + defer rows.Close() + + groups := make(map[int]*ChecklistGroup) + var found bool + var group *ChecklistGroup + var item ChecklistItem + for rows.Next() { + row := struct { + GroupID int `db:"group_id"` + GroupOrder int `db:"group_order"` + GroupLabel string `db:"group_label"` + + ItemID int `db:"item_id"` + ItemOrder int `db:"item_order"` + ItemLabel string `db:"item_label"` + Checked bool + }{} + err = rows.StructScan(&row) + if err != nil { + return + } + + if group, found = groups[row.GroupID]; !found { + group = new(ChecklistGroup) + group.ID = row.GroupID + group.NodeID = node.ID + group.Order = row.GroupOrder + group.Label = row.GroupLabel + group.Items = []ChecklistItem{} + groups[group.ID] = group + } + + item = ChecklistItem{} + item.ID = row.ItemID + item.GroupID = row.GroupID + item.Order = row.ItemOrder + item.Label = row.ItemLabel + item.Checked = row.Checked + group.Items = append(group.Items, item) + } + + node.ChecklistGroups = []ChecklistGroup{} + for _, group := range groups { + node.ChecklistGroups = append(node.ChecklistGroups, *group) + } + + return +} // }}} // vim: foldmethod=marker diff --git a/sql/00014.sql b/sql/00014.sql new file mode 100644 index 0000000..3a1123c --- /dev/null +++ b/sql/00014.sql @@ -0,0 +1,18 @@ +CREATE TABLE checklist_group ( + id serial NOT NULL, + node_id int4 NOT NULL, + "order" int NOT NULL DEFAULT 0, + label varchar NOT NULL, + CONSTRAINT checklist_group_pk PRIMARY KEY (id), + CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE checklist_item ( + id serial NOT NULL, + checklist_group_id int4 NOT NULL, + "order" int NOT NULL DEFAULT 0, + label varchar NOT NULL, + checked bool NOT NULL DEFAULT false, + CONSTRAINT checklist_item_pk PRIMARY KEY (id), + CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE +) diff --git a/static/css/main.css b/static/css/main.css index d56561f..5b3af8d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -26,12 +26,12 @@ body { height: 100%; } h1 { - margin-top: 0px; font-size: 1.5em; + color: #518048; } h2 { - margin-top: 32px; font-size: 1.25em; + color: #518048; } button { font-size: 1em; @@ -307,10 +307,15 @@ header .menu { padding-top: 16px; } #markdown { - padding: 16px; color: #333; grid-area: content; + justify-self: center; + width: calc(100% - 32px); max-width: 900px; + padding: 0.5rem; + border-radius: 8px; + margin-top: 8px; + margin-bottom: 0px; } #markdown table { border-collapse: collapse; @@ -330,6 +335,68 @@ header .menu { padding: 0px; border-radius: 0px; } +#checklist { + grid-area: checklist; + color: #333; + justify-self: center; + width: calc(100% - 32px); + max-width: 900px; + padding: 0.5rem; + border-radius: 8px; + margin-top: 8px; + margin-bottom: 0px; +} +#checklist .checklist-group { + margin-top: 1em; + font-weight: bold; +} +#checklist .checklist-item { + display: grid; + grid-template-columns: min-content 1fr; + align-items: center; + margin-top: 0.5em; +} +#checklist .checklist-item.checked { + text-decoration: line-through; + color: #888; +} +#checklist .checklist-item input[type="checkbox"] { + margin-left: 0px; + margin-right: 8px; + -webkit-appearance: none; + appearance: none; + background-color: #fff; + margin: 0 8px 0 0; + font: inherit; + color: currentColor; + width: 1.25em; + height: 1.25em; + border: 0.15em solid currentColor; + border-radius: 0.15em; + transform: translateY(-0.075em); + display: grid; + place-content: center; +} +#checklist .checklist-item input[type="checkbox"].ok { + border: 0.15em solid #54b356; +} +#checklist .checklist-item input[type="checkbox"].ok::before { + box-shadow: inset 1em 1em #54b356; +} +#checklist .checklist-item input[type="checkbox"]::before { + content: ""; + width: 0.7em; + height: 0.7em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em #666; +} +#checklist .checklist-item input[type="checkbox"]:checked::before { + transform: scale(1); +} +#checklist .checklist-item label { + user-select: none; +} /* ============================================================= * * Textarea replicates the height of an element expanding height * * ============================================================= */ @@ -476,9 +543,9 @@ header .menu { } .layout-tree { display: grid; - grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank"; + grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; /* blank */ color: #fff; min-height: 100%; @@ -511,14 +578,17 @@ header .menu { display: block; } .layout-crumbs { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; /* blank */ } .layout-crumbs #tree { display: none; } +.layout-crumbs #checklist { + padding: 16px; +} .layout-keys { display: grid; grid-template-areas: "header" "keys"; @@ -557,22 +627,25 @@ header .menu { } #app.node { display: grid; - grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank"; + grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; /* blank */ color: #fff; min-height: 100%; } #app.node.toggle-tree { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; /* blank */ } #app.node.toggle-tree #tree { display: none; } +#app.node.toggle-tree #checklist { + padding: 16px; +} #profile-settings { color: #333; padding: 16px; @@ -588,14 +661,17 @@ header .menu { } @media only screen and (max-width: 932px) { #app.node { - grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank"; + grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr; + grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; /* blank */ } #app.node #tree { display: none; } + #app.node #checklist { + padding: 16px; + } #app.node.toggle-tree { display: grid; grid-template-areas: "header" "tree"; @@ -628,7 +704,9 @@ header .menu { padding: 16px; justify-self: start; } - #file-section { + #file-section, + #checklist, + #markdown { width: calc(100% - 32px); padding: 16px; margin-left: 16px; diff --git a/static/index.html b/static/index.html index 7ef717b..ac87bce 100644 --- a/static/index.html +++ b/static/index.html @@ -20,6 +20,7 @@ "node": "/js/{{ .VERSION }}/node.mjs", "key": "/js/{{ .VERSION }}/key.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs", + "checklist": "/js/{{ .VERSION }}/checklist.mjs", "ws": "/_js/{{ .VERSION }}/websocket.mjs" } } diff --git a/static/js/checklist.mjs b/static/js/checklist.mjs new file mode 100644 index 0000000..8ebba1a --- /dev/null +++ b/static/js/checklist.mjs @@ -0,0 +1,94 @@ +import { h, Component, createRef } from 'preact' +import htm from 'htm' +import { signal } from 'preact/signals' +const html = htm.bind(h) + +export class ChecklistGroup { + static sort(a, b) {//{{{ + if (a.Order < b.Order) return -1 + if (a.Order > b.Order) return 1 + return 0 + }//}}} + constructor(data) {//{{{ + Object.keys(data).forEach(key => { + if (key == 'Items') + this.items = data[key].map(itemData => + new ChecklistItem(itemData) + ).sort(ChecklistItem.sort) + else + this[key] = data[key] + }) + }//}}} +} + +export class ChecklistItem { + static sort(a, b) {//{{{ + if (a.Order < b.Order) return -1 + if (a.Order > b.Order) return 1 + return 0 + }//}}} + constructor(data) {//{{{ + Object.keys(data).forEach(key => { + this[key] = data[key] + }) + }//}}} +} + +export class Checklist extends Component { + render({ groups }) {//{{{ + if (groups.length == 0) + return + + groups.sort(ChecklistGroup.sort) + + let groupElements = groups.map(group => html`<${ChecklistGroupElement} group=${group} />`) + + return html` +
+

Checklist

+ ${groupElements} +
+ ` + }//}}} +} + +class ChecklistGroupElement extends Component { + render({ group }) {//{{{ + let items = group.items.map(item => html`<${ChecklistItemElement} item=${item} />`) + return html` +
${group.Label}
+ ${items} + ` + }//}}} +} + +class ChecklistItemElement extends Component { + constructor(props) {//{{{ + super(props) + this.state = { + checked: props.item.Checked, + } + this.checkbox = createRef() + }//}}} + render({ item }, { checked }) {//{{{ + return html` +
+ this.update(evt.target.checked)} /> + +
+ ` + }//}}} + update(checked) {//{{{ + this.setState({ checked }) + window._app.current.request('/node/checklist_item/state', { + ChecklistItemID: this.props.item.ID, + State: checked, + }) + .then(res => { + this.checkbox.current.classList.add('ok') + setTimeout(()=>this.checkbox.current.classList.remove('ok'), 500) + + }) + .catch(window._app.current.responseError) + }//}}} +} diff --git a/static/js/node.mjs b/static/js/node.mjs index 1c76682..32d879c 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -3,7 +3,7 @@ import htm from 'htm' import { signal } from 'preact/signals' import { Keys, Key } from 'key' import Crypto from 'crypto' -//import { marked } from 'marked' +import { Checklist, ChecklistGroup } from 'checklist' const html = htm.bind(h) export class NodeUI extends Component { @@ -65,12 +65,14 @@ export class NodeUI extends Component { 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} /> + <${Checklist} groups=${node.ChecklistGroups} /> <${NodeFiles} node=${this.node.value} /> ` } @@ -342,16 +344,6 @@ class NodeContent extends Component { let textarea = document.getElementById('node-content') if (textarea) textarea.parentNode.dataset.replicatedValue = textarea.value - - let crumbsEl = document.getElementById('crumbs') - let markdown = document.getElementById('markdown') - if (markdown) { - let margins = (crumbsEl.clientWidth - 900) / 2.0 - if (margins < 0) - margins = 0 - markdown.style.marginLeft = `${margins}px` - markdown.style.marginRight = `${margins}px` - } }//}}} unlock() {//{{{ let pass = prompt(`Password for "${this.props.model.description}"`) @@ -368,9 +360,9 @@ class NodeContent extends Component { } class MarkdownContent extends Component { - render({ content }) { + render({ content }) {//{{{ return html`
` - } + }//}}} componentDidMount() {//{{{ const markdown = document.getElementById('markdown') if (markdown) @@ -430,6 +422,7 @@ export class Node { this.Files = [] this._decrypted = false this._expanded = false // start value for the TreeNode component, + this.ChecklistGroups = {} // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} @@ -446,6 +439,7 @@ export class Node { this.Files = res.Node.Files this.Markdown = res.Node.Markdown this.RenderMarkdown.value = this.Markdown + this.initChecklist(res.Node.ChecklistGroups) callback(this) }) .catch(this.app.responseError) @@ -605,6 +599,13 @@ export class Node { this._decrypted = false return this._content }//}}} + initChecklist(checklistData) {//{{{ + if (checklistData === undefined || checklistData === null) + return + this.ChecklistGroups = checklistData.map(groupData=>{ + return new ChecklistGroup(groupData) + }) + }//}}} } class Menu extends Component { diff --git a/static/less/main.less b/static/less/main.less index 670bbfa..f64f0c6 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -23,13 +23,13 @@ html, body { } h1 { - margin-top: 0px; font-size: 1.5em; + color: @header_1; } h2 { - margin-top: 32px; font-size: 1.25em; + color: @header_1; } button { @@ -352,10 +352,17 @@ header { } #markdown { - padding: 16px; color: #333; grid-area: content; + + justify-self: center; + width: calc(100% - 32px); max-width: 900px; + padding: 0.5rem; + border-radius: 8px; + margin-top: 8px; + margin-bottom: 0px; + table { border-collapse: collapse; @@ -379,6 +386,83 @@ header { } } +#checklist { + grid-area: checklist; + color: #333; + + justify-self: center; + width: calc(100% - 32px); + max-width: 900px; + padding: 0.5rem; + border-radius: 8px; + margin-top: 8px; + margin-bottom: 0px; + + .checklist-group { + margin-top: 1em; + font-weight: bold; + + } + + .checklist-item { + display: grid; + grid-template-columns: min-content 1fr; + align-items: center; + margin-top: 0.50em; + + &.checked { + text-decoration: line-through; + color: #888; + } + + input[type="checkbox"] { + margin-left: 0px; + margin-right: 8px; + + -webkit-appearance: none; + appearance: none; + background-color: #fff; + margin: 0 8px 0 0; + font: inherit; + color: currentColor; + width: 1.25em; + height: 1.25em; + border: 0.15em solid currentColor; + border-radius: 0.15em; + transform: translateY(-0.075em); + + display: grid; + place-content: center; + } + + input[type="checkbox"].ok { + border: 0.15em solid #54b356; + } + + input[type="checkbox"].ok::before { + box-shadow: inset 1em 1em #54b356; + } + + + input[type="checkbox"]::before { + content: ""; + width: 0.70em; + height: 0.70em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em @checkbox_1; + } + + input[type="checkbox"]:checked::before { + transform: scale(1); + } + + label { + user-select: none; + } + } +} + /* ============================================================= * * Textarea replicates the height of an element expanding height * * ============================================================= */ @@ -555,6 +639,7 @@ header { "tree child-nodes" "tree name" "tree content" + "tree checklist" "tree files" "tree blank" ; @@ -565,6 +650,7 @@ header { min-content /* child-nodes */ min-content /* name */ min-content /* content */ + min-content /* checklist */ min-content /* files */ 1fr; /* blank */ color: #fff; @@ -597,6 +683,7 @@ header { "child-nodes" "name" "content" + "checklist" "files" "blank" ; @@ -607,9 +694,14 @@ header { min-content /* child-nodes */ min-content /* name */ min-content /* content */ + min-content /* checklist */ min-content /* files */ 1fr; /* blank */ #tree { display: none } + + #checklist { + padding: 16px; + } }// }}} .layout-keys { display: grid; @@ -681,7 +773,7 @@ header { justify-self: start; } - #file-section { + #file-section, #checklist, #markdown { width: calc(100% - 32px); padding: 16px; margin-left: 16px; diff --git a/static/less/theme.less b/static/less/theme.less index e6932f9..14b6041 100644 --- a/static/less/theme.less +++ b/static/less/theme.less @@ -4,6 +4,11 @@ @accent_2: #ecbf00; @accent_3: #c84a37; +@header_1: #518048; +@header_2: #518048; + +@checkbox_1: #666; + /* @theme_gradient: linear-gradient(to right, #009fff, #ec2f4b); @theme_gradient: linear-gradient(to right, #f5af19, #f12711);