Added checklists to database, rendering and toggling items

This commit is contained in:
Magnus Åhall 2024-01-10 23:19:40 +01:00
parent 5c27f9ed1c
commit f98a6ab863
9 changed files with 455 additions and 31 deletions

24
main.go
View File

@ -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")

111
node.go
View File

@ -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

18
sql/00014.sql Normal file
View File

@ -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
)

View File

@ -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;

View File

@ -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"
}
}

94
static/js/checklist.mjs Normal file
View File

@ -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`
<div id="checklist">
<h1>Checklist</h1>
${groupElements}
</div>
`
}//}}}
}
class ChecklistGroupElement extends Component {
render({ group }) {//{{{
let items = group.items.map(item => html`<${ChecklistItemElement} item=${item} />`)
return html`
<div class="checklist-group">${group.Label}</div>
${items}
`
}//}}}
}
class ChecklistItemElement extends Component {
constructor(props) {//{{{
super(props)
this.state = {
checked: props.item.Checked,
}
this.checkbox = createRef()
}//}}}
render({ item }, { checked }) {//{{{
return html`
<div class="checklist-item ${checked ? 'checked' : ''}">
<input type="checkbox" ref=${this.checkbox} key="checkbox-${item.ID}" id="checkbox-${item.ID}" checked=${checked} onchange=${evt => this.update(evt.target.checked)} />
<label for="checkbox-${item.ID}">${item.Label}</label>
</div>
`
}//}}}
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)
}//}}}
}

View File

@ -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`<img src="/images/${window._VERSION}/padlock-black.svg" style="height: 24px;" />`
page = html`
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
<div class="node-name">
${node.Name} ${padlock}
</div>
<${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`<div id="markdown"></div>`
}
}//}}}
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 {

View File

@ -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;

View File

@ -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);