1 let assert = require('assert')
2 let jst_server = (await import('@ndcode/jst_server')).default
3 let XDate = require('xdate')
7 a.length === b.length && a.every((value, index) => value === b[index])
9 return async (env, head, body, scripts) => {
10 //let cart = await _require('/online_store/cart.jst')
11 let fa_arrow_circle_left = await env.site.get_min_svg('/_svg/fa_arrow-circle-left.svg')
12 let fa_bars = await env.site.get_min_svg('/_svg/fa_bars.svg')
13 let fa_times_circle = await env.site.get_min_svg('/_svg/fa_times-circle.svg')
14 let fa_envelope = await env.site.get_min_svg('/_svg/fa_envelope.svg')
15 let fa_unlock_alt = await env.site.get_min_svg('/_svg/fa_unlock-alt.svg')
16 let fa_search = await env.site.get_min_svg('/_svg/fa_search.svg')
17 let get_session = await _require('/_lib/get_session.jst')
18 //let icon_cart_small = await env.site.get_min_svg('/_svg/icon_cart_small.svg')
19 let icon_cross = await env.site.get_min_svg('/_svg/icon_cross.svg')
20 //let icon_tick = await env.site.get_min_svg('/_svg/icon_tick.svg')
21 let avatar_maker = await env.site.get_min_svg('/_svg/AvatarMaker.svg')
22 let page = await _require('/_lib/page.jst')
24 // initialize env.cart
27 // compute breadcrumbs from directories of the path
28 let component_names = env.parsed_url.pathname.split('/')
29 assert(component_names.length >= 2)
30 assert(component_names[0].length === 0)
31 assert(component_names[component_names.length - 1].length)
32 component_names = component_names.slice(1, -1)
34 let transaction = await env.site.database.Transaction()
36 let site_title, copyright
37 let component_titles // collects breadcrumb titles for current page
38 let menu_names, menu_titles // collects top level of menu for the sidebar
41 let root = await transaction.get()
43 let session = await get_session(env, root)
44 signed_in_as = await session.get_json('signed_in_as')
46 let globals = await root.get('globals')
47 site_title = await globals.get_json('site_title')
48 copyright = await globals.get_json('copyright')
50 let navigation = await root.get('navigation')
51 if (navigation === undefined)
52 throw new jst_server.Problem(
54 'Please import the navigation tree into the database.',
58 // this code is taken from get_navigation.jst and instrumented
60 component_titles = [await p.get_json('title')] // Home
61 for (let i = 0; i < component_names.length; ++i) {
62 let children = await p.get('children')
63 p = await children.get(component_names[i])
64 if (navigation === undefined)
65 throw new jst_server.Problem(
67 `Can't find the path "${
68 component_names.slice(0, i + 1).map(name => '/' + name).join('')
69 }" in the navigation tree.`,
72 component_titles.push(await p.get_json('title'))
75 // similar to above but walks the top level laterally (not deeply)
76 menu_names = await navigation.get_json('menu')
77 let children = await navigation.get('children')
78 menu_titles = [await navigation.get_json('title')] // Home
79 for (let i = 0; i < menu_names.length; ++i) {
80 let child = await children.get(menu_names[i])
81 if (child === undefined)
82 throw new jst_server.Problem(
84 `Can't find the path "/${menu_names[i]}" in the navigation tree.`
87 menu_titles.push(await child.get('title'))
90 feedback_draft = await session.get_json('feedback_draft')
91 if (feedback_draft === undefined || env.now >= feedback_draft.expires)
95 transaction.rollback()
98 // save breadcrumbs and their titles for breadcrumbs.jst
99 // note: component_titles.length === component_names.length + 1
100 // component_titles[0] corresponds to /, is 'Home' or similar
101 // component_titles[i] corresponds to component_names[i - 1], i >= 1
102 env.component_names = component_names
103 env.component_titles = component_titles
105 // note: menu_titles.length === menu_names.length + 1
106 // menu_titles[0] corresponds to /, is 'Home' or similar
107 // menu_titles[i] corresponds to menu_names[i - 1], i >= 1
108 // (sidebar has Home appearing at same level as its immediate children)
117 component_names.length >= 2 ? 1 : component_names.length
126 // extract top-level directory name
127 assert(env.parsed_url.pathname.slice(0, 1) === '/')
128 let index = env.parsed_url.pathname.indexOf('/', 1)
129 let dir = index === -1 ? '' : env.parsed_url.pathname.slice(1, index)
131 div.container-fluid {
133 div.col-md.sidebar-outer.sidebar-outer-collapsed#sidebar-outer {
134 nav.sidebar-inner.d-flex.flex-column#sidebar-inner {
136 div(style="width: 128px; height: 128px;") {
137 _out.push(avatar_maker)
145 span#sidebar-signed-in-status {
146 if (signed_in_as !== undefined)
152 if (signed_in_as !== undefined)
153 a#sidebar-sign-in(href="#" hidden) {'Sign in'}
155 a#sidebar-sign-in(href="#") {'Sign in'}
157 if (signed_in_as !== undefined)
158 a#sidebar-sign-up(href="/my_account/sign_up/index.html" hidden) {'Sign up'}
160 a#sidebar-sign-up(href="/my_account/sign_up/index.html") {'Sign up'}
162 if (signed_in_as !== undefined)
163 a#sidebar-sign-out(href="#") {'Sign out'}
165 a#sidebar-sign-out(href="#" hidden) {'Sign out'}
168 form.mb-4(action="/search/index.html") {
170 input.form-control(name="query" type="text" placeholder="Search" aria-describedby="search-button") {}
171 div.input-group-append {
172 button.btn.btn-outline-secondary#sidebar-search-button(type="submit") {
174 div.icon24-inner {_out.push(fa_search)}
181 // the active entry in the sidebar bar is based on which top-level
182 // page we are under, even if we are not directly on that page
183 // but one of its children, this may be unexpected as the active
184 // entry does not highlight on hover, but you can still click it;
185 // we determine here the path to the corresponding top-level page
186 let component_prefix = component_names.slice(0, 1)
188 for (let i = 0; i < menu_titles.length; ++i) {
189 // construct path to the top-level page about to be described
191 i === 0 ? [] : [menu_names[i - 1]]
192 let menu_prefix_path =
193 menu_prefix.map(name => '/' + name).join('') + '/index.html'
195 if (arrays_equal(menu_prefix, component_prefix))
196 div.nav-item.active {
197 a.nav-link.nav-link2.grid-gutter-background(href=menu_prefix_path) {
199 span.sr-only {' (current)'}
204 a.nav-link.nav-link2.grid-gutter-background(href=menu_prefix_path) {
209 div.nav-item.mt-auto {
210 a.nav-link.nav-link2.grid-gutter-background#sidebar-give-feedback(href="#") {'Give feedback'}
215 div.col-md.sidebar-content {
216 // the breadcrumbs have already been determined by sidebar.jst, as
217 // the HTML title is similar to the breadcrumbs (but without links)
218 let component_names = env.component_names
219 let component_titles = env.component_titles
221 // present component_titles as breadcrumbs, except last one as text
222 h2.page-header.grid-gutter-background.'py-2'.mb-0 {
223 button.btn.btn-outline-secondary.sidebar-toggle.mr-3#sidebar-toggle {
224 div.icon24-outer(style="top: -1px;") {
225 div.icon24-inner {_out.push(fa_bars)}
227 span.sr-only {'Navbar toggle'}
229 for (let i = 0; i < component_names.length; ++i) {
233 component_names.slice(0, i).map(name => '/' + name).join('')
235 ) {`${component_titles[i]}`}
240 `${component_titles[component_names.length]}`
245 footer.page-footer.grid-gutter-background.py-5 {
246 a(rel="license" href="http://creativecommons.org/licenses/by-sa/3.0/") {
247 img(alt="Creative Commons License" style="border-width:0;" src="/images/by-sa_3.0_88x31.png") {}
251 a(href="https://git.ndcode.org/public/nick_site.git") {
254 ' and licensed under a '
255 a(rel="license" href="http://creativecommons.org/licenses/by-sa/3.0/") {
256 'Creative Commons Attribution-ShareAlike 3.0 Unported License'
261 p.mb-0 {`Copyright © ${new XDate(env.now).getUTCFullYear()} ${copyright}.`}
264 div.col-md.sidebar-dummy {}
269 div.modal#sidebar-sign-in-modal(tabindex="-1") {
273 span.h4.modal-title {'Sign in'}
276 form.mb-2#sidebar-sign-in-form {
280 label.form-label(for="sidebar-sign-in-email") {'Email'}
281 input.form-control#sidebar-sign-in-email(type="email" required maxlength=256) {}
282 div.invalid-feedback {'Please enter your account\'s email address.'}
289 label.form-label(for="sidebar-sign-in-password") {'Password'}
290 input.form-control#sidebar-sign-in-password(type="password" required minlength=8 maxlength=256) {}
291 div.invalid-feedback {'Please enter at least 8 characters.'}
299 a(href="/my_account/sign_up/index.html") {'Sign up'}
304 a(href="/my_account/password_reset/index.html") {'Password reset'}
307 div.alert.alert-danger.'mt-3'.mb-0#sidebar-sign-in-alert(hidden) {}
310 button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
311 div.icon24-outer.mr-2 {
312 div.icon24-inner {_out.push(fa_arrow_circle_left)}
316 button.btn.btn-primary#sidebar-sign-in-sign-in(type="button") {
317 div.icon24-outer.mr-2#sidebar-sign-in-icon {
318 div.icon24-inner {_out.push(fa_unlock_alt)}
320 //div.icon24-outer.mr-2#sidebar-sign-in-tick(hidden) {
321 // div.icon24-inner {_out.push(icon_tick)}
323 div.icon24-outer.mr-2#sidebar-sign-in-cross(hidden) {
324 div.icon24-inner {_out.push(icon_cross)}
326 div.icon24-outer.mr-2#sidebar-sign-in-spinner(hidden) {
328 div.spinner-border.spinner-border-sm(role="status") {}
338 div.modal#sidebar-feedback-modal(tabindex="-1") {
342 span.h4.modal-title {'Give feedback'}
346 'Did you notice something not quite right, or just want to share your impression of this page?'
349 form#sidebar-feedback-form {
353 label.form-label(for="sidebar-feedback-message") {'Message'}
354 textarea.form-control#sidebar-feedback-message(placeholder="I noticed that..." required rows=4 maxlength=65536) {
356 `${feedback_draft.message}`
358 div.invalid-feedback {'Please let us have your thoughts.'}
364 div.alert.alert-danger.'mt-3'.mb-0#sidebar-feedback-alert(hidden) {}
367 button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
368 div.icon24-outer.mr-2 {
369 div.icon24-inner {_out.push(fa_arrow_circle_left)}
374 button.btn.btn-primary#sidebar-feedback-send-message(type="button") {
375 div.icon24-outer.mr-2#sidebar-feedback-icon {
376 div.icon24-inner {_out.push(fa_envelope)}
378 //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
379 // div.icon24-inner {_out.push(icon_tick)}
381 div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
382 div.icon24-inner {_out.push(icon_cross)}
384 div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
386 div.spinner-border.spinner-border-sm(role="status") {}
392 button.btn.btn-primary#sidebar-feedback-send-message(type="button" disabled) {
393 div.icon24-outer.mr-2#sidebar-feedback-icon {
394 div.icon24-inner {_out.push(fa_envelope)}
396 //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
397 // div.icon24-inner {_out.push(icon_tick)}
399 div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
400 div.icon24-inner {_out.push(icon_cross)}
402 div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
404 div.spinner-border.spinner-border-sm(role="status") {}
414 div.modal#sidebar-message-modal(tabindex="-1") {
418 span.h4.modal-title {'Message'}
420 div.modal-body#sidebar-message-modal-message {
423 button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
424 div.icon24-outer.mr-2 {
425 div.icon24-inner {_out.push(fa_times_circle)}
436 script(src="/js/utils.js") {}
439 // this function can be overridden in a further script
440 function sign_in_out(status) {
444 document.addEventListener(
447 let id_sidebar_feedback_alert = document.getElementById('sidebar-feedback-alert')
448 let id_sidebar_feedback_cross = document.getElementById('sidebar-feedback-cross')
449 let id_sidebar_feedback_form = document.getElementById('sidebar-feedback-form')
450 let id_sidebar_feedback_icon = document.getElementById('sidebar-feedback-icon')
451 let id_sidebar_feedback_message = document.getElementById('sidebar-feedback-message')
452 let id_sidebar_feedback_modal = document.getElementById('sidebar-feedback-modal')
453 let id_sidebar_feedback_send_message = document.getElementById('sidebar-feedback-send-message')
454 let id_sidebar_feedback_spinner = document.getElementById('sidebar-feedback-spinner')
455 let id_sidebar_feedback_tick = document.getElementById('sidebar-feedback-tick')
456 let id_sidebar_give_feedback = document.getElementById('sidebar-give-feedback')
457 let id_sidebar_inner = document.getElementById('sidebar-inner')
458 let id_sidebar_message_modal = document.getElementById('sidebar-message-modal')
459 let id_sidebar_message_modal_message = document.getElementById('sidebar-message-modal-message')
460 let id_sidebar_outer = document.getElementById('sidebar-outer')
461 let id_sidebar_search_button = document.getElementById('sidebar-search-button')
462 let id_sidebar_sign_in = document.getElementById('sidebar-sign-in')
463 let id_sidebar_sign_in_alert = document.getElementById('sidebar-sign-in-alert')
464 let id_sidebar_sign_in_cross = document.getElementById('sidebar-sign-in-cross')
465 let id_sidebar_sign_in_email = document.getElementById('sidebar-sign-in-email')
466 let id_sidebar_sign_in_form = document.getElementById('sidebar-sign-in-form')
467 let id_sidebar_sign_in_icon = document.getElementById('sidebar-sign-in-icon')
468 let id_sidebar_sign_in_modal = document.getElementById('sidebar-sign-in-modal')
469 let id_sidebar_sign_in_password = document.getElementById('sidebar-sign-in-password')
470 let id_sidebar_sign_in_sign_in = document.getElementById('sidebar-sign-in-sign-in')
471 let id_sidebar_sign_in_spinner = document.getElementById('sidebar-sign-in-spinner')
472 let id_sidebar_sign_in_tick = document.getElementById('sidebar-sign-in-tick')
473 let id_sidebar_sign_out = document.getElementById('sidebar-sign-out')
474 let id_sidebar_sign_up = document.getElementById('sidebar-sign-up')
475 let id_sidebar_signed_in_status = document.getElementById('sidebar-signed-in-status')
476 let id_sidebar_toggle = document.getElementById('sidebar-toggle')
479 id_sidebar_sign_in.addEventListener(
482 id_sidebar_sign_in_email.value = ''
483 id_sidebar_sign_in_password.value = ''
484 id_sidebar_sign_in_sign_in.disabled = true
485 $('#sidebar-sign-in-modal').modal('show')
489 $('#sidebar-sign-in-modal').on(
491 () => {id_sidebar_sign_in_email.focus()}
494 let sign_in_edited = () => {
495 id_sidebar_sign_in_sign_in.disabled =
496 id_sidebar_sign_in_email.value.length === 0 &&
497 id_sidebar_sign_in_password.value.length === 0
498 id_sidebar_sign_in_icon.hidden = false
499 //id_sidebar_sign_in_tick.hidden = true
500 id_sidebar_sign_in_cross.hidden = true
501 id_sidebar_sign_in_spinner.hidden = true
502 id_sidebar_sign_in_alert.hidden = true
505 id_sidebar_sign_in_email.addEventListener(
509 id_sidebar_sign_in_password.addEventListener(
514 id_sidebar_sign_in_sign_in.addEventListener(
517 id_sidebar_sign_in_icon.hidden = false
518 //id_sidebar_sign_in_tick.hidden = true
519 id_sidebar_sign_in_cross.hidden = true
520 id_sidebar_sign_in_spinner.hidden = true
521 // the below causes an ugly flicker, so just keep the alert
522 //id_sidebar_sign_in_alert.hidden = true
524 if (!id_sidebar_sign_in_form.checkValidity()) {
525 id_sidebar_sign_in_form.classList.add('was-validated');
527 id_sidebar_sign_in_icon.hidden = true
528 id_sidebar_sign_in_cross.hidden = false
531 id_sidebar_sign_in_form.classList.remove('was-validated');
533 let email = id_sidebar_sign_in_email.value.slice(0, 256).toLowerCase()
535 id_sidebar_sign_in_icon.hidden = true
536 id_sidebar_sign_in_spinner.hidden = false
539 '/api/account/sign_in.json',
541 id_sidebar_sign_in_password.value.slice(0, 256)
545 let problem = Problem.from(error)
547 if (problem.title === 'Email not yet verified') {
548 location.href = `/my_account/send_verification_email?email=${encodeURIComponent(email)}`
552 id_sidebar_sign_in_cross.hidden = false
553 id_sidebar_sign_in_spinner.hidden = true
555 id_sidebar_sign_in_alert.textContent = problem.detail
556 //id_sidebar_sign_in_alert.classList.remove('alert-success')
557 //id_sidebar_sign_in_alert.classList.add('alert-danger')
558 id_sidebar_sign_in_alert.hidden = false
561 //id_sidebar_sign_in_tick.hidden = false
562 //id_sidebar_sign_in_spinner.hidden = true
563 //id_sidebar_sign_in_alert.textContent = `You are now signed in as "${email}".`
564 //id_sidebar_sign_in_alert.classList.add('alert-success')
565 //id_sidebar_sign_in_alert.classList.remove('alert-danger')
566 //id_sidebar_sign_in_alert.hidden = false
568 if (sign_in_out(true))
569 // if location has been changed, leave the spinner and do
570 // not show status/dialog, as it causes an annoying flicker
573 id_sidebar_signed_in_status.textContent = 'Signed in.'
574 id_sidebar_sign_in.hidden = true
575 id_sidebar_sign_up.hidden = true
576 id_sidebar_sign_out.hidden = false
578 id_sidebar_sign_in_icon.hidden = false
579 id_sidebar_sign_in_spinner.hidden = true
580 id_sidebar_sign_in_alert.hidden = true
581 id_sidebar_message_modal_message.textContent = `You are now signed in as "${email}".`
582 $('#sidebar-sign-in-modal').modal('hide')
583 $('#sidebar-message-modal').modal('show')
588 id_sidebar_sign_out.addEventListener(
593 '/api/account/sign_out.json'
597 let problem = Problem.from(error)
599 id_sidebar_message_modal_message.textContent = problem.detail
600 $('#sidebar-sign-in-modal').modal('hide')
601 $('#sidebar-message-modal').modal('show')
605 if (sign_in_out(false))
606 // if location has been changed, leave the spinner and do
607 // not show status/dialog, as it causes an annoying flicker
610 id_sidebar_signed_in_status.textContent = 'Signed out.'
611 id_sidebar_sign_in.hidden = false
612 id_sidebar_sign_up.hidden = false
613 id_sidebar_sign_out.hidden = true
615 id_sidebar_message_modal_message.textContent = `You are now signed out.`
616 $('#sidebar-sign-in-modal').modal('hide')
617 $('#sidebar-message-modal').modal('show')
622 id_sidebar_give_feedback.addEventListener(
625 // hack to move cursor to end of textarea
626 let temp = id_sidebar_feedback_message.value
627 id_sidebar_feedback_message.value = ''
628 id_sidebar_feedback_message.value = temp
630 $('#sidebar-feedback-modal').modal('show')
635 $('#sidebar-feedback-modal').on(
637 () => {id_sidebar_feedback_message.focus()}
640 let feedback_input_semaphore = new BinarySemaphore(false)
644 await feedback_input_semaphore.acquire()
645 await new Promise(resolve => setTimeout(resolve, 3000))
646 feedback_input_semaphore.try_acquire()
648 '/api/feedback/set_draft.json',
649 id_sidebar_feedback_message.value.length === 0 ?
652 message: id_sidebar_feedback_message.value.slice(0, 65536)
657 )() // ignore returned promise (start thread)
659 let feedback_edited = () => {
660 feedback_input_semaphore.release()
662 id_sidebar_feedback_send_message.disabled =
663 id_sidebar_feedback_message.value.length === 0
664 id_sidebar_feedback_icon.hidden = false
665 //id_sidebar_feedback_tick.hidden = true
666 id_sidebar_feedback_cross.hidden = true
667 id_sidebar_feedback_spinner.hidden = true
668 id_sidebar_feedback_alert.hidden = true
671 id_sidebar_feedback_message.addEventListener(
676 id_sidebar_feedback_send_message.addEventListener(
679 id_sidebar_feedback_icon.hidden = false
680 //id_sidebar_feedback_tick.hidden = true
681 id_sidebar_feedback_cross.hidden = true
682 id_sidebar_feedback_spinner.hidden = true
683 // the below causes an ugly flicker, so just keep the alert
684 //id_sidebar_feedback_alert.hidden = true
686 if (!id_sidebar_feedback_form.checkValidity()) {
687 id_sidebar_feedback_form.classList.add('was-validated');
689 id_sidebar_feedback_icon.hidden = true
690 id_sidebar_feedback_cross.hidden = false
693 id_sidebar_feedback_form.classList.remove('was-validated');
695 id_sidebar_feedback_icon.hidden = true
696 id_sidebar_feedback_spinner.hidden = false
699 '/api/feedback/send_message.json',
701 id_sidebar_feedback_message.value.slice(0, 65536)
705 let problem = Problem.from(error)
707 id_sidebar_feedback_cross.hidden = false
708 id_sidebar_feedback_spinner.hidden = true
710 id_sidebar_feedback_alert.textContent = problem.detail
711 //id_sidebar_feedback_alert.classList.remove('alert-success')
712 //id_sidebar_feedback_alert.classList.add('alert-danger')
713 id_sidebar_feedback_alert.hidden = false
716 //id_sidebar_feedback_tick.hidden = false
717 //id_sidebar_feedback_spinner.hidden = true
718 //id_sidebar_feedback_alert.alertContent = 'We have received your message. We will be in touch as soon as possible.'
719 //id_sidebar_feedback_alert.classList.add('alert-success')
720 //id_sidebar_feedback_alert.classList.remove('alert-danger')
721 //id_sidebar_feedback_alert.hidden = false
723 id_sidebar_feedback_icon.hidden = false
724 id_sidebar_feedback_spinner.hidden = true
725 id_sidebar_feedback_alert.hidden = true
726 id_sidebar_message_modal_message.textContent = 'Thanks! We have received your feedback.'
727 $('#sidebar-feedback-modal').modal('hide')
728 $('#sidebar-message-modal').modal('show')
733 let sidebar_outer_computed_style = window.getComputedStyle(
736 let sidebar_toggle_computed_style = window.getComputedStyle(
739 let sidebar_is_collapsed =
741 sidebar_toggle_computed_style.display !== 'none' &&
742 id_sidebar_outer.classList.contains(
743 'sidebar-outer-collapsed'
745 let sidebar_collapse_update = () => {
746 if (sidebar_outer_computed_style.position === 'sticky') { // md and up
747 id_sidebar_outer.style.flexBasis =
748 sidebar_is_collapsed() ?
750 `${id_sidebar_inner.clientWidth}px`
751 id_sidebar_outer.style.removeProperty('height')
754 id_sidebar_outer.style.height =
755 sidebar_is_collapsed() ?
757 `${id_sidebar_inner.clientHeight}px`
758 id_sidebar_outer.style.removeProperty('flex-basis')
761 window.addEventListener('resize', sidebar_collapse_update)
762 sidebar_collapse_update()
764 id_sidebar_outer.addEventListener(
767 // transitions are only allowed after clicking collapse button,
768 // otherwise they can be triggered by media queries on resize
769 id_sidebar_outer.style.removeProperty('transition-property')
773 id_sidebar_toggle.addEventListener(
777 id_sidebar_outer.classList.contains(
778 'sidebar-outer-collapsed'
781 id_sidebar_outer.classList.remove(
782 'sidebar-outer-collapsed'
785 id_sidebar_outer.classList.add(
786 'sidebar-outer-collapsed'
788 id_sidebar_outer.style.transitionProperty =
789 sidebar_outer_computed_style.position === 'sticky' ?
790 'flex-basis' : // md and up
792 sidebar_collapse_update()