Saving of nodes

This commit is contained in:
Magnus Åhall 2025-07-03 13:25:08 +02:00
parent c5bec0afa6
commit 08fd2cf4e9
16 changed files with 852 additions and 42 deletions

BIN
datagraph

Binary file not shown.

55
node.go
View file

@ -5,6 +5,7 @@ import (
"github.com/jmoiron/sqlx"
// Standard
"encoding/json"
"time"
)
@ -14,19 +15,56 @@ type Node struct {
ParentID int `db:"parent_id"`
TypeID int `db:"type_id"`
TypeName string `db:"type_name"`
TypeIcon string `db:"type_icon"`
TypeID int `db:"type_id"`
TypeName string `db:"type_name"`
TypeSchema any `db:"type_schema"`
TypeSchemaRaw []byte `db:"type_schema_raw" json:"-"`
TypeIcon string `db:"type_icon"`
Updated time.Time
Data any
RawData []byte `db:"raw_data"`
DataRaw []byte `db:"data_raw" json:"-"`
NumChildren int `db:"num_children"`
Children []*Node
}
func GetNode(startNodeID, maxDepth int) (topNode *Node, err error) {
func GetNode(nodeID int) (node Node, err error) {
row := db.QueryRowx(`
SELECT
n.id,
n.name,
n.updated,
n.data AS data_raw,
t.id AS type_id,
t.name AS type_name,
t.schema AS type_schema_raw,
t.schema->>'icon' AS type_icon
FROM public.node n
INNER JOIN public.type t ON n.type_id = t.id
WHERE
n.id = $1
`, nodeID)
err = row.StructScan(&node)
if err != nil {
return
}
err = json.Unmarshal(node.TypeSchemaRaw, &node.TypeSchema)
if err != nil {
return
}
err = json.Unmarshal(node.DataRaw, &node.Data)
if err != nil {
return
}
return
}
func GetNodeTree(startNodeID, maxDepth int) (topNode *Node, err error) {
nodes := make(map[int]*Node)
var rows *sqlx.Rows
rows, err = GetNodeRows(startNodeID, maxDepth)
@ -85,7 +123,7 @@ func GetNodeRows(startNodeID, maxDepth int) (rows *sqlx.Rows, err error) {
t.name AS type_name,
COALESCE(t.schema->>'icon', '') AS type_icon,
n.updated,
n.data AS raw_data,
n.data AS data_raw,
COUNT(c.child) AS num_children
FROM nodes ns
INNER JOIN public.node n ON ns.id = n.id
@ -122,3 +160,8 @@ func ComposeTree(nodes map[int]*Node, node *Node) {
nodes[node.ID] = node
}
func UpdateNode(nodeID int, data []byte) (err error) {
_, err = db.Exec(`UPDATE public.node SET data=$2 WHERE id=$1`, nodeID, data)
return
}

View file

@ -8,7 +8,7 @@ html {
body {
margin: 0px;
padding: 0px;
background-color: #333;
background-color: #444 !important;
}
*,
*:before,
@ -21,13 +21,80 @@ body {
[onClick] {
cursor: pointer;
}
#nodes {
.section {
background-color: #fff;
padding: 32px;
border-radius: 8px;
margin: 32px;
display: none;
}
.section.show {
display: block;
}
#layout {
display: grid;
grid-template-areas: "menu menu" "navigation details";
grid-template-columns: min-content 1fr;
grid-gap: 32px;
padding: 32px;
}
#menu {
grid-area: menu;
grid-template-columns: repeat(100, min-content);
grid-gap: 16px;
align-items: center;
}
#menu.section {
display: grid;
padding: 16px 32px;
}
#menu .item {
cursor: pointer;
}
#menu .item.selected {
font-weight: bold;
}
#logo img {
height: 96px;
margin-right: 32px;
}
#nodes {
grid-area: navigation;
width: min-content;
}
#editor-node {
grid-area: details;
}
#types {
grid-area: navigation;
}
#types .group {
font-weight: bold;
white-space: nowrap;
margin-top: 32px;
margin-bottom: 8px;
}
#types .group:first-child {
margin-top: 0px;
}
#types .type {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 8px;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
}
#types .type .img img {
height: 24px;
display: inline;
}
#types .type .title {
white-space: nowrap;
line-height: 24px;
}
#editor-type-schema {
grid-area: details;
}
.node {
display: grid;
grid-template-columns: min-content min-content 100%;
@ -44,7 +111,7 @@ body {
padding-right: 8px;
}
.node .expand-status.leaf {
width: 36px;
width: 40px;
}
.node .expand-status.leaf img {
display: none;
@ -52,15 +119,16 @@ body {
.node .expand-status img {
cursor: pointer;
}
.node .icon {
padding-right: 8px;
.node .type-icon {
padding-right: 4px;
}
.node .icon img {
filter: invert(0.7) sepia(0.5) hue-rotate(50deg) saturate(300%) brightness(0.85);
.node .type-icon img {
filter: invert(0.7) sepia(0.5) hue-rotate(50deg) saturate(300%) brightness(0.85) !important;
}
.node .name {
margin-bottom: 8px;
line-height: 24px;
cursor: pointer;
}
.node .children {
display: none;

1
static/css/spectre-exp.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
static/css/spectre-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
static/css/spectre.min.css vendored Normal file

File diff suppressed because one or more lines are too long

142
static/images/logo.svg Normal file
View file

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="79.624962mm"
height="78.647499mm"
viewBox="0 0 79.624962 78.647499"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.60258984"
inkscape:cx="66.380144"
inkscape:cy="193.33217"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-38.185371,-67.683764)"><path
d="m 51.45301,114.65074 v -1.98318 h 4.210436 q 1.55603,0 2.379811,-0.82378 0.823781,-0.82378 0.823781,-2.50185 V 98.632775 h -3.081551 v -1.983178 h 4.881664 v 12.051613 q 0,5.94953 -5.247789,5.94953 z M 72.535691,98.632775 h -4.942685 q -1.311947,0 -2.074707,0.67123 -0.76276,0.67123 -0.76276,2.135735 0,1.58654 0.610208,2.3493 0.610208,0.73225 1.708582,0.73225 h 1.861134 q 2.379812,0 3.417165,1.34245 1.037354,1.34246 1.037354,3.90533 0,2.41033 -1.342458,3.66125 -1.311947,1.22042 -3.874821,1.22042 h -5.308809 v -1.98318 h 5.827486 q 1.281437,0 1.983176,-0.70174 0.73225,-0.70174 0.73225,-2.16624 0,-1.55603 -0.579698,-2.41032 -0.579697,-0.8848 -1.861134,-0.8848 h -2.013687 q -2.166238,0 -3.142571,-1.25092 -0.976333,-1.28144 -0.976333,-3.84432 0,-2.349305 1.281437,-3.539205 1.281437,-1.220418 3.966352,-1.220418 h 4.454519 z m 10.922694,-1.983178 q 1.372968,0 2.074708,0.671228 0.732249,0.67123 0.732249,2.22726 v 12.021105 q 0,3.08155 -2.959509,3.08155 H 78.45468 q -2.867978,0 -2.867978,-2.86798 V 99.548085 q 0,-2.898488 2.837467,-2.898488 z m -4.362987,1.983178 q -1.617051,0 -1.617051,1.464505 v 10.83119 q 0,0.91531 0.427145,1.34246 0.427146,0.39663 1.006844,0.39663 h 4.088393 q 0.549187,0 0.945823,-0.51867 0.427145,-0.51868 0.427145,-1.22042 v -10.83119 q 0,-0.793275 -0.427145,-1.128895 -0.427146,-0.33561 -1.159396,-0.33561 z M 98.835602,114.65074 H 96.089666 L 90.628304,100.4634 v 14.18734 H 88.858701 V 96.649597 h 2.257769 l 5.919018,15.377243 V 96.649597 h 1.800114 z"
id="text1"
style="font-size:30.5104px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';text-align:center;text-anchor:middle;fill:#262626;stroke-width:1.71737"
aria-label="JSON" /><circle
style="fill:#27a698;fill-opacity:1;stroke-width:0.477313;-inkscape-stroke:none"
id="path1"
cx="46.627323"
cy="90.933174"
r="4.6399903" /><circle
style="fill:#1e6380;fill-opacity:1;stroke-width:0.452723;-inkscape-stroke:none"
id="circle1"
cx="96.612923"
cy="72.084717"
r="4.4009533" /><circle
style="fill:#21818c;fill-opacity:1;stroke-width:0.529167;-inkscape-stroke:none"
id="circle2"
cx="43.329437"
cy="121.67689"
r="5.1440659" /><circle
style="fill:#2988af;fill-opacity:1;stroke-width:0.355205;-inkscape-stroke:none"
id="circle3"
cx="78.708733"
cy="125.57096"
r="3.4529741" /><circle
style="fill:#1e4c80;fill-opacity:1;stroke-width:0.70227;-inkscape-stroke:none"
id="circle4"
cx="102.07027"
cy="124.97508"
r="6.8268108" /><text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:30.5104px;line-height:normal;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:center;text-decoration-color:#000000;text-anchor:middle;fill:#262626;stroke-width:1.71737;-inkscape-stroke:none"
x="73.981911"
y="186.11093"
id="text4"><tspan
sodipodi:role="line"
id="tspan4"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:30.5104px;font-family:'Forgotten Futurist';-inkscape-font-specification:'Forgotten Futurist, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;stroke-width:1.71737"
x="73.981911"
y="186.11093">JSON</tspan></text><circle
style="fill:#1e8071;fill-opacity:1;stroke-width:0.529167;-inkscape-stroke:none"
id="circle5"
cx="80.797234"
cy="85.280693"
r="5.1440659" /><circle
style="fill:#248d9a;fill-opacity:1;stroke-width:0.529167;-inkscape-stroke:none"
id="circle6"
cx="112.66627"
cy="94.749619"
r="5.1440659" /><path
style="fill:none;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 51.205022,90.175919 75.722226,86.120215"
id="path6"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#path1"
inkscape:connection-end="#circle5" /><path
style="fill:none;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="M 84.746953,81.985206 93.23378,74.904138"
id="path7"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#circle5"
inkscape:connection-end="#circle1" /><path
style="fill:#1e6380;fill-opacity:1;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 85.728154,86.745768 22.007196,6.538776"
id="path8"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#circle5"
inkscape:connection-end="#circle6" /><path
style="display:inline;fill:#1e6380;fill-opacity:1;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 48.4426,122.23968 26.833902,2.95351"
id="path9"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#circle2"
inkscape:connection-end="#circle3" /><path
style="fill:none;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 82.160565,125.48292 13.085148,-0.33376"
id="path10"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-end="#circle4"
inkscape:connection-start="#circle3" /><circle
style="fill:#267ea2;fill-opacity:1;stroke-width:0.653982;-inkscape-stroke:none"
id="circle10"
cx="62.795177"
cy="139.97386"
r="6.3574047" /><path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#1e6380;fill-opacity:1;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1"
d="m 67.508676,135.70781 8.639957,-7.81978"
id="path11"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#circle10"
inkscape:connection-end="#circle3" /><path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#1e6380;fill-opacity:1;fill-rule:evenodd;stroke:#205d7b;stroke-width:0.529167;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1"
d="M 99.156616,75.676028 109.69306,90.551905"
id="path12"
inkscape:connector-type="polyline"
inkscape:connector-curvature="0"
inkscape:connection-start="#circle1"
inkscape:connection-end="#circle6" /></g></svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -1,3 +1,124 @@
import { Editor } from '@editor'
import { MessageBus } from '@mbus'
export class App {
constructor() {// {{{
window.mbus = new MessageBus()
this.editor = null
this.typesList = null
this.currentNodeID = null
const events = [
'MENU_ITEM_SELECTED',
'NODE_SELECTED',
'EDITOR_NODE_SAVE',
'TYPES_LIST_FETCHED',
]
for (const eventName of events)
mbus.subscribe(eventName, event => this.eventHandler(event))
mbus.dispatch('MENU_ITEM_SELECTED', 'node')
}// }}}
eventHandler(event) {// {{{
switch (event.type) {
case 'MENU_ITEM_SELECTED':
const item = document.querySelector(`#menu [data-section="${event.detail}"]`)
this.section(item, event.detail)
break
case 'NODE_SELECTED':
this.currentNodeID = event.detail
this.edit(this.currentNodeID)
break
case 'EDITOR_NODE_SAVE':
this.nodeUpdate()
break
case 'TYPES_LIST_FETCHED':
const types = document.getElementById('types')
types.replaceChildren(this.typesList.render())
break
default:
console.log(event)
}
}// }}}
section(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'))
el.classList.remove('show')
switch (name) {
case 'node':
document.getElementById('nodes').classList.add('show')
document.getElementById('editor-node').classList.add('show')
break
case 'type':
document.getElementById('types').classList.add('show')
document.getElementById('editor-type-schema').classList.add('show')
if (this.typesList === null)
this.typesList = new TypesList()
this.typesList.fetchTypes().then(() => {
mbus.dispatch('TYPES_LIST_FETCHED')
})
break
}
}// }}}
edit(nodeID) {// {{{
console.log(nodeID)
fetch(`/nodes/${nodeID}`)
.then(data => data.json())
.then(json => {
if (!json.OK) {
showError(json.Error)
return
}
const editorEl = document.querySelector('#editor-node .editor')
this.editor = new Editor(json.Node.TypeSchema)
editorEl.replaceChildren(this.editor.render(json.Node.Data))
})
}// }}}
nodeUpdate() {// {{{
if (this.editor === null)
return
const btn = document.querySelector('#editor-node .controls button')
btn.disabled = true
const buttonPressed = Date.now()
const nodeData = this.editor.data()
fetch(`/nodes/update/${this.currentNodeID}`, {
method: 'POST',
body: JSON.stringify(nodeData),
})
.then(data => data.json())
.then(json => {
if (!json.OK) {
showError(json.Error)
return
}
const timePassed = Date.now() - buttonPressed
if (timePassed < 250)
setTimeout(()=>btn.disabled = false, 250 - timePassed)
else
btn.disabled = false
})
}// }}}
}
export class TreeNode {
constructor(parent, data) {// {{{
this.data = data
@ -12,16 +133,18 @@ export class TreeNode {
const nodeHTML = `
<div class="node">
<div class="expand-status"><img /></div>
<div class="icon"><img /></div>
<div class="type-icon"><img /></div>
<div class="name">${this.name()}</div>
<div class="children"></div>
</div>
`
this.name()
const tmpl = document.createElement('template')
tmpl.innerHTML = nodeHTML
this.children = tmpl.content.querySelector('.children')
tmpl.content.querySelector('.name').addEventListener('click', () => mbus.dispatch('NODE_SELECTED', this.data.ID))
// data.NumChildren is set regardless of having fetched the children or not.
if (this.hasChildren()) {
const img = tmpl.content.querySelector('.expand-status img')
@ -31,7 +154,7 @@ export class TreeNode {
tmpl.content.querySelector('.expand-status').classList.add('leaf')
if (this.data.TypeIcon) {
const img = tmpl.content.querySelector('.icon img')
const img = tmpl.content.querySelector('.type-icon img')
img.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/${this.data.TypeIcon}.svg`)
}
@ -51,7 +174,6 @@ export class TreeNode {
}// }}}
sortChildren() {// {{{
this.data.Children.sort((a, b) => {
console.log(a.Name, b.Name)
if (a.TypeName < b.TypeName) return -1
if (a.TypeName > b.TypeName) return 1
@ -91,10 +213,81 @@ export class TreeNode {
return new Promise((resolve, reject) => {
fetch(`/nodes/tree/${this.data.ID}?depth=2`)
.then(data => data.json())
.then(json => resolve(json))
.then(json => {
if (json.OK)
resolve(json.Nodes)
else
reject(json.Error)
})
.catch(err => reject(err))
})
}// }}}
}
export class TypesList {
constructur() {// {{{
this.types = []
}// }}}
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))
})
}// }}}
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
})
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('div')
group.classList.add('group')
group.innerText = t.Schema['x-group']
div.appendChild(group)
}
const tDiv = document.createElement('div')
tDiv.classList.add('type')
tDiv.innerHTML = `
<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/file-document-check-outline.svg" /></div>
<div class="title">${t.Schema.title || t.Name}</div>
`
div.appendChild(tDiv)
}
return div
}// }}}
}
// vim: foldmethod=marker

27
static/js/editor.mjs Normal file
View file

@ -0,0 +1,27 @@
export class Editor {
constructor(schema) {
this.schema = schema
this.editor = null
}
render(data) {
const div = document.createElement('div')
this.editor = new JSONEditor(div, {
theme: 'spectre',
iconlib: 'spectre',
disable_collapse: true,
disable_properties: true,
schema: this.schema,
});
this.editor.on('ready', () => {
this.editor.setValue(data)
})
return div
}
data() {
return this.editor.getValue()
}
}

File diff suppressed because one or more lines are too long

17
static/js/mbus.mjs Normal file
View file

@ -0,0 +1,17 @@
export class MessageBus {
constructor() {
this.bus = new EventTarget()
}
subscribe(eventName, fn) {
this.bus.addEventListener(eventName, fn)
}
unsubscribe(eventName, fn) {
this.bus.removeEventListener(eventName, fn)
}
dispatch(eventName, data) {
this.bus.dispatchEvent(new CustomEvent(eventName, { detail: data }))
}
}

View file

@ -9,7 +9,7 @@ html {
body {
margin: 0px;
padding: 0px;
background-color: #333;
background-color: #444 !important;
}
*,
@ -26,14 +26,105 @@ body {
cursor: pointer;
}
#nodes {
.section {
background-color: #fff;
padding: 32px;
border-radius: 8px;
margin: 32px;
display: none;
&.show {
display: block;
}
}
#layout {
display: grid;
grid-template-areas:
"menu menu"
"navigation details"
;
grid-template-columns: min-content 1fr;
grid-gap: 32px;
padding: 32px;
}
#menu {
grid-area: menu;
grid-template-columns: repeat(100, min-content);
grid-gap: 16px;
align-items: center;
&.section {
display: grid;
padding: 16px 32px;
}
.item {
cursor: pointer;
&.selected {
font-weight: bold;
}
}
}
#logo {
img {
height: 96px;
margin-right: 32px;
}
}
#nodes {
grid-area: navigation;
width: min-content;
}
#editor-node {
grid-area: details;
}
#types {
grid-area: navigation;
.group {
font-weight: bold;
white-space: nowrap;
margin-top: 32px;
margin-bottom: 8px;
&:first-child {
margin-top: 0px;
}
}
.type {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 8px;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
.img {
img {
height: 24px;
display: inline;
}
}
.title {
white-space: nowrap;
line-height: 24px;
}
}
}
#editor-type-schema {
grid-area: details;
}
.node {
display: grid;
grid-template-columns: min-content min-content 100%;
@ -54,7 +145,7 @@ body {
padding-right: 8px;
&.leaf {
width: 36px;
width: 40px;
img {
display: none;
}
@ -65,17 +156,18 @@ body {
}
}
.icon {
padding-right: 8px;
.type-icon {
padding-right: 4px;
img {
filter: invert(.7) sepia(.5) hue-rotate(50deg) saturate(300%) brightness(0.85);
filter: invert(.7) sepia(.5) hue-rotate(50deg) saturate(300%) brightness(0.85) !important;
}
}
.name {
margin-bottom: 8px;
line-height: 24px;
cursor: pointer;
}
.children {

76
type.go Normal file
View file

@ -0,0 +1,76 @@
package main
import (
// External
"github.com/jmoiron/sqlx"
// Standard
"encoding/json"
"time"
)
type NodeType struct {
ID int
Name string
SchemaRaw []byte `db:"schema_raw" json:"-"`
Schema any
Updated time.Time
}
func GetType(typeID int) (typ NodeType, err error) {
row := db.QueryRowx(`
SELECT
id,
name,
schema AS schema_raw,
updated
FROM public.type
WHERE
id = $1
`, typeID)
err = row.StructScan(&typ)
if err != nil {
return
}
err = json.Unmarshal(typ.SchemaRaw, &typ.Schema)
return
}
func GetTypes() (types []NodeType, err error) {
types = []NodeType{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
SELECT
id,
name,
updated,
schema AS schema_raw
FROM
public.type
ORDER BY
name ASC
`)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var typ NodeType
err = rows.StructScan(&typ)
if err != nil {
return
}
err = json.Unmarshal(typ.SchemaRaw, &typ.Schema)
if err != nil {
return
}
types = append(types, typ)
}
return
}

View file

@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/main.css">
<script type="text/javascript">
function showError(err) {
console.error(err)
alert(err)
}
</script>
</head>
<body>
<div id="app">{{ block "page" . }}{{ end }}</div>

View file

@ -1,16 +1,56 @@
{{ define "page" }}
<script type="module" defer>
import { TreeNode } from '/js/{{ .VERSION }}/app.mjs'
window._VERSION = '{{ .VERSION }}'
<script type="importmap">
{
"imports": {
"@editor": "/js/{{ .VERSION }}/editor.mjs",
"@mbus": "/js/{{ .VERSION }}/mbus.mjs"
}
}
</script>
fetch('/nodes/tree/0?depth=2')
.then(data => data.json())
.then(json => {
const top = document.getElementById('nodes')
const topNode = new TreeNode(top, json)
topNode.render()
})
<script src="/js/{{ .VERSION }}/lib/jsoneditor.js"></script>
<!--<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script>-->
<script type="module" defer>
import {App, TreeNode} from '/js/{{ .VERSION }}/app.mjs'
window._VERSION = '{{ .VERSION }}'
window._app = new App()
fetch('/nodes/tree/0?depth=2')
.then(data => data.json())
.then(json => {
if (!json.OK) {
showError(json.Error)
return
}
const top = document.getElementById('nodes')
const topNode = new TreeNode(top, json.Nodes)
topNode.render()
})
</script>
<div id="nodes"></div>
<div id="layout">
<div class="section" id="menu">
<div class="item" id="logo"><img src="/images/{{ .VERSION }}/logo.svg" /></div>
<div class="item" data-section='node' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'node')">Nodes</div>
<div class="item" data-section='type' onclick="mbus.dispatch('MENU_ITEM_SELECTED', 'type')">Types</div>
</div>
<div class="section" id="nodes"></div>
<div class="section" id="editor-node">
<div class="editor"></div>
<div class="controls">
<button onclick="mbus.dispatch('EDITOR_NODE_SAVE')">Save</button>
</div>
</div>
<div class="section" id="types"></div>
<div class="section" id="editor-type-schema"></div>
</div>
<link rel="stylesheet" href="/css/{{ .VERSION }}/spectre.min.css">
<link rel="stylesheet" href="/css/{{ .VERSION }}/spectre-exp.min.css">
<link rel="stylesheet" href="/css/{{ .VERSION }}/spectre-icons.min.css">
{{ end }}

View file

@ -7,6 +7,7 @@ import (
// Standard
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"strconv"
@ -31,11 +32,27 @@ func initWebserver() (err error) {
http.HandleFunc("/", pageIndex)
http.HandleFunc("/app", pageApp)
http.HandleFunc("/nodes/tree/{startNode}", actionNodesTree)
http.HandleFunc("/nodes/{nodeID}", actionNode)
http.HandleFunc("/nodes/update/{nodeID}", actionNodeUpdate)
http.HandleFunc("/types/{typeID}", actionType)
http.HandleFunc("/types/", actionTypesAll)
err = http.ListenAndServe(address, nil)
return
}
func httpError(w http.ResponseWriter, err error) { // {{{
out := struct {
OK bool
Error string
}{
false,
err.Error(),
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
if r.URL.Path == "/" {
http.Redirect(w, r, "/app", http.StatusSeeOther)
@ -43,7 +60,6 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
engine.StaticResource(w, r)
}
} // }}}
func pageApp(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("app")
err := engine.Render(page, w, r)
@ -64,11 +80,96 @@ func actionNodesTree(w http.ResponseWriter, r *http.Request) { // {{{
maxDepth = 3
}
topNode, err := GetNode(startNode, maxDepth)
topNode, err := GetNodeTree(startNode, maxDepth)
if err != nil {
logger.Error("test", "error", err)
httpError(w, err)
return
}
j, _ := json.Marshal(topNode)
out := struct {
OK bool
Nodes *Node
}{
true,
topNode,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionNode(w http.ResponseWriter, r *http.Request) { // {{{
nodeID := 0
nodeIDStr := r.PathValue("nodeID")
nodeID, _ = strconv.Atoi(nodeIDStr)
node, err := GetNode(nodeID)
if err != nil {
httpError(w, err)
return
}
out := struct {
OK bool
Node Node
}{
true,
node,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionNodeUpdate(w http.ResponseWriter, r *http.Request) { // {{{
nodeID := 0
nodeIDStr := r.PathValue("nodeID")
nodeID, _ = strconv.Atoi(nodeIDStr)
data, _ := io.ReadAll(r.Body)
err := UpdateNode(nodeID, data)
if err != nil {
httpError(w, err)
return
}
out := struct {
OK bool
}{
true,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
func actionType(w http.ResponseWriter, r *http.Request) { // {{{
typeID := 0
typeIDStr := r.PathValue("typeID")
typeID, _ = strconv.Atoi(typeIDStr)
typ, err := GetType(typeID)
if err != nil {
httpError(w, err)
return
}
j, _ := json.Marshal(typ)
w.Write(j)
} // }}}
func actionTypesAll(w http.ResponseWriter, r *http.Request) { // {{{
types, err := GetTypes()
if err != nil {
httpError(w, err)
return
}
out := struct {
OK bool
Types []NodeType
}{
true,
types,
}
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
// vim: foldmethod=marker