/* Client-side behaviour. Everything here is progressive: the application is server-rendered and works without this file, apart from the streaming reply, which is htmx's SSE extension rather than anything hand-written below. */ (function () { "use strict"; var THEME_KEY = "lembas-theme"; var THEMES = ["moria", "shire"]; /* --- Theme ------------------------------------------------------------- Stored locally so the choice applies instantly and survives being signed out, and mirrored to the server so it follows the user to another device. The server call is best-effort: a failure must not undo the local switch. */ function currentTheme() { return document.documentElement.dataset.theme || THEMES[0]; } function applyTheme(name) { if (THEMES.indexOf(name) === -1) return; document.documentElement.dataset.theme = name; try { localStorage.setItem(THEME_KEY, name); } catch (e) { /* private mode */ } document.querySelectorAll("[data-theme-toggle]").forEach(function (el) { el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)" : "Switch to Moria (dark)"); }); if (document.body.dataset.authenticated === "true") { fetch("/api/preferences/theme", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ theme: name }) }).catch(function () { /* preference is already applied locally */ }); } } function toggleTheme() { applyTheme(currentTheme() === "moria" ? "shire" : "moria"); } /* --- Textarea autosize ------------------------------------------------- Grows the composer with its content up to a cap, after which it scrolls. */ function autosize(el) { if (!el) return; var max = parseInt(el.dataset.maxHeight || "320", 10); el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, max) + "px"; el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; } /* --- Copy -------------------------------------------------------------- Falls back to a hidden textarea because navigator.clipboard is unavailable on pages served over plain http, which self-hosted installs often are. */ function copyText(text, trigger) { function done() { if (!trigger) return; var original = trigger.getAttribute("aria-label"); trigger.classList.add("is-copied"); trigger.setAttribute("aria-label", "Copied"); setTimeout(function () { trigger.classList.remove("is-copied"); if (original) trigger.setAttribute("aria-label", original); }, 1400); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(function () {}); return; } var scratch = document.createElement("textarea"); scratch.value = text; scratch.setAttribute("readonly", ""); scratch.style.position = "fixed"; scratch.style.opacity = "0"; document.body.appendChild(scratch); scratch.select(); try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ } document.body.removeChild(scratch); } /* --- Thread scrolling -------------------------------------------------- Only auto-scrolls when the reader is already near the bottom, so scrolling up to re-read something is not yanked away by an incoming token. */ var STICK_THRESHOLD = 120; function isNearBottom(el) { return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD; } function scrollThread(force) { var thread = document.getElementById("thread-scroll"); if (!thread) return; if (force || isNearBottom(thread)) { thread.scrollTop = thread.scrollHeight; } } /* --- Attachments ------------------------------------------------------- Files are uploaded one at a time as soon as they are chosen, dropped or pasted, rather than all at once when the message is sent. The chip (or the rejection) then appears immediately, and a large file cannot make the send button appear to hang. */ function uploadFiles(fileList) { var input = document.getElementById("file-input"); var target = document.getElementById("attachments"); if (!input || !target || !fileList || !fileList.length) return; var url = input.dataset.uploadUrl; Array.prototype.forEach.call(fileList, function (file) { var body = new FormData(); body.append("file", file, file.name); fetch(url, { method: "POST", body: body, credentials: "same-origin" }) .then(function (response) { return response.text(); }) .then(function (html) { target.insertAdjacentHTML("beforeend", html); // The chip's remove button is htmx-driven, so the new markup has to // be announced or its attributes are inert. if (window.htmx) window.htmx.process(target.lastElementChild); }) .catch(function () { target.insertAdjacentHTML( "beforeend", '
' + '' + 'Upload failed.
' ); // Set as text, never as HTML: the filename comes from the user. target.lastElementChild.querySelector(".chip__name").textContent = file.name; }); }); } function setupDropzone() { var zone = document.querySelector("[data-dropzone]"); if (!zone) return; /* dragenter/dragleave fire for every child element the pointer crosses, so a plain toggle flickers. Counting entries and exits is the standard fix. */ var depth = 0; function hasFiles(event) { return event.dataTransfer && Array.prototype.indexOf.call( event.dataTransfer.types || [], "Files" ) !== -1; } zone.addEventListener("dragenter", function (event) { if (!hasFiles(event)) return; event.preventDefault(); depth += 1; zone.classList.add("is-dropping"); }); zone.addEventListener("dragover", function (event) { if (hasFiles(event)) event.preventDefault(); }); zone.addEventListener("dragleave", function () { depth = Math.max(0, depth - 1); if (depth === 0) zone.classList.remove("is-dropping"); }); zone.addEventListener("drop", function (event) { if (!hasFiles(event)) return; event.preventDefault(); depth = 0; zone.classList.remove("is-dropping"); uploadFiles(event.dataTransfer.files); }); /* Pasting a screenshot straight into the composer. Only files are taken; pasted text must still behave as text. */ document.addEventListener("paste", function (event) { var composer = event.target.closest("[data-composer-input]"); if (!composer || !event.clipboardData) return; var files = Array.prototype.filter.call( event.clipboardData.files || [], function (f) { return f && f.size; } ); if (!files.length) return; event.preventDefault(); uploadFiles(files); }); } window.lembas = { applyTheme: applyTheme, toggleTheme: toggleTheme, copyText: copyText, scrollThread: scrollThread, autosize: autosize, uploadFiles: uploadFiles }; /* --- Wiring ------------------------------------------------------------ */ document.addEventListener("click", function (event) { var toggle = event.target.closest("[data-theme-toggle]"); if (toggle) { event.preventDefault(); toggleTheme(); return; } var copy = event.target.closest("[data-copy]"); if (copy) { event.preventDefault(); var source = document.getElementById(copy.dataset.copy); if (source) copyText(source.textContent.trim(), copy); return; } /* Show/hide a panel by selector, so templates do not each carry their own inline toggle script. */ var toggle = event.target.closest("[data-toggle]"); if (toggle) { event.preventDefault(); var panel = document.querySelector(toggle.dataset.toggle); if (!panel) return; var nowOpen = panel.hasAttribute("hidden"); panel.toggleAttribute("hidden"); toggle.setAttribute("aria-expanded", nowOpen ? "true" : "false"); toggle.classList.toggle("is-active", nowOpen); } }); document.addEventListener("input", function (event) { if (event.target.matches("[data-autosize]")) autosize(event.target); }); /* A "select all" box driving every checkbox inside a container. Scoped to a selector rather than the whole page, so a list can carry more than one. */ document.addEventListener("change", function (event) { var master = event.target.closest("[data-select-all]"); if (!master) return; var scope = document.querySelector(master.dataset.selectAll); if (!scope) return; scope.querySelectorAll('input[type="checkbox"]').forEach(function (box) { box.checked = master.checked; }); }); /* Enter sends, Shift+Enter inserts a newline -- the convention every chat application uses. Left alone on touch devices, where there is no easy Shift and Enter should mean "new line". */ document.addEventListener("keydown", function (event) { if (event.key !== "Enter" || event.shiftKey) return; var composer = event.target.closest("[data-composer-input]"); if (!composer) return; if (window.matchMedia("(pointer: coarse)").matches) return; event.preventDefault(); var form = composer.closest("form"); if (form && composer.value.trim()) form.requestSubmit(); }); document.addEventListener("DOMContentLoaded", function () { document.querySelectorAll("[data-autosize]").forEach(autosize); scrollThread(true); applyTheme(currentTheme()); setupDropzone(); }); /* After any htmx swap: re-measure the composer and follow new content. */ document.body.addEventListener("htmx:afterSwap", function () { document.querySelectorAll("[data-autosize]").forEach(autosize); scrollThread(false); }); /* Tokens arriving over SSE are appended outside the normal swap cycle. */ document.body.addEventListener("htmx:sseMessage", function () { scrollThread(false); }); })();