Wip
This commit is contained in:
parent
a76c093d44
commit
c255b58335
@ -21,6 +21,10 @@ type Config struct {
|
|||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Session struct {
|
||||||
|
DaysValid int
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConfigRead(filename string) (config Config, err error) {
|
func ConfigRead(filename string) (config Config, err error) {
|
||||||
|
157
main.go
157
main.go
@ -11,9 +11,10 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v0.0.1";
|
const VERSION = "v0.0.3";
|
||||||
const LISTEN_HOST = "0.0.0.0";
|
const LISTEN_HOST = "0.0.0.0";
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -33,6 +34,10 @@ func init() {// {{{
|
|||||||
)
|
)
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if false {
|
||||||
|
time.Sleep(time.Second*1)
|
||||||
|
}
|
||||||
}// }}}
|
}// }}}
|
||||||
func main() {// {{{
|
func main() {// {{{
|
||||||
var err error
|
var err error
|
||||||
@ -53,6 +58,7 @@ func main() {// {{{
|
|||||||
http.HandleFunc("/session/create", sessionCreate)
|
http.HandleFunc("/session/create", sessionCreate)
|
||||||
http.HandleFunc("/session/retrieve", sessionRetrieve)
|
http.HandleFunc("/session/retrieve", sessionRetrieve)
|
||||||
http.HandleFunc("/session/authenticate", sessionAuthenticate)
|
http.HandleFunc("/session/authenticate", sessionAuthenticate)
|
||||||
|
http.HandleFunc("/node/retrieve", nodeRetrieve)
|
||||||
http.HandleFunc("/ws", websocketHandler)
|
http.HandleFunc("/ws", websocketHandler)
|
||||||
http.HandleFunc("/", staticHandler)
|
http.HandleFunc("/", staticHandler)
|
||||||
|
|
||||||
@ -66,66 +72,6 @@ func cssUpdateHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
|||||||
log.Println("[BROADCAST] CSS updated")
|
log.Println("[BROADCAST] CSS updated")
|
||||||
connectionManager.Broadcast(struct{ Ok bool; ID string; Op string }{ Ok: true, Op: "css_reload" })
|
connectionManager.Broadcast(struct{ Ok bool; ID string; Op string }{ Ok: true, Op: "css_reload" })
|
||||||
}// }}}
|
}// }}}
|
||||||
func sessionCreate(w http.ResponseWriter, r *http.Request) {// {{{
|
|
||||||
session, err := NewSession()
|
|
||||||
if err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
responseData(w, map[string]interface{}{
|
|
||||||
"OK": true,
|
|
||||||
"Session": session,
|
|
||||||
})
|
|
||||||
}// }}}
|
|
||||||
func sessionRetrieve(w http.ResponseWriter, r *http.Request) {// {{{
|
|
||||||
var err error
|
|
||||||
var uuid string
|
|
||||||
|
|
||||||
if uuid, err = sessionUUID(r); err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := Session{ UUID: uuid }
|
|
||||||
if err = session.Retrieve(); err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
responseData(w, map[string]interface{}{
|
|
||||||
"OK": true,
|
|
||||||
"Session": session,
|
|
||||||
})
|
|
||||||
}// }}}
|
|
||||||
func sessionAuthenticate(w http.ResponseWriter, r *http.Request) {// {{{
|
|
||||||
var err error
|
|
||||||
var uuid string
|
|
||||||
|
|
||||||
if uuid, err = sessionUUID(r); err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := Session{
|
|
||||||
UUID: uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
req := struct{ Username string; Password string }{}
|
|
||||||
if err = request(r, &req); err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = session.Authenticate(req.Username, req.Password); err != nil {
|
|
||||||
responseError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
responseData(w, map[string]interface{}{
|
|
||||||
"OK": true,
|
|
||||||
"Session": session,
|
|
||||||
})
|
|
||||||
}// }}}
|
|
||||||
func websocketHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
func websocketHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@ -145,6 +91,7 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
|||||||
// URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version.
|
// URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version.
|
||||||
// To get rid of problems with cached content in browser on a new version release,
|
// To get rid of problems with cached content in browser on a new version release,
|
||||||
// while also not disabling cache altogether.
|
// while also not disabling cache altogether.
|
||||||
|
log.Printf("static: %s", r.URL.Path)
|
||||||
rxp := regexp.MustCompile("^/(css|images|js|fonts)/v[0-9]+\\.[0-9]+\\.[0-9]+/(.*)$")
|
rxp := regexp.MustCompile("^/(css|images|js|fonts)/v[0-9]+\\.[0-9]+\\.[0-9]+/(.*)$")
|
||||||
if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil {
|
if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil {
|
||||||
r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2])
|
r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2])
|
||||||
@ -154,6 +101,7 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
|||||||
|
|
||||||
// Everything else is run through the template system.
|
// Everything else is run through the template system.
|
||||||
// For now to get VERSION into files to fix caching.
|
// For now to get VERSION into files to fix caching.
|
||||||
|
log.Printf("template: %s", r.URL.Path)
|
||||||
tmpl, err := newTemplate(r.URL.Path)
|
tmpl, err := newTemplate(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@ -168,6 +116,93 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{
|
|||||||
}
|
}
|
||||||
}// }}}
|
}// }}}
|
||||||
|
|
||||||
|
func sessionCreate(w http.ResponseWriter, r *http.Request) {// {{{
|
||||||
|
log.Println("/session/create")
|
||||||
|
session, err := CreateSession()
|
||||||
|
if err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responseData(w, map[string]interface{}{
|
||||||
|
"OK": true,
|
||||||
|
"Session": session,
|
||||||
|
})
|
||||||
|
}// }}}
|
||||||
|
func sessionRetrieve(w http.ResponseWriter, r *http.Request) {// {{{
|
||||||
|
log.Println("/session/retrieve")
|
||||||
|
var err error
|
||||||
|
var found bool
|
||||||
|
var session Session
|
||||||
|
|
||||||
|
if session, found, err = ValidateSession(r, false); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData(w, map[string]interface{}{
|
||||||
|
"OK": true,
|
||||||
|
"Valid": found,
|
||||||
|
"Session": session,
|
||||||
|
})
|
||||||
|
}// }}}
|
||||||
|
func sessionAuthenticate(w http.ResponseWriter, r *http.Request) {// {{{
|
||||||
|
log.Println("/session/authenticate")
|
||||||
|
var err error
|
||||||
|
var session Session
|
||||||
|
var authenticated bool
|
||||||
|
|
||||||
|
// Validate session
|
||||||
|
if session, _, err = ValidateSession(r, true); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := struct{ Username string; Password string }{}
|
||||||
|
if err = parseRequest(r, &req); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if authenticated, err = session.Authenticate(req.Username, req.Password); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData(w, map[string]interface{}{
|
||||||
|
"OK": true,
|
||||||
|
"Authenticated": authenticated,
|
||||||
|
"Session": session,
|
||||||
|
})
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
func nodeRetrieve(w http.ResponseWriter, r *http.Request) {// {{{
|
||||||
|
log.Println("/node/retrieve")
|
||||||
|
var err error
|
||||||
|
var session Session
|
||||||
|
|
||||||
|
if session, _, err = ValidateSession(r, true); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := struct { ID int }{}
|
||||||
|
if err = parseRequest(r, &req); err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := session.Node(req.ID)
|
||||||
|
if err != nil {
|
||||||
|
responseError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData(w, map[string]interface{}{
|
||||||
|
"OK": true,
|
||||||
|
"Node": node,
|
||||||
|
})
|
||||||
|
}// }}}
|
||||||
|
|
||||||
func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{
|
func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{
|
||||||
// Append index.html if needed for further reading of the file
|
// Append index.html if needed for further reading of the file
|
||||||
p := requestPath
|
p := requestPath
|
||||||
|
183
node.go
Normal file
183
node.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID int
|
||||||
|
UserID int `db:"user_id"`
|
||||||
|
ParentID int `db:"parent_id"`
|
||||||
|
Name string
|
||||||
|
Content string
|
||||||
|
Children []Node
|
||||||
|
Crumbs []Node
|
||||||
|
Complete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (session Session) RootNode() (node Node, err error) {// {{{
|
||||||
|
var rows *sqlx.Rows
|
||||||
|
rows, err = db.Queryx(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
0 AS parent_id,
|
||||||
|
name
|
||||||
|
FROM node
|
||||||
|
WHERE
|
||||||
|
user_id = $1 AND
|
||||||
|
parent_id IS NULL
|
||||||
|
`,
|
||||||
|
session.UserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
node.Name = "Start"
|
||||||
|
node.UserID = session.UserID
|
||||||
|
node.Complete = true
|
||||||
|
node.Children = []Node{}
|
||||||
|
node.Crumbs = []Node{}
|
||||||
|
for rows.Next() {
|
||||||
|
row := Node{}
|
||||||
|
if err = rows.StructScan(&row); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.Children = append(node.Children, Node{
|
||||||
|
ID: row.ID,
|
||||||
|
UserID: row.UserID,
|
||||||
|
ParentID: row.ParentID,
|
||||||
|
Name: row.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}// }}}
|
||||||
|
func (session Session) Node(nodeID int) (node Node, err error) {// {{{
|
||||||
|
if nodeID == 0 {
|
||||||
|
return session.RootNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows *sqlx.Rows
|
||||||
|
rows, err = db.Queryx(`
|
||||||
|
WITH RECURSIVE recurse AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
COALESCE(parent_id, 0) AS parent_id,
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
0 AS level
|
||||||
|
FROM node
|
||||||
|
WHERE
|
||||||
|
user_id = $1 AND
|
||||||
|
id = $2
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.user_id,
|
||||||
|
n.parent_id,
|
||||||
|
n.name,
|
||||||
|
'' AS content,
|
||||||
|
r.level + 1 AS level
|
||||||
|
FROM node n
|
||||||
|
INNER JOIN recurse r ON n.parent_id = r.id AND r.level = 0
|
||||||
|
WHERE
|
||||||
|
n.user_id = $1
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT * FROM recurse ORDER BY level ASC
|
||||||
|
`,
|
||||||
|
session.UserID,
|
||||||
|
nodeID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type resultRow struct {
|
||||||
|
Node
|
||||||
|
Level int
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row := resultRow{}
|
||||||
|
if err = rows.StructScan(&row); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Level == 0 {
|
||||||
|
node = Node{}
|
||||||
|
node.Children = []Node{}
|
||||||
|
node.ID = row.ID
|
||||||
|
node.UserID = row.UserID
|
||||||
|
node.ParentID = row.ParentID
|
||||||
|
node.Name = row.Name
|
||||||
|
node.Content = row.Content
|
||||||
|
node.Complete = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Level == 1 {
|
||||||
|
node.Children = append(node.Children, Node{
|
||||||
|
ID: row.ID,
|
||||||
|
UserID: row.UserID,
|
||||||
|
ParentID: row.ParentID,
|
||||||
|
Name: row.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.Crumbs, err = session.NodeCrumbs(node.ID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}// }}}
|
||||||
|
func (session Session) NodeCrumbs(nodeID int) (nodes []Node, err error) {// {{{
|
||||||
|
var rows *sqlx.Rows
|
||||||
|
rows, err = db.Queryx(`
|
||||||
|
WITH RECURSIVE nodes AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(parent_id, 0) AS parent_id,
|
||||||
|
name
|
||||||
|
FROM node
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
|
||||||
|
UNION
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
COALESCE(n.parent_id, 0) AS parent_id,
|
||||||
|
n.name
|
||||||
|
FROM node n
|
||||||
|
INNER JOIN nodes nr ON n.id = nr.parent_id
|
||||||
|
)
|
||||||
|
SELECT * FROM nodes
|
||||||
|
`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
nodes = []Node{}
|
||||||
|
for rows.Next() {
|
||||||
|
node := Node{}
|
||||||
|
if err = rows.StructScan(&node); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
// vim: foldmethod=marker
|
@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
// Standard
|
// Standard
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@ -14,8 +13,8 @@ type Request struct {
|
|||||||
|
|
||||||
func responseError(w http.ResponseWriter, err error) {
|
func responseError(w http.ResponseWriter, err error) {
|
||||||
res := map[string]interface{}{
|
res := map[string]interface{}{
|
||||||
"ok": false,
|
"OK": false,
|
||||||
"error": err.Error(),
|
"Error": err.Error(),
|
||||||
}
|
}
|
||||||
resJSON, _ := json.Marshal(res)
|
resJSON, _ := json.Marshal(res)
|
||||||
|
|
||||||
@ -29,17 +28,9 @@ func responseData(w http.ResponseWriter, data interface{}) {
|
|||||||
w.Write(resJSON)
|
w.Write(resJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(r *http.Request, data interface{}) (err error) {
|
func parseRequest(r *http.Request, data interface{}) (err error) {
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
err = json.Unmarshal(body, data)
|
err = json.Unmarshal(body, data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionUUID(r *http.Request) (string, error) {
|
|
||||||
headers := r.Header["X-Session-Id"]
|
|
||||||
if len(headers) > 0 {
|
|
||||||
return headers[0], nil
|
|
||||||
}
|
|
||||||
return "", errors.New("Invalid session")
|
|
||||||
}
|
|
||||||
|
54
session.go
54
session.go
@ -4,6 +4,8 @@ import (
|
|||||||
// Standard
|
// Standard
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -13,7 +15,7 @@ type Session struct {
|
|||||||
Created time.Time
|
Created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSession() (session Session, err error) {
|
func CreateSession() (session Session, err error) {// {{{
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
if rows, err = db.Query(`
|
if rows, err = db.Query(`
|
||||||
INSERT INTO public.session(uuid)
|
INSERT INTO public.session(uuid)
|
||||||
@ -29,33 +31,60 @@ func NewSession() (session Session, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}// }}}
|
||||||
|
|
||||||
func (session *Session) Retrieve() (err error) {
|
func sessionUUID(r *http.Request) (string, error) {// {{{
|
||||||
|
headers := r.Header["X-Session-Id"]
|
||||||
|
if len(headers) > 0 {
|
||||||
|
return headers[0], nil
|
||||||
|
}
|
||||||
|
return "", errors.New("Invalid session")
|
||||||
|
}// }}}
|
||||||
|
func ValidateSession(r *http.Request, notFoundIsError bool) (session Session, found bool, err error) {// {{{
|
||||||
|
var uuid string
|
||||||
|
if uuid, err = sessionUUID(r); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.UUID = uuid
|
||||||
|
if found, err = session.Retrieve(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if notFoundIsError && !found {
|
||||||
|
err = errors.New("Invalid session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
func (session *Session) Retrieve() (found bool, err error) {// {{{
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
if rows, err = db.Query(`
|
if rows, err = db.Query(`
|
||||||
SELECT
|
SELECT
|
||||||
uuid, user_id, created
|
uuid, user_id, created
|
||||||
FROM public.session
|
FROM public.session
|
||||||
WHERE
|
WHERE
|
||||||
uuid = $1
|
uuid = $1 AND
|
||||||
|
created + $2::interval >= NOW()
|
||||||
`,
|
`,
|
||||||
session.UUID,
|
session.UUID,
|
||||||
|
fmt.Sprintf("%d days", config.Session.DaysValid),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
found = false
|
||||||
if rows.Next() {
|
if rows.Next() {
|
||||||
|
found = true
|
||||||
rows.Scan(&session.UUID, &session.UserID, &session.Created)
|
rows.Scan(&session.UUID, &session.UserID, &session.Created)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}// }}}
|
||||||
|
func (session *Session) Authenticate(username, password string) (authenticated bool, err error) {// {{{
|
||||||
func (session *Session) Authenticate(username, password string) (err error) {
|
|
||||||
authenticated := false
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
if rows, err = db.Query(`
|
if rows, err = db.Query(`
|
||||||
SELECT id
|
SELECT id
|
||||||
@ -81,9 +110,10 @@ func (session *Session) Authenticate(username, password string) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
err = errors.New("Invalid authentication")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}// }}}
|
||||||
|
|
||||||
|
|
||||||
|
// vim: foldmethod=marker
|
||||||
|
28
sql/0001.sql
Normal file
28
sql/0001.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE public."user" (
|
||||||
|
id SERIAL NOT NULL,
|
||||||
|
username VARCHAR(64) NOT NULL DEFAULT ''::character varying,
|
||||||
|
"password" VARCHAR(64) NOT NULL DEFAULT ''::character varying,
|
||||||
|
last_login TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT newtable_pk PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public."session" (
|
||||||
|
uuid UUID NOT NULL,
|
||||||
|
user_id INT4 NULL,
|
||||||
|
created TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT session_pk PRIMARY KEY (uuid),
|
||||||
|
CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE public.node (
|
||||||
|
id SERIAL NOT NULL,
|
||||||
|
user_id INT4 NOT NULL,
|
||||||
|
parent_id INT4 NULL,
|
||||||
|
"name" VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
"content" TEXT NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0),
|
||||||
|
CONSTRAINT node_pk PRIMARY KEY (id),
|
||||||
|
CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
@ -25,3 +25,10 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
#login .auth-failed {
|
||||||
|
margin-top: 32px;
|
||||||
|
color: #a00;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
@ -3,13 +3,60 @@ body {
|
|||||||
margin: 0px;
|
margin: 0px;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
font-family: 'Liberation Mono', monospace;
|
font-family: 'Liberation Mono', monospace;
|
||||||
font-size: 16pt;
|
font-size: 14pt;
|
||||||
background-color: #494949;
|
background-color: #494949;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
color: #ecbf00;
|
color: #ecbf00;
|
||||||
}
|
}
|
||||||
#app {
|
#app {
|
||||||
padding: 32px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #ecbf00;
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.crumbs .crumb {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.crumbs .crumb:after {
|
||||||
|
content: ">";
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #a08100;
|
||||||
|
}
|
||||||
|
.crumbs .crumb:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.crumbs .crumb:last-child:after {
|
||||||
|
content: '';
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
.child-nodes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 16px 16px 0px 16px;
|
||||||
|
background-color: #3c3c3c;
|
||||||
|
box-shadow: 0px 0px 16px -4px rgba(0, 0, 0, 0.55) inset;
|
||||||
|
}
|
||||||
|
.child-nodes .child-node {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #292929;
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.node-name {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.node-content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
@ -17,7 +17,8 @@
|
|||||||
"@preact/signals-core": "/js/{{ .VERSION }}/lib/signals/signals-core.mjs",
|
"@preact/signals-core": "/js/{{ .VERSION }}/lib/signals/signals-core.mjs",
|
||||||
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
|
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
|
||||||
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
|
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
|
||||||
"session": "/js/{{ .VERSION }}/session.mjs"
|
"session": "/js/{{ .VERSION }}/session.mjs",
|
||||||
|
"node": "/js/{{ .VERSION }}/node.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import 'preact/debug'
|
|
||||||
import 'preact/devtools'
|
import 'preact/devtools'
|
||||||
//import { signal } from 'preact/signals'
|
import { signal } from 'preact/signals'
|
||||||
import { h, Component, render, createRef } from 'preact'
|
import { h, Component, render, createRef } from 'preact'
|
||||||
import htm from 'htm'
|
import htm from 'htm'
|
||||||
import { Session } from 'session'
|
import { Session } from 'session'
|
||||||
|
import { NodeUI } from 'node'
|
||||||
const html = htm.bind(h)
|
const html = htm.bind(h)
|
||||||
|
|
||||||
class App extends Component {
|
class App extends Component {
|
||||||
@ -17,18 +17,19 @@ class App extends Component {
|
|||||||
|
|
||||||
this.session = new Session(this)
|
this.session = new Session(this)
|
||||||
this.session.initialize()
|
this.session.initialize()
|
||||||
|
this.login = createRef()
|
||||||
|
this.nodeUI = createRef()
|
||||||
}//}}}
|
}//}}}
|
||||||
render() {//{{{
|
render() {//{{{
|
||||||
if(!this.session.initialized) {
|
if(!this.session.initialized) {
|
||||||
return false;
|
return html`<div>Validating session</div>`
|
||||||
//return html`<div>Validating session</div>`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!this.session.authenticated()) {
|
if(!this.session.authenticated()) {
|
||||||
return html`<${Login} />`
|
return html`<${Login} ref=${this.login} />`
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`Welcome`;
|
return html`<${NodeUI} app=${this} ref=${this.nodeUI} />`
|
||||||
}//}}}
|
}//}}}
|
||||||
|
|
||||||
wsLoop() {//{{{
|
wsLoop() {//{{{
|
||||||
@ -40,7 +41,7 @@ class App extends Component {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}//}}}
|
}//}}}
|
||||||
wsConnect() {//{{{
|
wsConnect() {//{{{
|
||||||
this.websocket = new WebSocket(`wss://notes.ahall.se/ws`)
|
this.websocket = new WebSocket(`ws://192.168.11.60:1371/ws`)
|
||||||
this.websocket.onopen = evt=>this.wsOpen(evt)
|
this.websocket.onopen = evt=>this.wsOpen(evt)
|
||||||
this.websocket.onmessage = evt=>this.wsMessage(evt)
|
this.websocket.onmessage = evt=>this.wsMessage(evt)
|
||||||
this.websocket.onerror = evt=>this.wsError(evt)
|
this.websocket.onerror = evt=>this.wsError(evt)
|
||||||
@ -76,8 +77,8 @@ class App extends Component {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if(app !== undefined && app.hasOwnProperty('error')) {
|
if(app !== undefined && app.hasOwnProperty('Error')) {
|
||||||
alert(app.error)
|
alert(app.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +92,6 @@ class App extends Component {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(this.session)
|
|
||||||
if(this.session.UUID !== '')
|
if(this.session.UUID !== '')
|
||||||
headers['X-Session-Id'] = this.session.UUID
|
headers['X-Session-Id'] = this.session.UUID
|
||||||
|
|
||||||
@ -108,8 +108,9 @@ class App extends Component {
|
|||||||
})
|
})
|
||||||
.then(json=>{
|
.then(json=>{
|
||||||
// An application level error occured
|
// An application level error occured
|
||||||
if(!json.OK)
|
if(!json.OK) {
|
||||||
return reject({app: json})
|
return reject({app: json})
|
||||||
|
}
|
||||||
return resolve(json)
|
return resolve(json)
|
||||||
})
|
})
|
||||||
.catch(err=>reject({comm: err}))
|
.catch(err=>reject({comm: err}))
|
||||||
@ -126,13 +127,28 @@ class App extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Login extends Component {
|
class Login extends Component {
|
||||||
render() {//{{{
|
constructor() {//{{{
|
||||||
|
super()
|
||||||
|
this.authentication_failed = signal(false)
|
||||||
|
this.state = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
}//}}}
|
||||||
|
render({}, { username, password }) {//{{{
|
||||||
|
console.log('login render')
|
||||||
|
|
||||||
|
let authentication_failed = html``;
|
||||||
|
if(this.authentication_failed.value)
|
||||||
|
authentication_failed = html`<div class="auth-failed">Authentication failed</div>`;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div id="login">
|
<div id="login">
|
||||||
<h1>Notes</h1>
|
<h1>Notes</h1>
|
||||||
<input id="username" type="text" placeholder="Username" onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} />
|
<input id="username" type="text" placeholder="Username" value=${username} oninput=${evt=>this.setState({ username: evt.target.value})} onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} />
|
||||||
<input id="password" type="password" placeholder="Password" onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} />
|
<input id="password" type="password" placeholder="Password" value=${password} oninput=${evt=>this.setState({ password: evt.target.value})} onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} />
|
||||||
<button onclick=${()=>this.login()}>Login</button>
|
<button onclick=${()=>this.login()}>Login</button>
|
||||||
|
${authentication_failed}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}//}}}
|
}//}}}
|
||||||
@ -149,6 +165,27 @@ class Login extends Component {
|
|||||||
|
|
||||||
// Init{{{
|
// Init{{{
|
||||||
//let urlParams = new URLSearchParams(window.location.search)
|
//let urlParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
/*
|
||||||
|
async function debug(type, context, data) {
|
||||||
|
await fetch("https://msg.kon-it.se/log", {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemId: 12,
|
||||||
|
type,
|
||||||
|
context,
|
||||||
|
data: JSON.stringify(data, null, 4),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onerror = (event, source, lineno, colon, error) => {
|
||||||
|
debug('Notes', 'error', {
|
||||||
|
event, source, lineno, colon, error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
window._app = createRef()
|
window._app = createRef()
|
||||||
window._resourceModels = []
|
window._resourceModels = []
|
||||||
render(html`<${App} ref=${window._app} />`, document.getElementById('app'))
|
render(html`<${App} ref=${window._app} />`, document.getElementById('app'))
|
||||||
|
89
static/js/node.mjs
Normal file
89
static/js/node.mjs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { h, Component } from 'preact'
|
||||||
|
import htm from 'htm'
|
||||||
|
import { signal } from 'preact/signals'
|
||||||
|
const html = htm.bind(h)
|
||||||
|
|
||||||
|
export class NodeUI extends Component {
|
||||||
|
constructor() {//{{{
|
||||||
|
super()
|
||||||
|
this.node = signal(null)
|
||||||
|
window.addEventListener('popstate', evt=>{
|
||||||
|
if(evt.state && evt.state.hasOwnProperty('nodeID'))
|
||||||
|
this.goToNode(evt.state.nodeID, true)
|
||||||
|
else
|
||||||
|
this.goToNode(0, true)
|
||||||
|
})
|
||||||
|
}//}}}
|
||||||
|
render() {//{{{
|
||||||
|
if(this.node.value === null)
|
||||||
|
return
|
||||||
|
|
||||||
|
let node = this.node.value
|
||||||
|
|
||||||
|
let crumbs = [
|
||||||
|
html`<div class="crumb" onclick=${()=>this.goToNode(0)}>Start</div>`
|
||||||
|
]
|
||||||
|
|
||||||
|
crumbs = crumbs.concat(node.Crumbs.slice(1).map(node=>
|
||||||
|
html`<div class="crumb" onclick=${()=>this.goToNode(node.ID)}>${node.Name}</div>`
|
||||||
|
).reverse())
|
||||||
|
|
||||||
|
let children = node.Children.sort((a,b)=>{
|
||||||
|
if(a.Name.toLowerCase() > b.Name.toLowerCase()) return 1;
|
||||||
|
if(a.Name.toLowerCase() < b.Name.toLowerCase()) return -1;
|
||||||
|
return 0
|
||||||
|
}).map(child=>html`
|
||||||
|
<div class="child-node" onclick=${()=>this.goToNode(child.ID)}>${child.Name}</div>
|
||||||
|
`)
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${node.ID > 0 ? html`<div class="crumbs">${crumbs}</crumbs>` : html``}
|
||||||
|
<div class="node-name">${node.Name}</div>
|
||||||
|
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
|
||||||
|
<div class="node-content">${node.Content}</div>
|
||||||
|
`
|
||||||
|
}//}}}
|
||||||
|
componentDidMount() {//{{{
|
||||||
|
let root = new Node(this.props.app, 0)
|
||||||
|
root.retrieve(node=>{
|
||||||
|
this.node.value = node
|
||||||
|
})
|
||||||
|
}//}}}
|
||||||
|
goToNode(nodeID, dontPush) {//{{{
|
||||||
|
if(!dontPush)
|
||||||
|
history.pushState({ nodeID }, '', `#${nodeID}`)
|
||||||
|
let node = new Node(this.props.app, nodeID)
|
||||||
|
node.retrieve(node=>{
|
||||||
|
this.node.value = node
|
||||||
|
})
|
||||||
|
}//}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
constructor(app, nodeID) {//{{{
|
||||||
|
this.app = app
|
||||||
|
this.ID = nodeID
|
||||||
|
this.ParentID = 0
|
||||||
|
this.UserID = 0
|
||||||
|
this.Name = ''
|
||||||
|
this.Content = ''
|
||||||
|
this.Children = []
|
||||||
|
this.Crumbs = []
|
||||||
|
}//}}}
|
||||||
|
|
||||||
|
retrieve(callback) {//{{{
|
||||||
|
this.app.request('/node/retrieve', { ID: this.ID })
|
||||||
|
.then(res=>{
|
||||||
|
this.ParentID = res.Node.ParentID
|
||||||
|
this.UserID = res.Node.UserID
|
||||||
|
this.Name = res.Node.Name
|
||||||
|
this.Content = res.Node.Content
|
||||||
|
this.Children = res.Node.Children
|
||||||
|
this.Crumbs = res.Node.Crumbs
|
||||||
|
callback(this)
|
||||||
|
})
|
||||||
|
.catch(this.app.responseError)
|
||||||
|
}//}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vim: foldmethod=marker
|
@ -9,37 +9,61 @@ export class Session {
|
|||||||
initialize() {//{{{
|
initialize() {//{{{
|
||||||
// Retrieving the stored session UUID, if any.
|
// Retrieving the stored session UUID, if any.
|
||||||
// If one found, validate with server.
|
// If one found, validate with server.
|
||||||
let uuid = window.localStorage.getItem("session.UUID")
|
|
||||||
|
|
||||||
|
// If the browser doesn't know anything about a session,
|
||||||
|
// a call to /session/create is necessary to retrieve a session UUID.
|
||||||
|
let uuid= window.localStorage.getItem("session.UUID")
|
||||||
if(uuid === null) {
|
if(uuid === null) {
|
||||||
this.create()
|
this.create()
|
||||||
} else {
|
return
|
||||||
this.UUID = uuid
|
}
|
||||||
this.app.request('/session/retrieve', {})
|
|
||||||
.then(res=>{
|
// When loading the page, the web app needs to know session information
|
||||||
this.UserID = res.Session.UserID
|
// such as user ID and possibly some state to render properly.
|
||||||
|
//
|
||||||
|
// A call to /session/retrieve with a session UUID validates that the
|
||||||
|
// session is still valid and returns all session information.
|
||||||
|
this.UUID = uuid
|
||||||
|
this.app.request('/session/retrieve', {})
|
||||||
|
.then(res=>{
|
||||||
|
if(res.Valid) {
|
||||||
|
// Session exists on server.
|
||||||
|
// Not necessarily authenticated.
|
||||||
|
this.UserID = res.Session.UserID // could be 0
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.app.forceUpdate()
|
this.app.forceUpdate()
|
||||||
})
|
} else {
|
||||||
.catch(this.app.responseError)
|
// Session has probably expired. A new is required.
|
||||||
}
|
this.create()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(this.app.responseError)
|
||||||
}//}}}
|
}//}}}
|
||||||
create() {//{{{
|
create() {//{{{
|
||||||
this.app.request('/session/create', {})
|
this.app.request('/session/create', {})
|
||||||
.then(res=>{
|
.then(res=>{
|
||||||
this.UUID = res.Session.UUID
|
this.UUID = res.Session.UUID
|
||||||
window.localStorage.setItem('session.UUID', this.UUID)
|
window.localStorage.setItem('session.UUID', this.UUID)
|
||||||
|
this.initialized = true
|
||||||
this.app.forceUpdate()
|
this.app.forceUpdate()
|
||||||
})
|
})
|
||||||
.catch(this.responseError)
|
.catch(this.responseError)
|
||||||
}//}}}
|
}//}}}
|
||||||
authenticate(username, password) {//{{{
|
authenticate(username, password) {//{{{
|
||||||
|
this.app.login.current.authentication_failed.value = false
|
||||||
|
|
||||||
this.app.request('/session/authenticate', {
|
this.app.request('/session/authenticate', {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
.then(res=>{
|
.then(res=>{
|
||||||
this.UserID = res.Session.UserID
|
if(res.Authenticated) {
|
||||||
this.app.forceUpdate()
|
this.UserID = res.Session.UserID
|
||||||
|
this.app.forceUpdate()
|
||||||
|
} else {
|
||||||
|
this.app.login.current.authentication_failed.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(this.app.responseError)
|
.catch(this.app.responseError)
|
||||||
}//}}}
|
}//}}}
|
||||||
|
@ -30,4 +30,12 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-failed {
|
||||||
|
margin-top: 32px;
|
||||||
|
color: #a00;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ html, body {
|
|||||||
margin: 0px;
|
margin: 0px;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
font-family: 'Liberation Mono', monospace;
|
font-family: 'Liberation Mono', monospace;
|
||||||
font-size: 16pt;
|
font-size: @fontsize;
|
||||||
|
|
||||||
background-color: @background;
|
background-color: @background;
|
||||||
}
|
}
|
||||||
@ -14,6 +14,63 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
padding: 32px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: @accent_1;
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0px 2px 8px 0px rgba(0,0,0,0.4);
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:after {
|
||||||
|
content: ">";
|
||||||
|
margin-left: 8px;
|
||||||
|
color: darken(@accent_1, 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.crumb:last-child:after {
|
||||||
|
content: '';
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-nodes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
padding: 16px 16px 0px 16px;
|
||||||
|
background-color: darken(@background, 5%);
|
||||||
|
box-shadow: 0px 0px 16px -4px rgba(0,0,0,0.55) inset;
|
||||||
|
|
||||||
|
.child-node {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: darken(@background, 12.5%);
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
@fontsize: 14pt;
|
||||||
@background: #494949;
|
@background: #494949;
|
||||||
@accent_1: #ecbf00;
|
@accent_1: #ecbf00;
|
||||||
|
@accent_2: #abc837;
|
||||||
|
Loading…
Reference in New Issue
Block a user