diff --git a/datagraph b/datagraph index 0320ee8..30cb21a 100755 Binary files a/datagraph and b/datagraph differ diff --git a/node.go b/node.go index 065b4b8..2e064e5 100644 --- a/node.go +++ b/node.go @@ -165,3 +165,24 @@ func UpdateNode(nodeID int, data []byte) (err error) { _, err = db.Exec(`UPDATE public.node SET data=$2 WHERE id=$1`, nodeID, data) return } + +func CreateNode(parentNodeID, typeID int, name string) (err error) { + j, _ := json.Marshal(struct { Name string }{name}) + + row := db.QueryRow(` + INSERT INTO node(type_id, name, data) + VALUES($1, $2, $3::jsonb) + RETURNING id + `, + typeID, name, j) + + var id int + err = row.Scan(&id) + if err != nil { + return + } + + _, err = db.Exec(`INSERT INTO connection("parent", "child") VALUES($1, $2)`, parentNodeID, id) + + return +} diff --git a/static/css/main.css b/static/css/main.css index 0458aba..f00c2fc 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -21,14 +21,16 @@ body { [onClick] { cursor: pointer; } +.page { + display: none; +} +.page.show { + display: block; +} .section { background-color: #fff; padding: 32px; border-radius: 8px; - display: none; -} -.section.show { - display: block; } #layout { display: grid; @@ -43,7 +45,7 @@ body { grid-gap: 16px; align-items: center; } -#menu.section { +#menu.page { display: grid; padding: 16px 32px; } @@ -63,6 +65,8 @@ body { } #editor-node { grid-area: details; + display: grid; + grid-gap: 16px; } #types { grid-area: navigation; @@ -103,6 +107,12 @@ body { .node img { height: 24px; } +.node.selected > .name { + color: #a02c2c; +} +.node.selected > .type-icon { + filter: invert(0.7) sepia(0.5) hue-rotate(0deg) saturate(750%) brightness(0.85) !important; +} .node.expanded > .children { display: block; } @@ -123,7 +133,7 @@ body { padding-right: 4px; } .node .type-icon img { - filter: invert(0.7) sepia(0.5) hue-rotate(50deg) saturate(300%) brightness(0.85) !important; + filter: invert(0.7) sepia(0.5) hue-rotate(50deg) saturate(300%) brightness(0.85); } .node .name { margin-bottom: 8px; @@ -136,9 +146,33 @@ body { border-left: 1px solid #ccc; padding-left: 12px; margin-left: 19px; - /* - &.expanded { - display: block; - } - */ +} +select { + font-size: 1em; + border: 1px solid #bcc3ce; + background: #fff; + color: #444; + padding: 4px; +} +select optgroup { + color: #a22; +} +datalist div:before { + display: block; + content: 'group'; + font-weight: bold; +} +dialog#create-type { + min-width: 400px; +} +dialog#create-type > div { + display: grid; + grid-gap: 8px; +} +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.75); +} +select:focus { + outline: 2px solid #888; + outline-offset: -2px; } diff --git a/static/js/app.mjs b/static/js/app.mjs index 1b9080c..930ad14 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -7,27 +7,39 @@ export class App { this.editor = null this.typesList = null this.currentNodeID = null + this.types = [] + this.currentPage = null const events = [ 'MENU_ITEM_SELECTED', 'NODE_SELECTED', 'EDITOR_NODE_SAVE', 'TYPES_LIST_FETCHED', + 'NODE_CREATE_DIALOG', + 'NODE_CREATE', ] for (const eventName of events) mbus.subscribe(eventName, event => this.eventHandler(event)) + document.addEventListener('keydown', event => this.keyHandler(event)) + mbus.dispatch('MENU_ITEM_SELECTED', 'node') }// }}} - eventHandler(event) {// {{{ + async eventHandler(event) {// {{{ switch (event.type) { case 'MENU_ITEM_SELECTED': const item = document.querySelector(`#menu [data-section="${event.detail}"]`) - this.section(item, event.detail) + this.page(item, event.detail) break case 'NODE_SELECTED': + for (const n of document.querySelectorAll('#nodes .node.selected')) + n.classList.remove('selected') + + for (const n of document.querySelectorAll(`#nodes .node[data-node-id="${event.detail}"]`)) + n.classList?.add('selected') + this.currentNodeID = event.detail this.edit(this.currentNodeID) break @@ -40,20 +52,49 @@ export class App { const types = document.getElementById('types') types.replaceChildren(this.typesList.render()) + case 'NODE_CREATE_DIALOG': + if (this.currentPage !== 'node' || this.currentNodeID === null) + return + + new NodeCreateDialog(this.currentNodeID) + break + + case 'NODE_CREATE': break default: console.log(event) } }// }}} - section(item, name) {// {{{ + keyHandler(event) {// {{{ + let handled = true + + switch (event.key.toUpperCase()) { + case 'N': + if (!event.shiftKey || !event.altKey) + break + mbus.dispatch('NODE_CREATE_DIALOG') + break + + default: + handled = false + } + + if (handled) { + event.stopPropagation() + event.preventDefault() + } + }// }}} + page(item, name) {// {{{ for (const el of document.querySelectorAll('#menu .item')) el.classList.remove('selected') item.classList.add('selected') - for (const el of document.querySelectorAll('.section.show')) + for (const el of document.querySelectorAll('.page.show')) el.classList.remove('show') + this.currentPage = name + switch (name) { case 'node': document.getElementById('nodes').classList.add('show') @@ -112,13 +153,126 @@ export class App { const timePassed = Date.now() - buttonPressed if (timePassed < 250) - setTimeout(()=>btn.disabled = false, 250 - timePassed) + setTimeout(() => btn.disabled = false, 250 - timePassed) else btn.disabled = false }) }// }}} } +class NodeCreateDialog { + constructor(parentNodeID) {// {{{ + this.parentNodeID = parentNodeID + this.dialog = null + this.types = null + this.select = null + this.input = null + + this.createElements() + + this.fetchTypes() + .then(() => { + const st = new SelectType(this.types) + this.select.replaceChildren(st.render()) + }) + + this.dialog.showModal() + this.select.focus() + }// }}} + createElements() {// {{{ + this.dialog = document.createElement('dialog') + this.dialog.id = 'create-type' + this.dialog.innerHTML = ` +
+ + + +
+ ` + + this.select = this.dialog.querySelector('select') + this.input = this.dialog.querySelector('input') + this.input.addEventListener('keydown', event => { + if (event.key === 'Enter') + this.commit() + }) + + document.body.appendChild(this.dialog) + }// }}} + commit() {// {{{ + if (this.input.value.trim().length === 0) { + alert('Give a name.') + return + } + + const req = { + ParentNodeID: this.parentNodeID, + TypeID: parseInt(this.select.value), + Name: this.input.value.trim(), + } + + fetch('/nodes/create', { + method: 'POST', + body: JSON.stringify(req), + }) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + this.dialog.close() + }) + .catch(err => showError(err)) + }// }}} + async fetchTypes() {// {{{ + return new Promise((resolve, reject) => { + fetch('/types/') + .then(data => data.json()) + .then(json => { + if (!json.OK) { + showError(json.Error) + return + } + this.types = json.Types + resolve() + }) + .catch(err => reject(err)) + }) + }// }}} +} + + +class SelectType { + constructor(types) {// {{{ + this.types = types + }// }}} + render() {// {{{ + const tmpl = document.createElement('template') + + this.types.sort(typeSort) + let prevGroup = null + for (const t of this.types) { + if (t.Name == 'root_node') + continue + + if (t.Schema['x-group'] != prevGroup) { + prevGroup = t.Schema['x-group'] + const group = document.createElement('optgroup') + group.setAttribute('label', t.Schema['x-group']) + tmpl.content.appendChild(group) + } + + const opt = document.createElement('option') + opt.setAttribute('value', t.ID) + opt.innerText = t.Schema.title || t.Name + tmpl.content.appendChild(opt) + } + + return tmpl.content + }// }}} +} + export class TreeNode { constructor(parent, data) {// {{{ this.data = data @@ -131,7 +285,7 @@ export class TreeNode { render() {// {{{ const nodeHTML = ` -
+
${this.name()}
@@ -246,21 +400,7 @@ export class TypesList { render() {// {{{ const div = document.createElement('div') - this.types.sort((a,b)=> { - if (a.Schema['x-group'] === undefined) - a.Schema['x-group'] = 'No group' - - if (b.Schema['x-group'] === undefined) - b.Schema['x-group'] = 'No group' - - if (a.Schema['x-group'] < b.Schema['x-group']) return -1 - if (a.Schema['x-group'] > b.Schema['x-group']) return 1 - - if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 - if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 - - return 0 - }) + this.types.sort(typeSort) let prevGroup = null @@ -290,4 +430,20 @@ export class TypesList { }// }}} } +function typeSort(a, b) {// {{{ + if (a.Schema['x-group'] === undefined) + a.Schema['x-group'] = 'No group' + + if (b.Schema['x-group'] === undefined) + b.Schema['x-group'] = 'No group' + + if (a.Schema['x-group'] < b.Schema['x-group']) return -1 + if (a.Schema['x-group'] > b.Schema['x-group']) return 1 + + if ((a.Schema.title || a.Name) < (b.Schema.title || b.Name)) return -1 + if ((a.Schema.title || a.Name) > (b.Schema.title || b.Name)) return 1 + + return 0 +}// }}} + // vim: foldmethod=marker diff --git a/static/less/main.less b/static/less/main.less index 57f464d..c3e195d 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -26,10 +26,7 @@ body { cursor: pointer; } -.section { - background-color: #fff; - padding: 32px; - border-radius: 8px; +.page { display: none; &.show { @@ -37,6 +34,12 @@ body { } } +.section { + background-color: #fff; + padding: 32px; + border-radius: 8px; +} + #layout { display: grid; grid-template-areas: @@ -54,7 +57,7 @@ body { grid-gap: 16px; align-items: center; - &.section { + &.page { display: grid; padding: 16px 32px; } @@ -83,6 +86,8 @@ body { #editor-node { grid-area: details; + display: grid; + grid-gap: 16px; } #types { @@ -134,6 +139,16 @@ body { height: 24px; } + &.selected { + & > .name { + color: #a02c2c; + } + + & > .type-icon { + filter: invert(.7) sepia(.5) hue-rotate(0deg) saturate(750%) brightness(0.85) !important; + } + } + &.expanded { &>.children { display: block; @@ -160,7 +175,7 @@ body { padding-right: 4px; img { - filter: invert(.7) sepia(.5) hue-rotate(50deg) saturate(300%) brightness(0.85) !important; + filter: invert(.7) sepia(.5) hue-rotate(50deg) saturate(300%) brightness(0.85); } } @@ -176,11 +191,45 @@ body { border-left: 1px solid #ccc; padding-left: 12px; margin-left: 19px; - - /* - &.expanded { - display: block; - } - */ } } + +select { + font-size: 1em; + border: 1px solid #bcc3ce; + background: #fff; + color: #444; + padding: 4px; + + optgroup { + color: #a22; + } + +} + + +datalist { + div:before { + display: block; + content: 'group'; + font-weight: bold; + } +} + +dialog#create-type { + min-width: 400px; + + & > div { + display: grid; + grid-gap: 8px; + } +} + +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.75); +} + +select:focus { + outline: 2px solid #888; + outline-offset: -2px; +} diff --git a/views/pages/app.gotmpl b/views/pages/app.gotmpl index 06ee78a..78a40e4 100644 --- a/views/pages/app.gotmpl +++ b/views/pages/app.gotmpl @@ -31,22 +31,33 @@
-