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` +