This commit is contained in:
Magnus Åhall 2023-06-17 09:11:14 +02:00
parent a76c093d44
commit c255b58335
15 changed files with 658 additions and 115 deletions

View File

@ -21,6 +21,10 @@ type Config struct {
Username string
Password string
}
Session struct {
DaysValid int
}
}
func ConfigRead(filename string) (config Config, err error) {

157
main.go
View File

@ -11,9 +11,10 @@ import (
"path"
"regexp"
"strings"
"time"
)
const VERSION = "v0.0.1";
const VERSION = "v0.0.3";
const LISTEN_HOST = "0.0.0.0";
var (
@ -33,6 +34,10 @@ func init() {// {{{
)
flag.Parse()
if false {
time.Sleep(time.Second*1)
}
}// }}}
func main() {// {{{
var err error
@ -53,6 +58,7 @@ func main() {// {{{
http.HandleFunc("/session/create", sessionCreate)
http.HandleFunc("/session/retrieve", sessionRetrieve)
http.HandleFunc("/session/authenticate", sessionAuthenticate)
http.HandleFunc("/node/retrieve", nodeRetrieve)
http.HandleFunc("/ws", websocketHandler)
http.HandleFunc("/", staticHandler)
@ -66,66 +72,6 @@ func cssUpdateHandler(w http.ResponseWriter, r *http.Request) {// {{{
log.Println("[BROADCAST] CSS updated")
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) {// {{{
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.
// To get rid of problems with cached content in browser on a new version release,
// 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]+/(.*)$")
if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil {
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.
// For now to get VERSION into files to fix caching.
log.Printf("template: %s", r.URL.Path)
tmpl, err := newTemplate(r.URL.Path)
if err != nil {
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) {// {{{
// Append index.html if needed for further reading of the file
p := requestPath

183
node.go Normal file
View 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

View File

@ -3,7 +3,6 @@ package main
import (
// Standard
"encoding/json"
"errors"
"io"
"net/http"
)
@ -14,8 +13,8 @@ type Request struct {
func responseError(w http.ResponseWriter, err error) {
res := map[string]interface{}{
"ok": false,
"error": err.Error(),
"OK": false,
"Error": err.Error(),
}
resJSON, _ := json.Marshal(res)
@ -29,17 +28,9 @@ func responseData(w http.ResponseWriter, data interface{}) {
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)
defer r.Body.Close()
err = json.Unmarshal(body, data)
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")
}

View File

@ -4,6 +4,8 @@ import (
// Standard
"database/sql"
"errors"
"fmt"
"net/http"
"time"
)
@ -13,7 +15,7 @@ type Session struct {
Created time.Time
}
func NewSession() (session Session, err error) {
func CreateSession() (session Session, err error) {// {{{
var rows *sql.Rows
if rows, err = db.Query(`
INSERT INTO public.session(uuid)
@ -29,33 +31,60 @@ func NewSession() (session Session, err error) {
}
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
if rows, err = db.Query(`
SELECT
uuid, user_id, created
FROM public.session
WHERE
uuid = $1
uuid = $1 AND
created + $2::interval >= NOW()
`,
session.UUID,
fmt.Sprintf("%d days", config.Session.DaysValid),
); err != nil {
return
}
defer rows.Close()
found = false
if rows.Next() {
found = true
rows.Scan(&session.UUID, &session.UserID, &session.Created)
}
return
}
func (session *Session) Authenticate(username, password string) (err error) {
authenticated := false
}// }}}
func (session *Session) Authenticate(username, password string) (authenticated bool, err error) {// {{{
var rows *sql.Rows
if rows, err = db.Query(`
SELECT id
@ -81,9 +110,10 @@ func (session *Session) Authenticate(username, password string) (err error) {
if err != nil {
return
}
} else {
err = errors.New("Invalid authentication")
}
return
}
}// }}}
// vim: foldmethod=marker

28
sql/0001.sql Normal file
View 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
);

View File

@ -25,3 +25,10 @@
font-size: 0.8em;
align-self: center;
}
#login .auth-failed {
margin-top: 32px;
color: #a00;
background: #fff;
padding: 16px;
border-radius: 8px;
}

View File

@ -3,13 +3,60 @@ body {
margin: 0px;
padding: 0px;
font-family: 'Liberation Mono', monospace;
font-size: 16pt;
font-size: 14pt;
background-color: #494949;
}
h1 {
color: #ecbf00;
}
#app {
padding: 32px;
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;
}

View File

@ -17,7 +17,8 @@
"@preact/signals-core": "/js/{{ .VERSION }}/lib/signals/signals-core.mjs",
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
"session": "/js/{{ .VERSION }}/session.mjs"
"session": "/js/{{ .VERSION }}/session.mjs",
"node": "/js/{{ .VERSION }}/node.mjs"
}
}
</script>

View File

@ -1,9 +1,9 @@
import 'preact/debug'
import 'preact/devtools'
//import { signal } from 'preact/signals'
import { signal } from 'preact/signals'
import { h, Component, render, createRef } from 'preact'
import htm from 'htm'
import { Session } from 'session'
import { NodeUI } from 'node'
const html = htm.bind(h)
class App extends Component {
@ -17,18 +17,19 @@ class App extends Component {
this.session = new Session(this)
this.session.initialize()
this.login = createRef()
this.nodeUI = createRef()
}//}}}
render() {//{{{
if(!this.session.initialized) {
return false;
//return html`<div>Validating session</div>`
return html`<div>Validating session</div>`
}
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() {//{{{
@ -40,7 +41,7 @@ class App extends Component {
}, 1000)
}//}}}
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.onmessage = evt=>this.wsMessage(evt)
this.websocket.onerror = evt=>this.wsError(evt)
@ -76,8 +77,8 @@ class App extends Component {
return
}
if(app !== undefined && app.hasOwnProperty('error')) {
alert(app.error)
if(app !== undefined && app.hasOwnProperty('Error')) {
alert(app.Error)
return
}
@ -91,7 +92,6 @@ class App extends Component {
'Content-Type': 'application/json',
}
console.log(this.session)
if(this.session.UUID !== '')
headers['X-Session-Id'] = this.session.UUID
@ -108,8 +108,9 @@ class App extends Component {
})
.then(json=>{
// An application level error occured
if(!json.OK)
if(!json.OK) {
return reject({app: json})
}
return resolve(json)
})
.catch(err=>reject({comm: err}))
@ -126,13 +127,28 @@ class App 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`
<div id="login">
<h1>Notes</h1>
<input id="username" type="text" placeholder="Username" 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="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" value=${password} oninput=${evt=>this.setState({ password: evt.target.value})} onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} />
<button onclick=${()=>this.login()}>Login</button>
${authentication_failed}
</div>
`
}//}}}
@ -149,6 +165,27 @@ class Login extends Component {
// Init{{{
//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._resourceModels = []
render(html`<${App} ref=${window._app} />`, document.getElementById('app'))

89
static/js/node.mjs Normal file
View 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

View File

@ -9,37 +9,61 @@ export class Session {
initialize() {//{{{
// Retrieving the stored session UUID, if any.
// 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) {
this.create()
} else {
this.UUID = uuid
this.app.request('/session/retrieve', {})
.then(res=>{
this.UserID = res.Session.UserID
return
}
// When loading the page, the web app needs to know session information
// 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.app.forceUpdate()
})
.catch(this.app.responseError)
}
} else {
// Session has probably expired. A new is required.
this.create()
}
})
.catch(this.app.responseError)
}//}}}
create() {//{{{
this.app.request('/session/create', {})
.then(res=>{
this.UUID = res.Session.UUID
window.localStorage.setItem('session.UUID', this.UUID)
this.initialized = true
this.app.forceUpdate()
})
.catch(this.responseError)
}//}}}
authenticate(username, password) {//{{{
this.app.login.current.authentication_failed.value = false
this.app.request('/session/authenticate', {
username,
password,
})
.then(res=>{
this.UserID = res.Session.UserID
this.app.forceUpdate()
if(res.Authenticated) {
this.UserID = res.Session.UserID
this.app.forceUpdate()
} else {
this.app.login.current.authentication_failed.value = true
}
})
.catch(this.app.responseError)
}//}}}

View File

@ -30,4 +30,12 @@
font-size: 0.8em;
align-self: center;
}
.auth-failed {
margin-top: 32px;
color: #a00;
background: #fff;
padding: 16px;
border-radius: 8px;
}
}

View File

@ -4,7 +4,7 @@ html, body {
margin: 0px;
padding: 0px;
font-family: 'Liberation Mono', monospace;
font-size: 16pt;
font-size: @fontsize;
background-color: @background;
}
@ -14,6 +14,63 @@ h1 {
}
#app {
padding: 32px;
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;
}

View File

@ -1,2 +1,4 @@
@fontsize: 14pt;
@background: #494949;
@accent_1: #ecbf00;
@accent_2: #abc837;