Compare commits

...

5 Commits

Author SHA1 Message Date
Magnus Åhall
3b8c6432b6 Event schedule 2024-04-17 18:43:24 +02:00
Magnus Åhall
d186489f28 Fix crumbs shadow over node tree 2024-04-05 09:31:41 +02:00
Magnus Åhall
6a757c94b0 Fixed automatic CSS refresh 2024-04-05 09:27:47 +02:00
Magnus Åhall
3669b7e6ec Added wrappederror explicit logging 2024-04-05 09:01:59 +02:00
Magnus Åhall
566cff5e94 Smaller ui changes 2024-04-05 09:01:59 +02:00
9 changed files with 234 additions and 72 deletions

37
main.go
View File

@ -174,29 +174,6 @@ func main() { // {{{
os.Exit(1) os.Exit(1)
} }
} // }}} } // }}}
func scheduleHandler() { // {{{
// Wait for the approximate minute.
wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds()
logger.Info("schedule", "wait", wait/1000)
time.Sleep(time.Millisecond * time.Duration(wait))
tick := time.NewTicker(time.Minute)
for {
for _, event := range ExpiredSchedules() {
notificationManager.Send(
event.UserID,
event.ScheduleUUID,
[]byte(
fmt.Sprintf(
"%s\n%s",
event.Time.Format("2006-01-02 15:04"),
event.Description,
),
),
)
}
<-tick.C
}
} // }}}
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/node/tree") logger.Info("webserver", "request", "/node/tree")
@ -783,8 +760,20 @@ func scheduleList(w http.ResponseWriter, r *http.Request, sess *session.T) { //
var err error var err error
w.Header().Add("Access-Control-Allow-Origin", "*") w.Header().Add("Access-Control-Allow-Origin", "*")
request := struct {
NodeID int
}{}
body, _ := io.ReadAll(r.Body)
if len(body) > 0 {
err = json.Unmarshal(body, &request)
if err != nil {
responseError(w, err)
return
}
}
var schedules []Schedule var schedules []Schedule
schedules, err = FutureSchedules(sess.UserID) schedules, err = FutureSchedules(sess.UserID, request.NodeID)
if err != nil { if err != nil {
responseError(w, err) responseError(w, err)
return return

View File

@ -326,7 +326,7 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
} // }}} } // }}}
func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
var timezone string var timezone string
row := service.Db.Conn.QueryRow(`SELECT timezone FROM public.user WHERE id=$1`, userID) row := service.Db.Conn.QueryRow(`SELECT timezone FROM _webservice.user WHERE id=$1`, userID)
err = row.Scan(&timezone) err = row.Scan(&timezone)
if err != nil { if err != nil {
err = werr.Wrap(err).WithCode("002-000F") err = werr.Wrap(err).WithCode("002-000F")

View File

@ -1,6 +1,9 @@
package main package main
import ( import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard // Standard
"encoding/json" "encoding/json"
"io" "io"
@ -18,6 +21,8 @@ func responseError(w http.ResponseWriter, err error) {
} }
resJSON, _ := json.Marshal(res) resJSON, _ := json.Marshal(res)
werr.Wrap(err).Log()
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.Write(resJSON) w.Write(resJSON)
} }

View File

@ -28,6 +28,32 @@ type Schedule struct {
Acknowledged bool Acknowledged bool
} }
func scheduleHandler() { // {{{
// Wait for the approximate minute.
wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds()
logger.Info("schedule", "wait", wait/1000)
time.Sleep(time.Millisecond * time.Duration(wait))
tick := time.NewTicker(time.Minute)
for {
schedules := ExpiredSchedules()
logger.Info("FOO", "schedules", schedules)
for _, event := range schedules {
notificationManager.Send(
event.UserID,
event.ScheduleUUID,
[]byte(
fmt.Sprintf(
"%s\n%s",
event.Time.Format("2006-01-02 15:04"),
event.Description,
),
),
)
}
<-tick.C
}
} // }}}
func ScanForSchedules(timezone string, content string) (schedules []Schedule) { // {{{ func ScanForSchedules(timezone string, content string) (schedules []Schedule) { // {{{
schedules = []Schedule{} schedules = []Schedule{}
@ -89,7 +115,7 @@ func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error)
s.description, s.description,
s.acknowledged s.acknowledged
FROM schedule s FROM schedule s
INNER JOIN public.user u ON s.user_id = u.id INNER JOIN _webservice.user u ON s.user_id = u.id
WHERE WHERE
user_id=$1 AND user_id=$1 AND
CASE CASE
@ -120,6 +146,7 @@ func (a Schedule) IsEqual(b Schedule) bool { // {{{
return a.UserID == b.UserID && return a.UserID == b.UserID &&
a.Node.ID == b.Node.ID && a.Node.ID == b.Node.ID &&
a.Time.Equal(b.Time) && a.Time.Equal(b.Time) &&
a.RemindMinutes == b.RemindMinutes &&
a.Description == b.Description a.Description == b.Description
} // }}} } // }}}
func (s *Schedule) Insert(queryable Queryable) error { // {{{ func (s *Schedule) Insert(queryable Queryable) error { // {{{
@ -166,9 +193,9 @@ func ExpiredSchedules() (schedules []Schedule) { // {{{
(s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time, (s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
s.description s.description
FROM schedule s FROM schedule s
INNER JOIN public.user u ON s.user_id = u.id INNER JOIN _webservice.user u ON s.user_id = u.id
WHERE WHERE
(time - MAKE_INTERVAL(mins => remind_minutes)) < NOW() AND (time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND
NOT acknowledged NOT acknowledged
ORDER BY ORDER BY
time ASC time ASC
@ -189,7 +216,7 @@ func ExpiredSchedules() (schedules []Schedule) { // {{{
} }
return return
} // }}} } // }}}
func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{ func FutureSchedules(userID int, nodeID int) (schedules []Schedule, err error) {// {{{
schedules = []Schedule{} schedules = []Schedule{}
res := service.Db.Conn.QueryRow(` res := service.Db.Conn.QueryRow(`
@ -197,16 +224,27 @@ func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{
SELECT SELECT
s.id, s.id,
s.user_id, s.user_id,
to_jsonb(n.*) AS node, jsonb_build_object(
'id', n.id,
'name', n.name,
'updated', n.updated
) AS node,
s.schedule_uuid, s.schedule_uuid,
(time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time, time AT TIME ZONE 'UTC' AS time,
s.description, s.description,
s.acknowledged s.acknowledged,
s.remind_minutes AS RemindMinutes
FROM schedule s FROM schedule s
INNER JOIN public.user u ON s.user_id = u.id INNER JOIN _webservice.user u ON s.user_id = u.id
INNER JOIN node n ON s.node_id = n.id INNER JOIN node n ON s.node_id = n.id
WHERE WHERE
s.user_id=$1 AND s.user_id = $1 AND
(
CASE
WHEN $2 > 0 THEN n.id = $2
ELSE true
END
) AND
time >= NOW() AND time >= NOW() AND
NOT acknowledged NOT acknowledged
) )
@ -215,6 +253,7 @@ func FutureSchedules(userID int) (schedules []Schedule, err error) {// {{{
FROM schedule_events s FROM schedule_events s
`, `,
userID, userID,
nodeID,
) )
var j []byte var j []byte
err = res.Scan(&j) err = res.Scan(&j)

1
sql/00021.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz;

View File

@ -29,12 +29,16 @@ body {
height: 100%; height: 100%;
} }
h1 { h1 {
font-size: 1.5em;
color: #518048;
}
h2 {
font-size: 1.25em; font-size: 1.25em;
color: #518048; color: #518048;
border-bottom: 1px solid #ccc;
}
h2 {
font-size: 1em;
color: #518048;
}
h3 {
font-size: 1em;
} }
button { button {
font-size: 1em; font-size: 1em;
@ -203,6 +207,7 @@ header .menu {
padding: 16px; padding: 16px;
background-color: #333; background-color: #333;
color: #ddd; color: #ddd;
z-index: 100;
} }
#tree .node { #tree .node {
display: grid; display: grid;
@ -521,7 +526,7 @@ header .menu {
grid-area: 1 / 1 / 2 / 2; grid-area: 1 / 1 / 2 / 2;
} }
/* ============================================================= */ /* ============================================================= */
#file-section { #schedule-section {
grid-area: files; grid-area: files;
justify-self: center; justify-self: center;
width: calc(100% - 32px); width: calc(100% - 32px);
@ -531,6 +536,23 @@ header .menu {
border-radius: 8px; border-radius: 8px;
margin-top: 32px; margin-top: 32px;
margin-bottom: 32px; margin-bottom: 32px;
color: #000;
}
#schedule-section .header {
font-weight: bold;
color: #000;
margin-bottom: 16px;
}
#file-section {
grid-area: schedule;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 32px;
background: #f5f5f5;
border-radius: 8px;
margin-top: 32px;
margin-bottom: 16px;
} }
#file-section .header { #file-section .header {
font-weight: bold; font-weight: bold;
@ -629,9 +651,9 @@ header .menu {
} }
.layout-tree { .layout-tree {
display: grid; display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */ /* blank */
color: #fff; color: #fff;
min-height: 100%; min-height: 100%;
@ -664,9 +686,9 @@ header .menu {
display: block; display: block;
} }
.layout-crumbs { .layout-crumbs {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */ /* blank */
} }
.layout-crumbs #tree { .layout-crumbs #tree {
@ -713,17 +735,17 @@ header .menu {
} }
#app.node { #app.node {
display: grid; display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree files" "tree blank"; grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */ /* blank */
color: #fff; color: #fff;
min-height: 100%; min-height: 100%;
} }
#app.node.toggle-tree { #app.node.toggle-tree {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */ /* blank */
} }
#app.node.toggle-tree #tree { #app.node.toggle-tree #tree {
@ -745,11 +767,22 @@ header .menu {
#profile-settings .passwords div { #profile-settings .passwords div {
white-space: nowrap; white-space: nowrap;
} }
#schedule-events {
display: grid;
grid-template-columns: repeat(5, min-content);
grid-gap: 4px 12px;
margin: 32px;
color: #000;
white-space: nowrap;
}
#schedule-events .header {
font-weight: bold;
}
@media only screen and (max-width: 932px) { @media only screen and (max-width: 932px) {
#app.node { #app.node {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "files" "blank"; grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* files */ 1fr; grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
/* blank */ /* blank */
} }
#app.node #tree { #app.node #tree {

View File

@ -113,7 +113,7 @@ class App extends Component {
this.websocket.register('open', ()=>console.log('websocket connected')) this.websocket.register('open', ()=>console.log('websocket connected'))
this.websocket.register('close', ()=>console.log('websocket disconnected')) this.websocket.register('close', ()=>console.log('websocket disconnected'))
this.websocket.register('error', msg=>console.log(msg)) this.websocket.register('error', msg=>console.log(msg))
this.websocket.register('message', this.websocketMessage) this.websocket.register('message', msg=>this.websocketMessage(msg))
this.websocket.start() this.websocket.start()
}//}}} }//}}}
websocketMessage(data) {//{{{ websocketMessage(data) {//{{{
@ -121,7 +121,7 @@ class App extends Component {
switch (msg.Op) { switch (msg.Op) {
case 'css_reload': case 'css_reload':
refreshCSS() this.websocket.refreshCSS()
break; break;
} }
}//}}} }//}}}

View File

@ -14,7 +14,6 @@ export class NodeUI extends Component {
this.nodeContent = createRef() this.nodeContent = createRef()
this.nodeProperties = createRef() this.nodeProperties = createRef()
this.keys = signal([]) this.keys = signal([])
this.page = signal('node') this.page = signal('node')
window.addEventListener('popstate', evt => { window.addEventListener('popstate', evt => {
if (evt.state && evt.state.hasOwnProperty('nodeID')) if (evt.state && evt.state.hasOwnProperty('nodeID'))
@ -59,7 +58,7 @@ export class NodeUI extends Component {
case 'node': case 'node':
if (node.ID == 0) { if (node.ID == 0) {
page = html` page = html`
<div style="cursor: pointer; color: #000; text-align: center;" onclick=${()=>this.page.value = 'schedule-events'}>Schedule events</div> <div style="cursor: pointer; color: #000; text-align: center;" onclick=${() => this.page.value = 'schedule-events'}>Schedule events</div>
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``} ${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
` `
} else { } else {
@ -73,6 +72,7 @@ export class NodeUI extends Component {
${node.Name} ${padlock} ${node.Name} ${padlock}
</div> </div>
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} /> <${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
<${NodeEvents} events=${node.ScheduleEvents.value} />
<${Checklist} ui=${this} groups=${node.ChecklistGroups} /> <${Checklist} ui=${this} groups=${node.ChecklistGroups} />
<${NodeFiles} node=${this.node.value} /> <${NodeFiles} node=${this.node.value} />
` `
@ -250,15 +250,12 @@ export class NodeUI extends Component {
}) })
}//}}} }//}}}
saveNode() {//{{{ saveNode() {//{{{
/*
let nodeContent = this.nodeContent.current
if (this.page.value != 'node' || nodeContent === null)
return
*/
let content = this.node.value.content() let content = this.node.value.content()
this.node.value.setContent(content) this.node.value.setContent(content)
this.node.value.save(() => this.props.app.nodeModified.value = false) this.node.value.save(() => {
this.props.app.nodeModified.value = false
this.node.value.retrieve()
})
}//}}} }//}}}
renameNode() {//{{{ renameNode() {//{{{
let name = prompt("New name") let name = prompt("New name")
@ -389,6 +386,24 @@ class MarkdownContent extends Component {
}//}}} }//}}}
} }
class NodeEvents extends Component {
render({ events }) {//{{{
if (events.length == 0)
return html``
const eventElements = events.map(evt => {
const dt = evt.Time.split('T')
return html`<div>${dt[0]} ${dt[1].slice(0, 5)}</div>`
})
return html`
<div id="schedule-section">
<div class="header">Schedule events</div>
${eventElements}
</div>
`
}//}}}
}
class NodeFiles extends Component { class NodeFiles extends Component {
render({ node }) {//{{{ render({ node }) {//{{{
if (node.Files === null || node.Files.length == 0) if (node.Files === null || node.Files.length == 0)
@ -443,10 +458,16 @@ export class Node {
this._decrypted = false this._decrypted = false
this._expanded = false // start value for the TreeNode component, this._expanded = false // start value for the TreeNode component,
this.ChecklistGroups = {} this.ChecklistGroups = {}
this.ScheduleEvents = signal([])
// it doesn't control it afterwards. // it doesn't control it afterwards.
// Used to expand the crumbs upon site loading. // Used to expand the crumbs upon site loading.
}//}}} }//}}}
retrieve(callback) {//{{{ retrieve(callback) {//{{{
this.app.request('/schedule/list', { NodeID: this.ID })
.then(res => {
this.ScheduleEvents.value = res.ScheduleEvents
})
this.app.request('/node/retrieve', { ID: this.ID }) this.app.request('/node/retrieve', { ID: this.ID })
.then(res => { .then(res => {
this.ParentID = res.Node.ParentID this.ParentID = res.Node.ParentID
@ -965,18 +986,50 @@ class ProfileSettings extends Component {
} }
class ScheduleEventList extends Component { class ScheduleEventList extends Component {
constructor() { constructor() {//{{{
super() super()
this.events = signal(null)
this.retrieveFutureEvents() this.retrieveFutureEvents()
}//}}}
render() {//{{{
if (this.events.value === null)
return
let events = this.events.value.map(evt => {
const dt = evt.Time.split('T')
const remind = () => {
if (evt.RemindMinutes > 0)
return html`${evt.RemindMinutes} min`
} }
render() { const nodeLink = () => html`<a href="/?node=${evt.Node.ID}">${evt.Node.Name}</a>`
}
retrieveFutureEvents() {
_app.current.request('/schedule/list') return html`
.then(foo=>{ <div class="date">${dt[0]}</div>
console.log(foo) <div class="time">${dt[1].slice(0, 5)}</div>
<div class="remind"><${remind} /></div>
<div class="description">${evt.Description}</div>
<div class="node"><${nodeLink} /></div>
`
}) })
}
return html`
<div id="schedule-events">
<div class="header">Date</div>
<div class="header">Time</div>
<div class="header">Reminder</div>
<div class="header">Event</div>
<div class="header">Node</div>
${events}
</div>
`
}//}}}
retrieveFutureEvents() {//{{{
_app.current.request('/schedule/list')
.then(data => {
this.events.value = data.ScheduleEvents
})
}//}}}
} }

View File

@ -27,15 +27,20 @@ html, body {
} }
h1 { h1 {
font-size: 1.5em; font-size: 1.25em;
color: @header_1; color: @header_1;
border-bottom: 1px solid #ccc;
} }
h2 { h2 {
font-size: 1.25em; font-size: 1.0em;
color: @header_1; color: @header_1;
} }
h3 {
font-size: 1.0em;
}
button { button {
font-size: 1em; font-size: 1em;
padding: 6px; padding: 6px;
@ -222,6 +227,7 @@ header {
padding: 16px; padding: 16px;
background-color: #333; background-color: #333;
color: #ddd; color: #ddd;
z-index: 100; // Over crumbs shadow
.node { .node {
display: grid; display: grid;
@ -611,7 +617,7 @@ header {
} }
/* ============================================================= */ /* ============================================================= */
#file-section { #schedule-section {
grid-area: files; grid-area: files;
justify-self: center; justify-self: center;
width: calc(100% - 32px); width: calc(100% - 32px);
@ -621,6 +627,25 @@ header {
border-radius: 8px; border-radius: 8px;
margin-top: 32px; margin-top: 32px;
margin-bottom: 32px; margin-bottom: 32px;
color: #000;
.header {
font-weight: bold;
color: #000;
margin-bottom: 16px;
}
}
#file-section {
grid-area: schedule;
justify-self: center;
width: calc(100% - 32px);
max-width: 900px;
padding: 32px;
background: #f5f5f5;
border-radius: 8px;
margin-top: 32px;
margin-bottom: 16px;
.header { .header {
font-weight: bold; font-weight: bold;
@ -745,6 +770,7 @@ header {
"tree name" "tree name"
"tree content" "tree content"
"tree checklist" "tree checklist"
"tree schedule"
"tree files" "tree files"
"tree blank" "tree blank"
; ;
@ -756,6 +782,7 @@ header {
min-content /* name */ min-content /* name */
min-content /* content */ min-content /* content */
min-content /* checklist */ min-content /* checklist */
min-content /* schedule */
min-content /* files */ min-content /* files */
1fr; /* blank */ 1fr; /* blank */
color: #fff; color: #fff;
@ -789,6 +816,7 @@ header {
"name" "name"
"content" "content"
"checklist" "checklist"
"schedule"
"files" "files"
"blank" "blank"
; ;
@ -800,6 +828,7 @@ header {
min-content /* name */ min-content /* name */
min-content /* content */ min-content /* content */
min-content /* checklist */ min-content /* checklist */
min-content /* schedule */
min-content /* files */ min-content /* files */
1fr; /* blank */ 1fr; /* blank */
#tree { display: none } #tree { display: none }
@ -863,6 +892,19 @@ header {
} }
} }
#schedule-events {
display: grid;
grid-template-columns: repeat(5, min-content);
grid-gap: 4px 12px;
margin: 32px;
color: #000;
white-space: nowrap;
.header {
font-weight: bold;
}
}
@media only screen and (max-width: 932px) { @media only screen and (max-width: 932px) {
#app.node { #app.node {
.layout-crumbs(); .layout-crumbs();