diff --git a/static/style.css b/static/style.css
index 00491af..1e6c19b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -168,3 +168,24 @@ ul.dir li .material-symbols-outlined {
font-weight: bold;
text-align: center;
}
+
+@font-face {
+ font-family: 'Material Symbols Outlined';
+ font-style: normal;
+ font-weight: 400;
+ src: url(/static/vendor/material-symbols-outlined.woff2) format('truetype');
+}
+
+.material-symbols-outlined {
+ font-family: 'Material Symbols Outlined';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-block;
+ white-space: nowrap;
+ word-wrap: normal;
+ direction: ltr;
+}
diff --git a/static/vendor/htmx-sse.js b/static/vendor/htmx-sse.js
new file mode 100644
index 0000000..4088cd2
--- /dev/null
+++ b/static/vendor/htmx-sse.js
@@ -0,0 +1,322 @@
+/*
+Server Sent Events Extension
+============================
+This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
+
+*/
+
+(function(){
+
+ /** @type {import("../htmx").HtmxInternalApi} */
+ var api;
+
+ htmx.defineExtension("sse", {
+
+ /**
+ * Init saves the provided reference to the internal HTMX API.
+ *
+ * @param {import("../htmx").HtmxInternalApi} api
+ * @returns void
+ */
+ init: function(apiRef) {
+ // store a reference to the internal API.
+ api = apiRef;
+
+ // set a function in the public API for creating new EventSource objects
+ if (htmx.createEventSource == undefined) {
+ htmx.createEventSource = createEventSource;
+ }
+ },
+
+ /**
+ * onEvent handles all events passed to this extension.
+ *
+ * @param {string} name
+ * @param {Event} evt
+ * @returns void
+ */
+ onEvent: function(name, evt) {
+
+ switch (name) {
+
+ // Try to remove remove an EventSource when elements are removed
+ case "htmx:beforeCleanupElement":
+ var internalData = api.getInternalData(evt.target)
+ if (internalData.sseEventSource) {
+ internalData.sseEventSource.close();
+ }
+ return;
+
+ // Try to create EventSources when elements are processed
+ case "htmx:afterProcessNode":
+ createEventSourceOnElement(evt.target);
+ }
+ }
+ });
+
+ ///////////////////////////////////////////////
+ // HELPER FUNCTIONS
+ ///////////////////////////////////////////////
+
+
+ /**
+ * createEventSource is the default method for creating new EventSource objects.
+ * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
+ *
+ * @param {string} url
+ * @returns EventSource
+ */
+ function createEventSource(url) {
+ return new EventSource(url, {withCredentials:true});
+ }
+
+ function splitOnWhitespace(trigger) {
+ return trigger.trim().split(/\s+/);
+ }
+
+ function getLegacySSEURL(elt) {
+ var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
+ if (legacySSEValue) {
+ var values = splitOnWhitespace(legacySSEValue);
+ for (var i = 0; i < values.length; i++) {
+ var value = values[i].split(/:(.+)/);
+ if (value[0] === "connect") {
+ return value[1];
+ }
+ }
+ }
+ }
+
+ function getLegacySSESwaps(elt) {
+ var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
+ var returnArr = [];
+ if (legacySSEValue) {
+ var values = splitOnWhitespace(legacySSEValue);
+ for (var i = 0; i < values.length; i++) {
+ var value = values[i].split(/:(.+)/);
+ if (value[0] === "swap") {
+ returnArr.push(value[1]);
+ }
+ }
+ }
+ return returnArr;
+ }
+
+ /**
+ * createEventSourceOnElement creates a new EventSource connection on the provided element.
+ * If a usable EventSource already exists, then it is returned. If not, then a new EventSource
+ * is created and stored in the element's internalData.
+ * @param {HTMLElement} elt
+ * @param {number} retryCount
+ * @returns {EventSource | null}
+ */
+ function createEventSourceOnElement(elt, retryCount) {
+
+ if (elt == null) {
+ return null;
+ }
+
+ var internalData = api.getInternalData(elt);
+
+ // get URL from element's attribute
+ var sseURL = api.getAttributeValue(elt, "sse-connect");
+
+
+ if (sseURL == undefined) {
+ var legacyURL = getLegacySSEURL(elt)
+ if (legacyURL) {
+ sseURL = legacyURL;
+ } else {
+ return null;
+ }
+ }
+
+ // Connect to the EventSource
+ var source = htmx.createEventSource(sseURL);
+ internalData.sseEventSource = source;
+
+ // Create event handlers
+ source.onerror = function (err) {
+
+ // Log an error event
+ api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source});
+
+ // If parent no longer exists in the document, then clean up this EventSource
+ if (maybeCloseSSESource(elt)) {
+ return;
+ }
+
+ // Otherwise, try to reconnect the EventSource
+ if (source.readyState === EventSource.CLOSED) {
+ retryCount = retryCount || 0;
+ var timeout = Math.random() * (2 ^ retryCount) * 500;
+ window.setTimeout(function() {
+ createEventSourceOnElement(elt, Math.min(7, retryCount+1));
+ }, timeout);
+ }
+ };
+
+ source.onopen = function (evt) {
+ api.triggerEvent(elt, "htmx::sseOpen", {source: source});
+ }
+
+ // Add message handlers for every `sse-swap` attribute
+ queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
+
+ var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
+ if (sseSwapAttr) {
+ var sseEventNames = sseSwapAttr.split(",");
+ } else {
+ var sseEventNames = getLegacySSESwaps(child);
+ }
+
+ for (var i = 0 ; i < sseEventNames.length ; i++) {
+ var sseEventName = sseEventNames[i].trim();
+ var listener = function(event) {
+
+ // If the parent is missing then close SSE and remove listener
+ if (maybeCloseSSESource(elt)) {
+ source.removeEventListener(sseEventName, listener);
+ return;
+ }
+
+ // swap the response into the DOM and trigger a notification
+ swap(child, event.data);
+ api.triggerEvent(elt, "htmx:sseMessage", event);
+ };
+
+ // Register the new listener
+ api.getInternalData(elt).sseEventListener = listener;
+ source.addEventListener(sseEventName, listener);
+ }
+ });
+
+ // Add message handlers for every `hx-trigger="sse:*"` attribute
+ queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
+
+ var sseEventName = api.getAttributeValue(child, "hx-trigger");
+ if (sseEventName == null) {
+ return;
+ }
+
+ // Only process hx-triggers for events with the "sse:" prefix
+ if (sseEventName.slice(0, 4) != "sse:") {
+ return;
+ }
+
+ var listener = function(event) {
+
+ // If parent is missing, then close SSE and remove listener
+ if (maybeCloseSSESource(elt)) {
+ source.removeEventListener(sseEventName, listener);
+ return;
+ }
+
+ // Trigger events to be handled by the rest of htmx
+ htmx.trigger(child, sseEventName, event);
+ htmx.trigger(child, "htmx:sseMessage", event);
+ }
+
+ // Register the new listener
+ api.getInternalData(elt).sseEventListener = listener;
+ source.addEventListener(sseEventName.slice(4), listener);
+ });
+ }
+
+ /**
+ * maybeCloseSSESource confirms that the parent element still exists.
+ * If not, then any associated SSE source is closed and the function returns true.
+ *
+ * @param {HTMLElement} elt
+ * @returns boolean
+ */
+ function maybeCloseSSESource(elt) {
+ if (!api.bodyContains(elt)) {
+ var source = api.getInternalData(elt).sseEventSource;
+ if (source != undefined) {
+ source.close();
+ // source = null
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
+ *
+ * @param {HTMLElement} elt
+ * @param {string} attributeName
+ */
+ function queryAttributeOnThisOrChildren(elt, attributeName) {
+
+ var result = [];
+
+ // If the parent element also contains the requested attribute, then add it to the results too.
+ if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
+ result.push(elt);
+ }
+
+ // Search all child nodes that match the requested attribute
+ elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
+ result.push(node);
+ });
+
+ return result;
+ }
+
+ /**
+ * @param {HTMLElement} elt
+ * @param {string} content
+ */
+ function swap(elt, content) {
+
+ api.withExtensions(elt, function(extension) {
+ content = extension.transformResponse(content, null, elt);
+ });
+
+ var swapSpec = api.getSwapSpecification(elt);
+ var target = api.getTarget(elt);
+ var settleInfo = api.makeSettleInfo(elt);
+
+ api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
+
+ settleInfo.elts.forEach(function (elt) {
+ if (elt.classList) {
+ elt.classList.add(htmx.config.settlingClass);
+ }
+ api.triggerEvent(elt, 'htmx:beforeSettle');
+ });
+
+ // Handle settle tasks (with delay if requested)
+ if (swapSpec.settleDelay > 0) {
+ setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
+ } else {
+ doSettle(settleInfo)();
+ }
+ }
+
+ /**
+ * doSettle mirrors much of the functionality in htmx that
+ * settles elements after their content has been swapped.
+ * TODO: this should be published by htmx, and not duplicated here
+ * @param {import("../htmx").HtmxSettleInfo} settleInfo
+ * @returns () => void
+ */
+ function doSettle(settleInfo) {
+
+ return function() {
+ settleInfo.tasks.forEach(function (task) {
+ task.call();
+ });
+
+ settleInfo.elts.forEach(function (elt) {
+ if (elt.classList) {
+ elt.classList.remove(htmx.config.settlingClass);
+ }
+ api.triggerEvent(elt, 'htmx:afterSettle');
+ });
+ }
+ }
+
+})();
\ No newline at end of file
diff --git a/static/vendor/htmx.min.js b/static/vendor/htmx.min.js
new file mode 100644
index 0000000..8336a57
--- /dev/null
+++ b/static/vendor/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var z={onLoad:t,process:qt,on:se,off:le,trigger:ie,ajax:hr,find:b,findAll:f,closest:d,values:function(e,t){var r=Jt(e,t||"post");return r.values},remove:F,addClass:B,removeClass:n,toggleClass:j,takeClass:V,defineExtension:xr,removeExtension:yr,logAll:X,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=z.config.wsBinaryType;return t},version:"1.9.0"};var C={addTriggerHandler:mt,bodyContains:ee,canAccessLocalStorage:D,filterValues:Qt,hasAttribute:q,getAttributeValue:J,getClosestMatch:c,getExpressionVars:ur,getHeaders:Yt,getInputValues:Jt,getInternalData:Y,getSwapSpecification:tr,getTriggerSpecs:We,getTarget:he,makeFragment:l,mergeObjects:te,makeSettleInfo:S,oobSwap:pe,selectAndSwap:Pe,settleImmediately:Ft,shouldCancel:$e,triggerEvent:ie,triggerErrorEvent:ne,withExtensions:w};var R=["get","post","put","delete","patch"];var O=R.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function G(e,t){return e.getAttribute&&e.getAttribute(t)}function q(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function J(e,t){return G(e,t)||G(e,"data-"+t)}function u(e){return e.parentElement}function Z(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function T(e,t,r){var n=J(t,r);var i=J(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function $(t,r){var n=null;c(t,function(e){return n=T(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function H(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=Z().createDocumentFragment()}return i}function L(e){return e.match(/
"+e+"",0);return r.querySelector("template").content}else{var n=H(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i("",1);case"col":return i("",2);case"tr":return i("",2);case"td":case"th":return i("",3);case"script":return i(""+e+"
",1);default:return i(e,0)}}}function K(e){if(e){e()}}function A(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function N(e){return A(e,"Function")}function I(e){return A(e,"Object")}function Y(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function k(e){var t=[];if(e){for(var r=0;r=0}function ee(e){if(e.getRootNode&&e.getRootNode()instanceof ShadowRoot){return Z().body.contains(e.getRootNode().host)}else{return Z().body.contains(e)}}function M(e){return e.trim().split(/\s+/)}function te(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function y(e){try{return JSON.parse(e)}catch(e){x(e);return null}}function D(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function e(e){return or(Z().body,function(){return eval(e)})}function t(t){var e=z.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){z.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function b(e,t){if(t){return e.querySelector(t)}else{return b(Z(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(Z(),e)}}function F(e,t){e=s(e);if(t){setTimeout(function(){F(e);e=null},t)}else{e.parentElement.removeChild(e)}}function B(e,t,r){e=s(e);if(r){setTimeout(function(){B(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function j(e,t){e=s(e);e.classList.toggle(t)}function V(e,t){e=s(e);Q(e.parentElement.children,function(e){n(e,t)});B(e,t)}function d(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function r(e){var t=e.trim();if(t.startsWith("<")&&t.endsWith("/>")){return t.substring(1,t.length-2)}else{return t}}function U(e,t){if(t.indexOf("closest ")===0){return[d(e,r(t.substr(8)))]}else if(t.indexOf("find ")===0){return[b(e,r(t.substr(5)))]}else if(t.indexOf("next ")===0){return[_(e,r(t.substr(5)))]}else if(t.indexOf("previous ")===0){return[W(e,r(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else{return Z().querySelectorAll(r(t))}}var _=function(e,t){var r=Z().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function re(e,t){if(t){return U(e,t)[0]}else{return U(Z().body,e)[0]}}function s(e){if(A(e,"String")){return b(e)}else{return e}}function oe(e,t,r){if(N(t)){return{target:Z().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function se(t,r,n){wr(function(){var e=oe(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=N(r);return e?r:n}function le(t,r,n){wr(function(){var e=oe(t,r,n);e.target.removeEventListener(e.event,e.listener)});return N(r)?r:n}var ue=Z().createElement("output");function fe(e,t){var r=$(e,t);if(r){if(r==="this"){return[ce(e,t)]}else{var n=U(e,r);if(n.length===0){x('The selector "'+r+'" on '+t+" returned no matches!");return[ue]}else{return n}}}}function ce(e,t){return c(e,function(e){return J(e,t)!=null})}function he(e){var t=$(e,"hx-target");if(t){if(t==="this"){return ce(e,"hx-target")}else{return re(e,t)}}else{var r=Y(e);if(r.boosted){return Z().body}else{return e}}}function de(e){var t=z.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=Z().querySelectorAll(t);if(r){Q(r,function(e){var t;var r=i.cloneNode(true);t=Z().createDocumentFragment();t.appendChild(r);if(!ge(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ie(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Ie(o,e,e,t,a)}Q(a.elts,function(e){ie(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);ne(Z().body,"htmx:oobErrorNoTarget",{content:i})}return e}function me(e,t,r){var n=$(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var t=e.id.replace("'","\\'");var r=e.tagName.replace(":","\\:");var n=a.querySelector(r+"[id='"+t+"']");if(n&&n!==a){var i=e.cloneNode();ve(e,n);o.tasks.push(function(){ve(e,i)})}}})}function be(e){return function(){n(e,z.config.addedClass);qt(e);yt(e);we(e);ie(e,"htmx:load")}}function we(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){ye(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;B(i,z.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(be(i))}}}function Se(e,t){var r=0;while(r-1){var t=e.replace(/