Upgrade to nick_site commit f981fa57, adds alerts and inactive sidebar option
[ndcode_site.git] / _lib / sidebar.jst
1 let assert = require('assert')
2 let jst_server = (await import('@ndcode/jst_server')).default
3 let XDate = require('xdate')
4
5 let arrays_equal =
6   (a, b) =>
7     a.length === b.length && a.every((value, index) => value === b[index])
8
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')
23
24   // initialize env.cart
25   //await cart(env)
26
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)
33
34   let transaction = await env.site.database.Transaction()
35   let signed_in_as
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
39   let feedback_draft
40   try {
41     let root = await transaction.get()
42
43     let session = await get_session(env, root)
44     signed_in_as = await session.get_json('signed_in_as')
45
46     let globals = await root.get('globals')
47     site_title = await globals.get_json('site_title')
48     copyright = await globals.get_json('copyright')
49
50     let navigation = await root.get('navigation')
51     if (navigation === undefined)
52       throw new jst_server.Problem(
53         'Navigation error',
54         'Please import the navigation tree into the database.',
55         508
56       )
57
58     // this code is taken from get_navigation.jst and instrumented
59     let p = navigation
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(
66           'Navigation error',
67           `Can't find the path "${
68             component_names.slice(0, i + 1).map(name => '/' + name).join('')
69           }" in the navigation tree.`,
70           508
71         )
72       component_titles.push(await p.get_json('title'))
73     }
74
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(
83           'Navigation error',
84           `Can't find the path "/${menu_names[i]}" in the navigation tree.`
85           508
86         )
87       menu_titles.push(await child.get('title'))
88     }
89
90     feedback_draft = await session.get_json('feedback_draft')
91     if (feedback_draft === undefined || env.now >= feedback_draft.expires)
92       feedback_draft = null
93   }
94   finally {
95     transaction.rollback()
96   }
97
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
104
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)
109
110   await page(
111     env,
112     // head
113     async _out => {
114       title {
115         `${site_title}: ${
116           component_titles[
117             component_names.length >= 2 ? 1 : component_names.length
118           ]
119         }`
120       }
121
122       await head(_out)
123     },
124     // body
125     async _out => {
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)
130
131       div.container-fluid {
132         div.row {
133           div.col-md.sidebar-outer.sidebar-outer-collapsed#sidebar-outer {
134             nav.sidebar-inner.d-flex.flex-column#sidebar-inner {
135               div.mb-4 {
136                 div(style="width: 128px; height: 128px;") {
137                   _out.push(avatar_maker)
138                 }
139                 b.h1 {
140                   `${site_title}`
141                 }
142               }
143
144               div.mb-2 {
145                 span#sidebar-signed-in-status {
146                   if (signed_in_as !== undefined)
147                     'Signed in.'
148                   else
149                     'Signed out.'
150                 }
151                 ' '
152                 if (signed_in_as !== undefined)
153                   a#sidebar-sign-in(href="#" hidden) {'Sign in'}
154                 else
155                   a#sidebar-sign-in(href="#") {'Sign in'}
156                 ' '
157                 if (signed_in_as !== undefined)
158                   a#sidebar-sign-up(href="/my_account/sign_up/index.html" hidden) {'Sign up'}
159                 else
160                   a#sidebar-sign-up(href="/my_account/sign_up/index.html") {'Sign up'}
161                 ' '
162                 if (signed_in_as !== undefined)
163                   a#sidebar-sign-out(href="#") {'Sign out'}
164                 else
165                   a#sidebar-sign-out(href="#" hidden) {'Sign out'}
166               }
167
168               form.mb-4(action="/search/index.html") {
169                 div.input-group {
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") {
173                       div.icon24-outer {
174                         div.icon24-inner {_out.push(fa_search)}
175                       }
176                     }
177                   }
178                 }
179               }
180
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)
187
188               for (let i = 0; i < menu_titles.length; ++i) {
189                 // construct path to the top-level page about to be described
190                 let menu_prefix =
191                   i === 0 ? [] : [menu_names[i - 1]]
192                 let menu_prefix_path =
193                   menu_prefix.map(name => '/' + name).join('') + '/index.html'
194
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) {
198                       `${menu_titles[i]}`
199                       span.sr-only {' (current)'}
200                     }
201                   }
202                 else
203                   div.nav-item {
204                     a.nav-link.nav-link2.grid-gutter-background(href=menu_prefix_path) {
205                       `${menu_titles[i]}`
206                     }
207                   }
208               }
209               div.nav-item.mt-auto {
210                 a.nav-link.nav-link2.grid-gutter-background#sidebar-give-feedback(href="#") {'Give feedback'}
211               }
212             }
213           }
214
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
220
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)}
226                 }
227                 span.sr-only {'Navbar toggle'}
228               }
229               for (let i = 0; i < component_names.length; ++i) {
230                 a.h4(
231                   href=
232                     `${
233                       component_names.slice(0, i).map(name => '/' + name).join('')
234                     }/index.html`
235                 ) {`${component_titles[i]}`}
236                 ' '
237                 span.h5 {'>'}
238                 ' '
239               }
240               `${component_titles[component_names.length]}`
241             }
242
243             await body(_out)
244
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") {}
248               }
249               p {
250                 'This website is '
251                 a(href="https://git.ndcode.org/public/nick_site.git") {
252                   'open source'
253                 }
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'
257                 }
258                 '.'
259               }
260
261               p.mb-0 {`Copyright © ${new XDate(env.now).getUTCFullYear()} ${copyright}.`}
262             }
263           }
264           div.col-md.sidebar-dummy {}
265         }
266       }
267
268       // hidden part
269       div.modal#sidebar-sign-in-modal(tabindex="-1") {
270         div.modal-dialog {
271           div.modal-content {
272             div.modal-header {
273               span.h4.modal-title {'Sign in'}
274             }
275             div.modal-body {
276               form.mb-2#sidebar-sign-in-form {
277                 div.row {
278                   div.col-md-12 {
279                     div.form-group {
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.'}
283                     }
284                   }
285                 }
286                 div.row {
287                   div.col-md-12 {
288                     div.form-group {
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.'}
292                     }
293                   }
294                 }
295               }
296
297               p {
298                 'No account yet? '
299                 a(href="/my_account/sign_up/index.html") {'Sign up'}
300               }
301
302               p.mb-0 {
303                 'Forgot password? '
304                 a(href="/my_account/password_reset/index.html") {'Password reset'}
305               }
306
307               div.alert.alert-danger.'mt-3'.mb-0#sidebar-sign-in-alert(hidden) {}
308             }
309             div.modal-footer {
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)}
313                 }
314                 'Back'
315               }
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)}
319                 }
320                 //div.icon24-outer.mr-2#sidebar-sign-in-tick(hidden) {
321                 //  div.icon24-inner {_out.push(icon_tick)}
322                 //}
323                 div.icon24-outer.mr-2#sidebar-sign-in-cross(hidden) {
324                   div.icon24-inner {_out.push(icon_cross)}
325                 }
326                 div.icon24-outer.mr-2#sidebar-sign-in-spinner(hidden) {
327                   div.icon24-inner {
328                     div.spinner-border.spinner-border-sm(role="status") {}
329                   }
330                 }
331                 'Sign in'
332               }
333             }
334           }
335         }
336       }
337
338       div.modal#sidebar-feedback-modal(tabindex="-1") {
339         div.modal-dialog {
340           div.modal-content {
341             div.modal-header {
342               span.h4.modal-title {'Give feedback'}
343             }
344             div.modal-body {
345               p {
346                 'Did you notice something not quite right, or just want to share your impression of this page?'
347               }
348
349               form#sidebar-feedback-form {
350                 div.row {
351                   div.col-md-12 {
352                     div.form-group {
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) {
355                         if (feedback_draft)
356                           `${feedback_draft.message}`
357                       }
358                       div.invalid-feedback {'Please let us have your thoughts.'}
359                     }
360                   }
361                 }
362               }
363
364               div.alert.alert-danger.'mt-3'.mb-0#sidebar-feedback-alert(hidden) {}
365             }
366             div.modal-footer {
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)}
370                 }
371                 'Back'
372               }
373               if (feedback_draft)
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)}
377                   }
378                   //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
379                   //  div.icon24-inner {_out.push(icon_tick)}
380                   //}
381                   div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
382                     div.icon24-inner {_out.push(icon_cross)}
383                   }
384                   div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
385                     div.icon24-inner {
386                       div.spinner-border.spinner-border-sm(role="status") {}
387                     }
388                   }
389                   'Send message'
390                 }
391               else
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)}
395                   }
396                   //div.icon24-outer.mr-2#sidebar-feedback-tick(hidden) {
397                   //  div.icon24-inner {_out.push(icon_tick)}
398                   //}
399                   div.icon24-outer.mr-2#sidebar-feedback-cross(hidden) {
400                     div.icon24-inner {_out.push(icon_cross)}
401                   }
402                   div.icon24-outer.mr-2#sidebar-feedback-spinner(hidden) {
403                     div.icon24-inner {
404                       div.spinner-border.spinner-border-sm(role="status") {}
405                     }
406                   }
407                   'Send message'
408                 }
409             }
410           }
411         }
412       }
413
414       div.modal#sidebar-message-modal(tabindex="-1") {
415         div.modal-dialog {
416           div.modal-content {
417             div.modal-header {
418               span.h4.modal-title {'Message'}
419             }
420             div.modal-body#sidebar-message-modal-message {
421             }
422             div.modal-footer {
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)}
426                 }
427                 'Close'
428               }
429             }
430           }
431         }
432       }
433     },
434     // scripts
435     async _out => {
436       script(src="/js/utils.js") {}
437
438       script {
439         // this function can be overridden in a further script
440         function sign_in_out(status) {
441           return false
442         }
443
444         document.addEventListener(
445           'DOMContentLoaded',
446           () => {
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')
477
478             // sign in form
479             id_sidebar_sign_in.addEventListener(
480               'click',
481               () => {
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')
486               }
487             )
488
489             $('#sidebar-sign-in-modal').on(
490               'shown.bs.modal',
491               () => {id_sidebar_sign_in_email.focus()}
492             )
493
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
503             }
504
505             id_sidebar_sign_in_email.addEventListener(
506               'input',
507               sign_in_edited
508             )
509             id_sidebar_sign_in_password.addEventListener(
510               'input',
511               sign_in_edited
512             )
513
514             id_sidebar_sign_in_sign_in.addEventListener(
515               'click',
516               async () => {
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
523
524                 if (!id_sidebar_sign_in_form.checkValidity()) {
525                   id_sidebar_sign_in_form.classList.add('was-validated');
526
527                   id_sidebar_sign_in_icon.hidden = true
528                   id_sidebar_sign_in_cross.hidden = false
529                   return
530                 }
531                 id_sidebar_sign_in_form.classList.remove('was-validated');
532
533                 let email = id_sidebar_sign_in_email.value.slice(0, 256).toLowerCase()
534
535                 id_sidebar_sign_in_icon.hidden = true
536                 id_sidebar_sign_in_spinner.hidden = false
537                 try {
538                   await api_call(
539                     '/api/account/sign_in.json',
540                     email,
541                     id_sidebar_sign_in_password.value.slice(0, 256)
542                   )
543                 }
544                 catch (error) {
545                   let problem = Problem.from(error)
546
547                   if (problem.title === 'Email not yet verified') {
548                     location.href = `/my_account/send_verification_email?email=${encodeURIComponent(email)}`
549                     return
550                   }
551
552                   id_sidebar_sign_in_cross.hidden = false
553                   id_sidebar_sign_in_spinner.hidden = true
554
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
559                   return
560                 }
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
567
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
571                   return
572
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
577
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')
584               }
585             )
586
587             // sign out button
588             id_sidebar_sign_out.addEventListener(
589               'click',
590               async () => {
591                 try {
592                   await api_call(
593                     '/api/account/sign_out.json'
594                   )
595                 }
596                 catch (error) {
597                   let problem = Problem.from(error)
598
599                   id_sidebar_message_modal_message.textContent = problem.detail
600                   $('#sidebar-sign-in-modal').modal('hide')
601                   $('#sidebar-message-modal').modal('show')
602                   return
603                 }
604
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
608                   return
609
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
614
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')
618               }
619             )
620
621             // feedback form
622             id_sidebar_give_feedback.addEventListener(
623               'click',
624               () => {
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
629
630                 $('#sidebar-feedback-modal').modal('show')
631                 return false
632               }
633             )
634
635             $('#sidebar-feedback-modal').on(
636               'shown.bs.modal',
637               () => {id_sidebar_feedback_message.focus()}
638             )
639
640             let feedback_input_semaphore = new BinarySemaphore(false)
641             ;(
642               async () => {
643                 while (true) {
644                   await feedback_input_semaphore.acquire()
645                   await new Promise(resolve => setTimeout(resolve, 3000))
646                   feedback_input_semaphore.try_acquire()
647                   await api_call(
648                     '/api/feedback/set_draft.json',
649                     id_sidebar_feedback_message.value.length === 0 ?
650                       null :
651                       {
652                         message: id_sidebar_feedback_message.value.slice(0, 65536)
653                       }
654                   )
655                 }
656               }
657             )() // ignore returned promise (start thread)
658
659             let feedback_edited = () => {
660               feedback_input_semaphore.release()
661
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
669             }
670
671             id_sidebar_feedback_message.addEventListener(
672               'input',
673               feedback_edited
674             )
675
676             id_sidebar_feedback_send_message.addEventListener(
677               'click',
678               async () => {
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
685
686                 if (!id_sidebar_feedback_form.checkValidity()) {
687                   id_sidebar_feedback_form.classList.add('was-validated');
688
689                   id_sidebar_feedback_icon.hidden = true
690                   id_sidebar_feedback_cross.hidden = false
691                   return
692                 }
693                 id_sidebar_feedback_form.classList.remove('was-validated');
694
695                 id_sidebar_feedback_icon.hidden = true
696                 id_sidebar_feedback_spinner.hidden = false
697                 try {
698                   await api_call(
699                     '/api/feedback/send_message.json',
700                     location.href,
701                     id_sidebar_feedback_message.value.slice(0, 65536)
702                   )
703                 }
704                 catch (error) {
705                   let problem = Problem.from(error)
706
707                   id_sidebar_feedback_cross.hidden = false
708                   id_sidebar_feedback_spinner.hidden = true
709
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
714                   return
715                 }
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
722
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')
729               }
730             )
731
732             // sidebar
733             let sidebar_outer_computed_style = window.getComputedStyle(
734               id_sidebar_outer
735             )
736             let sidebar_toggle_computed_style = window.getComputedStyle(
737               id_sidebar_toggle
738             )
739             let sidebar_is_collapsed =
740               () =>
741                 sidebar_toggle_computed_style.display !== 'none' &&
742                   id_sidebar_outer.classList.contains(
743                     'sidebar-outer-collapsed'
744                   )
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() ?
749                     '0px' :
750                     `${id_sidebar_inner.clientWidth}px`
751                 id_sidebar_outer.style.removeProperty('height')
752               }
753               else {
754                 id_sidebar_outer.style.height =
755                   sidebar_is_collapsed() ?
756                     '0px' :
757                     `${id_sidebar_inner.clientHeight}px`
758                 id_sidebar_outer.style.removeProperty('flex-basis')
759               }
760             }
761             window.addEventListener('resize', sidebar_collapse_update)
762             sidebar_collapse_update()
763
764             id_sidebar_outer.addEventListener(
765               'transitionend',
766               () => {
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')
770               }
771             )
772
773             id_sidebar_toggle.addEventListener(
774               'click',
775               () => {
776                 if (
777                   id_sidebar_outer.classList.contains(
778                     'sidebar-outer-collapsed'
779                   )
780                 )
781                   id_sidebar_outer.classList.remove(
782                     'sidebar-outer-collapsed'
783                   )
784                 else
785                    id_sidebar_outer.classList.add(
786                     'sidebar-outer-collapsed'
787                   )
788                 id_sidebar_outer.style.transitionProperty =
789                   sidebar_outer_computed_style.position === 'sticky' ?
790                     'flex-basis' : // md and up
791                     'height'
792                 sidebar_collapse_update()
793               }
794             )
795           }
796         )
797       }
798
799       await scripts(_out)
800     }
801   )
802 }