diff --git a/config.go b/config.go index 026a06c..172e0c3 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,10 @@ type Config struct { Username string Password string } + + Session struct { + DaysValid int + } } func ConfigRead(filename string) (config Config, err error) { diff --git a/main.go b/main.go index 8a75ee2..a3ff503 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/node.go b/node.go new file mode 100644 index 0000000..a0f095e --- /dev/null +++ b/node.go @@ -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 diff --git a/request_response.go b/request_response.go index 90e8ad0..c2c6ba5 100644 --- a/request_response.go +++ b/request_response.go @@ -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") -} diff --git a/session.go b/session.go index 3aa27e8..2457eb3 100644 --- a/session.go +++ b/session.go @@ -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 diff --git a/sql/0001.sql b/sql/0001.sql new file mode 100644 index 0000000..0ccc6cd --- /dev/null +++ b/sql/0001.sql @@ -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 +); + diff --git a/static/css/login.css b/static/css/login.css index 4fdb19f..f940ceb 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -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; +} diff --git a/static/css/main.css b/static/css/main.css index 39b1a04..7576819 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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; +} diff --git a/static/index.html b/static/index.html index dca9637..5c92d11 100644 --- a/static/index.html +++ b/static/index.html @@ -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" } } diff --git a/static/js/app.mjs b/static/js/app.mjs index fc0e7b5..b6ecd20 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -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`
Validating session
` + return html`
Validating session
` } 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`
Authentication failed
`; + return html`

Notes

- { if(evt.code == 'Enter') this.login() }} /> - { if(evt.code == 'Enter') this.login() }} /> + this.setState({ username: evt.target.value})} onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} /> + this.setState({ password: evt.target.value})} onkeydown=${evt=>{ if(evt.code == 'Enter') this.login() }} /> + ${authentication_failed}
` }//}}} @@ -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')) diff --git a/static/js/node.mjs b/static/js/node.mjs new file mode 100644 index 0000000..17991d0 --- /dev/null +++ b/static/js/node.mjs @@ -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`
this.goToNode(0)}>Start
` + ] + + crumbs = crumbs.concat(node.Crumbs.slice(1).map(node=> + html`
this.goToNode(node.ID)}>${node.Name}
` + ).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` +
this.goToNode(child.ID)}>${child.Name}
+ `) + + return html` + ${node.ID > 0 ? html`
${crumbs}` : html``} +
${node.Name}
+ ${children.length > 0 ? html`
${children}
` : html``} +
${node.Content}
+ ` + }//}}} + 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 diff --git a/static/js/session.mjs b/static/js/session.mjs index f7aab33..9eedf70 100644 --- a/static/js/session.mjs +++ b/static/js/session.mjs @@ -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) }//}}} diff --git a/static/less/login.less b/static/less/login.less index 18210cb..6b0d313 100644 --- a/static/less/login.less +++ b/static/less/login.less @@ -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; + } } diff --git a/static/less/main.less b/static/less/main.less index 2563a09..36c2f9a 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -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; +} diff --git a/static/less/theme.less b/static/less/theme.less index 67c5735..bf0f18e 100644 --- a/static/less/theme.less +++ b/static/less/theme.less @@ -1,2 +1,4 @@ +@fontsize: 14pt; @background: #494949; @accent_1: #ecbf00; +@accent_2: #abc837;