diff --git a/.gitignore b/.gitignore
index 5d49811..8748532 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,6 +127,12 @@ dmypy.json
# Pyre type checker
.pyre/
+# node.js / npm stuff
+node_modules
+package.json
+package-lock.json
+
+# our own stuff
*.swp
ram/storage/
!ram/storage/.gitignore
diff --git a/ram/portal/static/css/main.min.css b/ram/portal/static/css/main.min.css
new file mode 100644
index 0000000..3be05d5
--- /dev/null
+++ b/ram/portal/static/css/main.min.css
@@ -0,0 +1 @@
+html[data-bs-theme=dark] .navbar svg{fill:#fff}.card>a>img{width:100%}td>img.logo{max-width:200px;max-height:48px}td>img.logo-xl{max-width:400px;max-height:96px}td>p:last-child{margin-bottom:0}.btn>span{display:inline-block}a.badge,a.badge:hover{text-decoration:none;color:#fff}.img-thumbnail{padding:0}.w-33{width:33%!important}.table-group-divider{border-top:calc(var(--bs-border-width) * 3) solid var(--bs-border-color)}#nav-journal ol,#nav-journal ul{padding-left:1rem}#nav-journal ol:last-child,#nav-journal p:last-child,#nav-journal ul:last-child{margin-bottom:0}#footer>p{display:inline}
\ No newline at end of file
diff --git a/ram/portal/static/css/src/README.md b/ram/portal/static/css/src/README.md
new file mode 100644
index 0000000..59610d0
--- /dev/null
+++ b/ram/portal/static/css/src/README.md
@@ -0,0 +1,7 @@
+# Compile main.min.css
+
+```bash
+$ npm install clean-css-cli
+$ npx cleancss -o ../main.min.css main.css
+```
+
diff --git a/ram/portal/static/css/main.css b/ram/portal/static/css/src/main.css
similarity index 100%
rename from ram/portal/static/css/main.css
rename to ram/portal/static/css/src/main.css
diff --git a/ram/portal/static/js/main.min.js b/ram/portal/static/js/main.min.js
new file mode 100644
index 0000000..71e2864
--- /dev/null
+++ b/ram/portal/static/js/main.min.js
@@ -0,0 +1,6 @@
+/*!
+* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
+* Copyright 2011-2023 The Bootstrap Authors
+* Licensed under the Creative Commons Attribution 3.0 Unported License.
+*/
+(()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){const e=window.location.hash.substring(1);if(e){const t=document.querySelector(`[data-bs-target="#nav-${e}"]`);t&&bootstrap.Tab.getOrCreateInstance(t).show()}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})});const t=document.getElementById("tabSelector");t&&(t.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="#${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const a=e.target.getAttribute("data-bs-target");t.value=a.substring(1)})}))});
diff --git a/ram/portal/static/js/src/README.md b/ram/portal/static/js/src/README.md
new file mode 100644
index 0000000..bfaeac2
--- /dev/null
+++ b/ram/portal/static/js/src/README.md
@@ -0,0 +1,7 @@
+# Compile main.min.js
+
+```bash
+$ npm install terser
+$ npx terser theme_selector.js tabs_selector.js -c -m -o ../main.min.js
+```
+
diff --git a/ram/portal/static/js/src/tabs_selector.js b/ram/portal/static/js/src/tabs_selector.js
new file mode 100644
index 0000000..3616b2b
--- /dev/null
+++ b/ram/portal/static/js/src/tabs_selector.js
@@ -0,0 +1,39 @@
+document.addEventListener("DOMContentLoaded", function () {
+ // code to handle tab selection and URL hash synchronization
+ const hash = window.location.hash.substring(1) // remove the '#' prefix
+ if (hash) {
+ const trigger = document.querySelector(`[data-bs-target="#nav-${hash}"]`);
+ if (trigger) {
+ bootstrap.Tab.getOrCreateInstance(trigger).show();
+ }
+ }
+ //
+ // update the URL hash when a tab is shown
+ document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => {
+ btn.addEventListener('shown.bs.tab', event => {
+ const newHash = event.target.getAttribute('data-bs-target').replace('nav-', '');
+ history.replaceState(null, null, newHash);
+ });
+ });
+
+ // allow tab selection via a dropdown on small screens
+ const selectElement = document.getElementById('tabSelector');
+ if (!selectElement) return;
+ selectElement.addEventListener('change', function () {
+ const targetSelector = this.value;
+ const triggerEl = document.querySelector(`[data-bs-target="#${targetSelector}"]`);
+ if (triggerEl) {
+ // Use Bootstrap 5.3's API — ensures transitions + ARIA updates
+ const tabInstance = bootstrap.Tab.getOrCreateInstance(triggerEl);
+ tabInstance.show();
+ }
+ });
+
+ // keep the dropdown in sync if the user clicks a tab button
+ document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
+ btn.addEventListener('shown.bs.tab', event => {
+ const target = event.target.getAttribute('data-bs-target');
+ selectElement.value = target.substring(1); // remove the '#' character
+ });
+ });
+});
diff --git a/ram/portal/static/js/src/theme_selector.js b/ram/portal/static/js/src/theme_selector.js
new file mode 100644
index 0000000..78596a5
--- /dev/null
+++ b/ram/portal/static/js/src/theme_selector.js
@@ -0,0 +1,76 @@
+/*!
+* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
+* Copyright 2011-2023 The Bootstrap Authors
+* Licensed under the Creative Commons Attribution 3.0 Unported License.
+*/
+
+(() => {
+ 'use strict'
+
+ const getStoredTheme = () => localStorage.getItem('theme')
+ const setStoredTheme = theme => localStorage.setItem('theme', theme)
+
+ const getPreferredTheme = () => {
+ const storedTheme = getStoredTheme()
+ if (storedTheme) {
+ return storedTheme
+ }
+
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+ }
+
+ const setTheme = theme => {
+ if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ document.documentElement.setAttribute('data-bs-theme', 'dark')
+ } else {
+ document.documentElement.setAttribute('data-bs-theme', theme)
+ }
+ }
+
+ setTheme(getPreferredTheme())
+
+ const showActiveTheme = (theme, focus = false) => {
+ const themeSwitcher = document.querySelector('#bd-theme')
+
+ if (!themeSwitcher) {
+ return
+ }
+
+ const activeThemeIcon = document.querySelector('.theme-icon-active i')
+ const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
+ const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
+
+ document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
+ element.classList.remove('active')
+ element.setAttribute('aria-pressed', 'false')
+ })
+
+ btnToActive.classList.add('active')
+ btnToActive.setAttribute('aria-pressed', 'true')
+ activeThemeIcon.setAttribute('class', biOfActiveBtn)
+
+ if (focus) {
+ themeSwitcher.focus()
+ }
+ }
+
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ const storedTheme = getStoredTheme()
+ if (storedTheme !== 'light' && storedTheme !== 'dark') {
+ setTheme(getPreferredTheme())
+ }
+ })
+
+ window.addEventListener('DOMContentLoaded', () => {
+ showActiveTheme(getPreferredTheme())
+ document.querySelectorAll('[data-bs-theme-value]')
+ .forEach(toggle => {
+ toggle.addEventListener('click', () => {
+ const theme = toggle.getAttribute('data-bs-theme-value')
+ setStoredTheme(theme)
+ setTheme(theme)
+ showActiveTheme(theme, true)
+ })
+ })
+ })
+ })()
diff --git a/ram/portal/templates/base.html b/ram/portal/templates/base.html
index 44d6d1a..1b88cf2 100644
--- a/ram/portal/templates/base.html
+++ b/ram/portal/templates/base.html
@@ -22,114 +22,8 @@
{% endif %}
-
-
-
-
+
+
{% block extra_head %}
{{ site_conf.extra_head | safe }}
{% endblock %}
diff --git a/ram/portal/templates/magazine.html b/ram/portal/templates/bookshelf/magazine.html
similarity index 100%
rename from ram/portal/templates/magazine.html
rename to ram/portal/templates/bookshelf/magazine.html
diff --git a/ram/portal/views.py b/ram/portal/views.py
index ef1c5f8..c015165 100644
--- a/ram/portal/views.py
+++ b/ram/portal/views.py
@@ -773,7 +773,7 @@ class GetMagazine(View):
return render(
request,
- "magazine.html",
+ "bookshelf/magazine.html",
{
"title": magazine,
"magazine": magazine,
diff --git a/ram/ram/__init__.py b/ram/ram/__init__.py
index f83e720..53f5fd8 100644
--- a/ram/ram/__init__.py
+++ b/ram/ram/__init__.py
@@ -1,4 +1,4 @@
from ram.utils import git_suffix
-__version__ = "0.19.8"
+__version__ = "0.19.9"
__version__ += git_suffix(__file__)