Upgrade to nick_site commit f981fa57, adds alerts and inactive sidebar option
[ndcode_site.git] / _lib / sidebar.jst
diff --git a/_lib/sidebar.jst b/_lib/sidebar.jst
new file mode 100644 (file)
index 0000000..5f108e2
--- /dev/null
@@ -0,0 +1,802 @@
+let assert = require('assert')
+let jst_server = (await import('@ndcode/jst_server')).default
+let XDate = require('xdate')
+
+let arrays_equal =
+  (a, b) =>
+    a.length === b.length && a.every((value, index) => value === b[index])
+
+return async (env, head, body, scripts) => {
+  //let cart = await _require('/online_store/cart.jst')
+  let fa_arrow_circle_left = await env.site.get_min_svg('/_svg/fa_arrow-circle-left.svg')
+  let fa_bars = await env.site.get_min_svg('/_svg/fa_bars.svg')
+  let fa_times_circle = await env.site.get_min_svg('/_svg/fa_times-circle.svg')
+  let fa_envelope = await env.site.get_min_svg('/_svg/fa_envelope.svg')
+  let fa_unlock_alt = await env.site.get_min_svg('/_svg/fa_unlock-alt.svg')
+  let fa_search = await env.site.get_min_svg('/_svg/fa_search.svg')
+  let get_session = await _require('/_lib/get_session.jst')
+  //let icon_cart_small = await env.site.get_min_svg('/_svg/icon_cart_small.svg')
+  let icon_cross = await env.site.get_min_svg('/_svg/icon_cross.svg')
+  //let icon_tick = await env.site.get_min_svg('/_svg/icon_tick.svg')
+  let avatar_maker = await env.site.get_min_svg('/_svg/AvatarMaker.svg')
+  let page = await _require('/_lib/page.jst')
+
+  // initialize env.cart
+  //await cart(env)
+
+  // compute breadcrumbs from directories of the path
+  let component_names = env.parsed_url.pathname.split('/')
+  assert(component_names.length >= 2)
+  assert(component_names[0].length === 0)
+  assert(component_names[component_names.length - 1].length)
+  component_names = component_names.slice(1, -1)
+
+  let transaction = await env.site.database.Transaction()
+  let signed_in_as
+  let site_title, copyright
+  let component_titles // collects breadcrumb titles for current page
+  let menu_names, menu_titles // collects top level of menu for the sidebar
+  let feedback_draft
+  try {
+    let root = await transaction.get()
+
+    let session = await get_session(env, root)
+    signed_in_as = await session.get_json('signed_in_as')
+
+    let globals = await root.get('globals')
+    site_title = await globals.get_json('site_title')
+    copyright = await globals.get_json('copyright')
+
+    let navigation = await root.get('navigation')
+    if (navigation === undefined)
+      throw new jst_server.Problem(
+        'Navigation error',
+        'Please import the navigation tree into the database.',
+        508
+      )
+
+    // this code is taken from get_navigation.jst and instrumented
+    let p = navigation
+    component_titles = [await p.get_json('title')] // Home
+    for (let i = 0; i < component_names.length; ++i) {
+      let children = await p.get('children')
+      p = await children.get(component_names[i])
+      if (navigation === undefined)
+        throw new jst_server.Problem(
+          'Navigation error',
+          `Can't find the path "${
+            component_names.slice(0, i + 1).map(name => '/' + name).join('')
+          }" in the navigation tree.`,
+          508
+        )
+      component_titles.push(await p.get_json('title'))
+    }
+
+    // similar to above but walks the top level laterally (not deeply)
+    menu_names = await navigation.get_json('menu')
+    let children = await navigation.get('children')
+    menu_titles = [await navigation.get_json('title')] // Home
+    for (let i = 0; i < menu_names.length; ++i) {
+      let child = await children.get(menu_names[i])
+      if (child === undefined)
+        throw new jst_server.Problem(
+          'Navigation error',
+          `Can't find the path "/${menu_names[i]}" in the navigation tree.`
+          508
+        )
+      menu_titles.push(await child.get('title'))
+    }
+
+    feedback_draft = await session.get_json('feedback_draft')
+    if (feedback_draft === undefined || env.now >= feedback_draft.expires)
+      feedback_draft = null
+  }
+  finally {
+    transaction.rollback()
+  }
+
+  // save breadcrumbs and their titles for breadcrumbs.jst
+  // note: component_titles.length === component_names.length + 1
+  // component_titles[0] corresponds to /, is 'Home' or similar
+  // component_titles[i] corresponds to component_names[i - 1], i >= 1
+  env.component_names = component_names
+  env.component_titles = component_titles
+
+  // note: menu_titles.length === menu_names.length + 1
+  // menu_titles[0] corresponds to /, is 'Home' or similar
+  // menu_titles[i] corresponds to menu_names[i - 1], i >= 1
+  // (sidebar has Home appearing at same level as its immediate children)
+
+  await page(
+    env,
+    // head
+    async _out => {
+      title {
+        `${site_title}: ${
+          component_titles[
+            component_names.length >= 2 ? 1 : component_names.length
+          ]
+        }`
+      }
+
+      await head(_out)
+    },
+    // body
+    async _out => {
+      // extract top-level directory name
+      assert(env.parsed_url.pathname.slice(0, 1) === '/')
+      let index = env.parsed_url.pathname.indexOf('/', 1)
+      let dir = index === -1 ? '' : env.parsed_url.pathname.slice(1, index)
+
+      div.container-fluid {
+        div.row {
+          div.col-md.sidebar-outer.sidebar-outer-collapsed#sidebar-outer {
+            nav.sidebar-inner.d-flex.flex-column#sidebar-inner {
+              div.mb-4 {
+                div(style="width: 128px; height: 128px;") {
+                  _out.push(avatar_maker)
+                }
+                b.h1 {
+                  `${site_title}`
+                }
+              }
+
+              div.mb-2 {
+                span#sidebar-signed-in-status {
+                  if (signed_in_as !== undefined)
+                    'Signed in.'
+                  else
+                    'Signed out.'
+                }
+                ' '
+                if (signed_in_as !== undefined)
+                  a#sidebar-sign-in(href="#" hidden) {'Sign in'}
+                else
+                  a#sidebar-sign-in(href="#") {'Sign in'}
+                ' '
+                if (signed_in_as !== undefined)
+                  a#sidebar-sign-up(href="/my_account/sign_up/index.html" hidden) {'Sign up'}
+                else
+                  a#sidebar-sign-up(href="/my_account/sign_up/index.html") {'Sign up'}
+                ' '
+                if (signed_in_as !== undefined)
+                  a#sidebar-sign-out(href="#") {'Sign out'}
+                else
+                  a#sidebar-sign-out(href="#" hidden) {'Sign out'}
+              }
+
+              form.mb-4(action="/search/index.html") {
+                div.input-group {
+                  input.form-control(name="query" type="text" placeholder="Search" aria-describedby="search-button") {}
+                  div.input-group-append {
+                    button.btn.btn-outline-secondary#sidebar-search-button(type="submit") {
+                      div.icon24-outer {
+                        div.icon24-inner {_out.push(fa_search)}
+                      }
+                    }
+                  }
+                }
+              }
+
+              // the active entry in the sidebar bar is based on which top-level
+              // page we are under, even if we are not directly on that page
+              // but one of its children, this may be unexpected as the active
+              // entry does not highlight on hover, but you can still click it;
+              // we determine here the path to the corresponding top-level page
+              let component_prefix = component_names.slice(0, 1)
+
+              for (let i = 0; i < menu_titles.length; ++i) {
+                // construct path to the top-level page about to be described
+                let menu_prefix =
+                  i === 0 ? [] : [menu_names[i - 1]]
+                let menu_prefix_path =
+                  menu_prefix.map(name => '/' + name).join('') + '/index.html'
+
+                if (arrays_equal(menu_prefix, component_prefix))
+                  div.nav-item.active {
+                    a.nav-link.nav-link2.grid-gutter-background(href=menu_prefix_path) {
+                      `${menu_titles[i]}`
+                      span.sr-only {' (current)'}
+                    }
+                  }
+                else
+                  div.nav-item {
+                    a.nav-link.nav-link2.grid-gutter-background(href=menu_prefix_path) {
+                      `${menu_titles[i]}`
+                    }
+                  }
+              }
+              div.nav-item.mt-auto {
+                a.nav-link.nav-link2.grid-gutter-background#sidebar-give-feedback(href="#") {'Give feedback'}
+              }
+            }
+          }
+
+          div.col-md.sidebar-content {
+            // the breadcrumbs have already been determined by sidebar.jst, as
+            // the HTML title is similar to the breadcrumbs (but without links)
+            let component_names = env.component_names
+            let component_titles = env.component_titles
+
+            // present component_titles as breadcrumbs, except last one as text
+            h2.page-header.grid-gutter-background.'py-2'.mb-0 {
+              button.btn.btn-outline-secondary.sidebar-toggle.mr-3#sidebar-toggle {
+                div.icon24-outer(style="top: -1px;") {
+                  div.icon24-inner {_out.push(fa_bars)}
+                }
+                span.sr-only {'Navbar toggle'}
+              }
+              for (let i = 0; i < component_names.length; ++i) {
+                a.h4(
+                  href=
+                    `${
+                      component_names.slice(0, i).map(name => '/' + name).join('')
+                    }/index.html`
+                ) {`${component_titles[i]}`}
+                ' '
+                span.h5 {'>'}
+                ' '
+              }
+              `${component_titles[component_names.length]}`
+            }
+
+            await body(_out)
+
+            footer.page-footer.grid-gutter-background.py-5 {
+              a(rel="license" href="http://creativecommons.org/licenses/by-sa/3.0/") {
+                img(alt="Creative Commons License" style="border-width:0;" src="/images/by-sa_3.0_88x31.png") {}
+              }
+              p {
+                'This website is '
+                a(href="https://git.ndcode.org/public/nick_site.git") {
+                  'open source'
+                }
+                ' and licensed under a '
+                a(rel="license" href="http://creativecommons.org/licenses/by-sa/3.0/") {
+                  'Creative Commons Attribution-ShareAlike 3.0 Unported License'
+                }
+                '.'
+              }
+
+              p.mb-0 {`Copyright © ${new XDate(env.now).getUTCFullYear()} ${copyright}.`}
+            }
+          }
+          div.col-md.sidebar-dummy {}
+        }
+      }
+
+      // hidden part
+      div.modal#sidebar-sign-in-modal(tabindex="-1") {
+        div.modal-dialog {
+          div.modal-content {
+            div.modal-header {
+              span.h4.modal-title {'Sign in'}
+            }
+            div.modal-body {
+              form.mb-2#sidebar-sign-in-form {
+                div.row {
+                  div.col-md-12 {
+                    div.form-group {
+                      label.form-label(for="sidebar-sign-in-email") {'Email'}
+                      input.form-control#sidebar-sign-in-email(type="email" required maxlength=256) {}
+                      div.invalid-feedback {'Please enter your account\'s email address.'}
+                    }
+                  }
+                }
+                div.row {
+                  div.col-md-12 {
+                    div.form-group {
+                      label.form-label(for="sidebar-sign-in-password") {'Password'}
+                      input.form-control#sidebar-sign-in-password(type="password" required minlength=8 maxlength=256) {}
+                      div.invalid-feedback {'Please enter at least 8 characters.'}
+                    }
+                  }
+                }
+              }
+
+              p {
+                'No account yet? '
+                a(href="/my_account/sign_up/index.html") {'Sign up'}
+              }
+
+              p.mb-0 {
+                'Forgot password? '
+                a(href="/my_account/password_reset/index.html") {'Password reset'}
+              }
+
+              div.alert.alert-danger.'mt-3'.mb-0#sidebar-sign-in-alert(hidden) {}
+            }
+            div.modal-footer {
+              button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
+                div.icon24-outer.mr-2 {
+                  div.icon24-inner {_out.push(fa_arrow_circle_left)}
+                }
+                'Back'
+              }
+              button.btn.btn-primary#sidebar-sign-in-sign-in(type="button") {
+                div.icon24-outer.mr-2#sidebar-sign-in-icon {
+                  div.icon24-inner {_out.push(fa_unlock_alt)}
+                }
+                //div.icon24-outer.mr-2#sidebar-sign-in-tick(hidden) {
+                //  div.icon24-inner {_out.push(icon_tick)}
+                //}
+                div.icon24-outer.mr-2#sidebar-sign-in-cross(hidden) {
+                  div.icon24-inner {_out.push(icon_cross)}
+                }
+                div.icon24-outer.mr-2#sidebar-sign-in-spinner(hidden) {
+                  div.icon24-inner {
+                    div.spinner-border.spinner-border-sm(role="status") {}
+                  }
+                }
+                'Sign in'
+              }
+            }
+          }
+        }
+      }
+
+      div.modal#sidebar-feedback-modal(tabindex="-1") {
+        div.modal-dialog {
+          div.modal-content {
+            div.modal-header {
+              span.h4.modal-title {'Give feedback'}
+            }
+            div.modal-body {
+              p {
+                'Did you notice something not quite right, or just want to share your impression of this page?'
+              }
+
+              form#sidebar-feedback-form {
+                div.row {
+                  div.col-md-12 {
+                    div.form-group {
+                      label.form-label(for="sidebar-feedback-message") {'Message'}
+                      textarea.form-control#sidebar-feedback-message(placeholder="I noticed that..." required rows=4 maxlength=65536) {
+                        if (feedback_draft)
+                          `${feedback_draft.message}`
+                      }
+                      div.invalid-feedback {'Please let us have your thoughts.'}
+                    }
+                  }
+                }
+              }
+
+              div.alert.alert-danger.'mt-3'.mb-0#sidebar-feedback-alert(hidden) {}
+            }
+            div.modal-footer {
+              button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
+                div.icon24-outer.mr-2 {
+                  div.icon24-inner {_out.push(fa_arrow_circle_left)}
+                }
+                'Back'
+              }
+              if (feedback_draft)
+                button.btn.btn-primary#sidebar-feedback-send-message(type="button") {
+                  div.icon24-outer.mr-2#sidebar-feedback-icon {
+                    div.icon24-inner {_out.push(fa_envelope)}
+                  }
+                  //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
+                  //  div.icon24-inner {_out.push(icon_tick)}
+                  //}
+                  div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
+                    div.icon24-inner {_out.push(icon_cross)}
+                  }
+                  div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
+                    div.icon24-inner {
+                      div.spinner-border.spinner-border-sm(role="status") {}
+                    }
+                  }
+                  'Send message'
+                }
+              else
+                button.btn.btn-primary#sidebar-feedback-send-message(type="button" disabled) {
+                  div.icon24-outer.mr-2#sidebar-feedback-icon {
+                    div.icon24-inner {_out.push(fa_envelope)}
+                  }
+                  //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
+                  //  div.icon24-inner {_out.push(icon_tick)}
+                  //}
+                  div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
+                    div.icon24-inner {_out.push(icon_cross)}
+                  }
+                  div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
+                    div.icon24-inner {
+                      div.spinner-border.spinner-border-sm(role="status") {}
+                    }
+                  }
+                  'Send message'
+                }
+            }
+          }
+        }
+      }
+
+      div.modal#sidebar-message-modal(tabindex="-1") {
+        div.modal-dialog {
+          div.modal-content {
+            div.modal-header {
+              span.h4.modal-title {'Message'}
+            }
+            div.modal-body#sidebar-message-modal-message {
+            }
+            div.modal-footer {
+              button.btn.btn-outline-secondary(type="button" data-dismiss="modal") {
+                div.icon24-outer.mr-2 {
+                  div.icon24-inner {_out.push(fa_times_circle)}
+                }
+                'Close'
+              }
+            }
+          }
+        }
+      }
+    },
+    // scripts
+    async _out => {
+      script(src="/js/utils.js") {}
+
+      script {
+        // this function can be overridden in a further script
+        function sign_in_out(status) {
+          return false
+        }
+
+        document.addEventListener(
+          'DOMContentLoaded',
+          () => {
+            let id_sidebar_feedback_alert = document.getElementById('sidebar-feedback-alert')
+            let id_sidebar_feedback_cross = document.getElementById('sidebar-feedback-cross')
+            let id_sidebar_feedback_form = document.getElementById('sidebar-feedback-form')
+            let id_sidebar_feedback_icon = document.getElementById('sidebar-feedback-icon')
+            let id_sidebar_feedback_message = document.getElementById('sidebar-feedback-message')
+            let id_sidebar_feedback_modal = document.getElementById('sidebar-feedback-modal')
+            let id_sidebar_feedback_send_message = document.getElementById('sidebar-feedback-send-message')
+            let id_sidebar_feedback_spinner = document.getElementById('sidebar-feedback-spinner')
+            let id_sidebar_feedback_tick = document.getElementById('sidebar-feedback-tick')
+            let id_sidebar_give_feedback = document.getElementById('sidebar-give-feedback')
+            let id_sidebar_inner = document.getElementById('sidebar-inner')
+            let id_sidebar_message_modal = document.getElementById('sidebar-message-modal')
+            let id_sidebar_message_modal_message = document.getElementById('sidebar-message-modal-message')
+            let id_sidebar_outer = document.getElementById('sidebar-outer')
+            let id_sidebar_search_button = document.getElementById('sidebar-search-button')
+            let id_sidebar_sign_in = document.getElementById('sidebar-sign-in')
+            let id_sidebar_sign_in_alert = document.getElementById('sidebar-sign-in-alert')
+            let id_sidebar_sign_in_cross = document.getElementById('sidebar-sign-in-cross')
+            let id_sidebar_sign_in_email = document.getElementById('sidebar-sign-in-email')
+            let id_sidebar_sign_in_form = document.getElementById('sidebar-sign-in-form')
+            let id_sidebar_sign_in_icon = document.getElementById('sidebar-sign-in-icon')
+            let id_sidebar_sign_in_modal = document.getElementById('sidebar-sign-in-modal')
+            let id_sidebar_sign_in_password = document.getElementById('sidebar-sign-in-password')
+            let id_sidebar_sign_in_sign_in = document.getElementById('sidebar-sign-in-sign-in')
+            let id_sidebar_sign_in_spinner = document.getElementById('sidebar-sign-in-spinner')
+            let id_sidebar_sign_in_tick = document.getElementById('sidebar-sign-in-tick')
+            let id_sidebar_sign_out = document.getElementById('sidebar-sign-out')
+            let id_sidebar_sign_up = document.getElementById('sidebar-sign-up')
+            let id_sidebar_signed_in_status = document.getElementById('sidebar-signed-in-status')
+            let id_sidebar_toggle = document.getElementById('sidebar-toggle')
+
+            // sign in form
+            id_sidebar_sign_in.addEventListener(
+              'click',
+              () => {
+                id_sidebar_sign_in_email.value = ''
+                id_sidebar_sign_in_password.value = ''
+                id_sidebar_sign_in_sign_in.disabled = true
+                $('#sidebar-sign-in-modal').modal('show')
+              }
+            )
+
+            $('#sidebar-sign-in-modal').on(
+              'shown.bs.modal',
+              () => {id_sidebar_sign_in_email.focus()}
+            )
+
+            let sign_in_edited = () => {
+              id_sidebar_sign_in_sign_in.disabled =
+                id_sidebar_sign_in_email.value.length === 0 &&
+                  id_sidebar_sign_in_password.value.length === 0
+              id_sidebar_sign_in_icon.hidden = false
+              //id_sidebar_sign_in_tick.hidden = true
+              id_sidebar_sign_in_cross.hidden = true
+              id_sidebar_sign_in_spinner.hidden = true
+              id_sidebar_sign_in_alert.hidden = true
+            }
+
+            id_sidebar_sign_in_email.addEventListener(
+              'input',
+              sign_in_edited
+            )
+            id_sidebar_sign_in_password.addEventListener(
+              'input',
+              sign_in_edited
+            )
+
+            id_sidebar_sign_in_sign_in.addEventListener(
+              'click',
+              async () => {
+                id_sidebar_sign_in_icon.hidden = false
+                //id_sidebar_sign_in_tick.hidden = true
+                id_sidebar_sign_in_cross.hidden = true
+                id_sidebar_sign_in_spinner.hidden = true
+                // the below causes an ugly flicker, so just keep the alert
+                //id_sidebar_sign_in_alert.hidden = true
+
+                if (!id_sidebar_sign_in_form.checkValidity()) {
+                  id_sidebar_sign_in_form.classList.add('was-validated');
+
+                  id_sidebar_sign_in_icon.hidden = true
+                  id_sidebar_sign_in_cross.hidden = false
+                  return
+                }
+                id_sidebar_sign_in_form.classList.remove('was-validated');
+
+                let email = id_sidebar_sign_in_email.value.slice(0, 256).toLowerCase()
+
+                id_sidebar_sign_in_icon.hidden = true
+                id_sidebar_sign_in_spinner.hidden = false
+                try {
+                  await api_call(
+                    '/api/account/sign_in.json',
+                    email,
+                    id_sidebar_sign_in_password.value.slice(0, 256)
+                  )
+                }
+                catch (error) {
+                  let problem = Problem.from(error)
+
+                  if (problem.title === 'Email not yet verified') {
+                    location.href = `/my_account/send_verification_email?email=${encodeURIComponent(email)}`
+                    return
+                  }
+
+                  id_sidebar_sign_in_cross.hidden = false
+                  id_sidebar_sign_in_spinner.hidden = true
+
+                  id_sidebar_sign_in_alert.textContent = problem.detail
+                  //id_sidebar_sign_in_alert.classList.remove('alert-success')
+                  //id_sidebar_sign_in_alert.classList.add('alert-danger')
+                  id_sidebar_sign_in_alert.hidden = false
+                  return
+                }
+                //id_sidebar_sign_in_tick.hidden = false
+                //id_sidebar_sign_in_spinner.hidden = true
+                //id_sidebar_sign_in_alert.textContent = `You are now signed in as "${email}".`
+                //id_sidebar_sign_in_alert.classList.add('alert-success')
+                //id_sidebar_sign_in_alert.classList.remove('alert-danger')
+                //id_sidebar_sign_in_alert.hidden = false
+
+                if (sign_in_out(true))
+                  // if location has been changed, leave the spinner and do
+                  // not show status/dialog, as it causes an annoying flicker
+                  return
+
+                id_sidebar_signed_in_status.textContent = 'Signed in.'
+                id_sidebar_sign_in.hidden = true
+                id_sidebar_sign_up.hidden = true
+                id_sidebar_sign_out.hidden = false
+
+                id_sidebar_sign_in_icon.hidden = false
+                id_sidebar_sign_in_spinner.hidden = true
+                id_sidebar_sign_in_alert.hidden = true
+                id_sidebar_message_modal_message.textContent = `You are now signed in as "${email}".`
+                $('#sidebar-sign-in-modal').modal('hide')
+                $('#sidebar-message-modal').modal('show')
+              }
+            )
+
+            // sign out button
+            id_sidebar_sign_out.addEventListener(
+              'click',
+              async () => {
+                try {
+                  await api_call(
+                    '/api/account/sign_out.json'
+                  )
+                }
+                catch (error) {
+                  let problem = Problem.from(error)
+
+                  id_sidebar_message_modal_message.textContent = problem.detail
+                  $('#sidebar-sign-in-modal').modal('hide')
+                  $('#sidebar-message-modal').modal('show')
+                  return
+                }
+
+                if (sign_in_out(false))
+                  // if location has been changed, leave the spinner and do
+                  // not show status/dialog, as it causes an annoying flicker
+                  return
+
+                id_sidebar_signed_in_status.textContent = 'Signed out.'
+                id_sidebar_sign_in.hidden = false
+                id_sidebar_sign_up.hidden = false
+                id_sidebar_sign_out.hidden = true
+
+                id_sidebar_message_modal_message.textContent = `You are now signed out.`
+                $('#sidebar-sign-in-modal').modal('hide')
+                $('#sidebar-message-modal').modal('show')
+              }
+            )
+
+            // feedback form
+            id_sidebar_give_feedback.addEventListener(
+              'click',
+              () => {
+                // hack to move cursor to end of textarea
+                let temp = id_sidebar_feedback_message.value
+                id_sidebar_feedback_message.value = ''
+                id_sidebar_feedback_message.value = temp
+
+                $('#sidebar-feedback-modal').modal('show')
+                return false
+              }
+            )
+
+            $('#sidebar-feedback-modal').on(
+              'shown.bs.modal',
+              () => {id_sidebar_feedback_message.focus()}
+            )
+
+            let feedback_input_semaphore = new BinarySemaphore(false)
+            ;(
+              async () => {
+                while (true) {
+                  await feedback_input_semaphore.acquire()
+                  await new Promise(resolve => setTimeout(resolve, 3000))
+                  feedback_input_semaphore.try_acquire()
+                  await api_call(
+                    '/api/feedback/set_draft.json',
+                    id_sidebar_feedback_message.value.length === 0 ?
+                      null :
+                      {
+                        message: id_sidebar_feedback_message.value.slice(0, 65536)
+                      }
+                  )
+                }
+              }
+            )() // ignore returned promise (start thread)
+
+            let feedback_edited = () => {
+              feedback_input_semaphore.release()
+
+              id_sidebar_feedback_send_message.disabled =
+                id_sidebar_feedback_message.value.length === 0
+              id_sidebar_feedback_icon.hidden = false
+              //id_sidebar_feedback_tick.hidden = true
+              id_sidebar_feedback_cross.hidden = true
+              id_sidebar_feedback_spinner.hidden = true
+              id_sidebar_feedback_alert.hidden = true
+            }
+
+            id_sidebar_feedback_message.addEventListener(
+              'input',
+              feedback_edited
+            )
+
+            id_sidebar_feedback_send_message.addEventListener(
+              'click',
+              async () => {
+                id_sidebar_feedback_icon.hidden = false
+                //id_sidebar_feedback_tick.hidden = true
+                id_sidebar_feedback_cross.hidden = true
+                id_sidebar_feedback_spinner.hidden = true
+                // the below causes an ugly flicker, so just keep the alert
+                //id_sidebar_feedback_alert.hidden = true
+
+                if (!id_sidebar_feedback_form.checkValidity()) {
+                  id_sidebar_feedback_form.classList.add('was-validated');
+
+                  id_sidebar_feedback_icon.hidden = true
+                  id_sidebar_feedback_cross.hidden = false
+                  return
+                }
+                id_sidebar_feedback_form.classList.remove('was-validated');
+
+                id_sidebar_feedback_icon.hidden = true
+                id_sidebar_feedback_spinner.hidden = false
+                try {
+                  await api_call(
+                    '/api/feedback/send_message.json',
+                    location.href,
+                    id_sidebar_feedback_message.value.slice(0, 65536)
+                  )
+                }
+                catch (error) {
+                  let problem = Problem.from(error)
+
+                  id_sidebar_feedback_cross.hidden = false
+                  id_sidebar_feedback_spinner.hidden = true
+
+                  id_sidebar_feedback_alert.textContent = problem.detail
+                  //id_sidebar_feedback_alert.classList.remove('alert-success')
+                  //id_sidebar_feedback_alert.classList.add('alert-danger')
+                  id_sidebar_feedback_alert.hidden = false
+                  return
+                }
+                //id_sidebar_feedback_tick.hidden = false
+                //id_sidebar_feedback_spinner.hidden = true
+                //id_sidebar_feedback_alert.alertContent = 'We have received your message. We will be in touch as soon as possible.'
+                //id_sidebar_feedback_alert.classList.add('alert-success')
+                //id_sidebar_feedback_alert.classList.remove('alert-danger')
+                //id_sidebar_feedback_alert.hidden = false
+
+                id_sidebar_feedback_icon.hidden = false
+                id_sidebar_feedback_spinner.hidden = true
+                id_sidebar_feedback_alert.hidden = true
+                id_sidebar_message_modal_message.textContent = 'Thanks! We have received your feedback.'
+                $('#sidebar-feedback-modal').modal('hide')
+                $('#sidebar-message-modal').modal('show')
+              }
+            )
+
+            // sidebar
+            let sidebar_outer_computed_style = window.getComputedStyle(
+              id_sidebar_outer
+            )
+            let sidebar_toggle_computed_style = window.getComputedStyle(
+              id_sidebar_toggle
+            )
+            let sidebar_is_collapsed =
+              () =>
+                sidebar_toggle_computed_style.display !== 'none' &&
+                  id_sidebar_outer.classList.contains(
+                    'sidebar-outer-collapsed'
+                  )
+            let sidebar_collapse_update = () => {
+              if (sidebar_outer_computed_style.position === 'sticky') { // md and up
+                id_sidebar_outer.style.flexBasis =
+                  sidebar_is_collapsed() ?
+                    '0px' :
+                    `${id_sidebar_inner.clientWidth}px`
+                id_sidebar_outer.style.removeProperty('height')
+              }
+              else {
+                id_sidebar_outer.style.height =
+                  sidebar_is_collapsed() ?
+                    '0px' :
+                    `${id_sidebar_inner.clientHeight}px`
+                id_sidebar_outer.style.removeProperty('flex-basis')
+              }
+            }
+            window.addEventListener('resize', sidebar_collapse_update)
+            sidebar_collapse_update()
+
+            id_sidebar_outer.addEventListener(
+              'transitionend',
+              () => {
+                // transitions are only allowed after clicking collapse button,
+                // otherwise they can be triggered by media queries on resize
+                id_sidebar_outer.style.removeProperty('transition-property')
+              }
+            )
+
+            id_sidebar_toggle.addEventListener(
+              'click',
+              () => {
+                if (
+                  id_sidebar_outer.classList.contains(
+                    'sidebar-outer-collapsed'
+                  )
+                )
+                  id_sidebar_outer.classList.remove(
+                    'sidebar-outer-collapsed'
+                  )
+                else
+                   id_sidebar_outer.classList.add(
+                    'sidebar-outer-collapsed'
+                  )
+                id_sidebar_outer.style.transitionProperty =
+                  sidebar_outer_computed_style.position === 'sticky' ?
+                    'flex-basis' : // md and up
+                    'height'
+                sidebar_collapse_update()
+              }
+            )
+          }
+        )
+      }
+
+      await scripts(_out)
+    }
+  )
+}