From f6ffc90ec2bfb0efd309ad9315db910986db9c3f Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:16:53 -0500 Subject: [PATCH] V0.3.2: Improved admin UI and performance galore This commit fixes a plethora of bugs related to the admin UI, as well as dramatically improving the performance of in-place editing. Furthermore, several server bugs and misc bugs have been fixed. The admin UI is now entirely client side when adding, deleting, or editng a category or link. Other internal improvements hasve also been made. --- .prettierrc | 19 + go.mod | 3 + go.sum | 8 + src/main.go | 72 +- src/scripts/admin.js | 1106 +++++++++++++++++++++++++++ src/styles/adminUi.css | 70 ++ src/styles/main.scss | 86 ++- src/templates/layouts/admin.hbs | 20 + src/templates/layouts/main.hbs | 11 +- src/templates/views/admin/index.hbs | 968 +---------------------- src/templates/views/index.hbs | 6 +- zqdgr.config.json | 4 +- 12 files changed, 1383 insertions(+), 990 deletions(-) create mode 100644 .prettierrc create mode 100644 src/scripts/admin.js create mode 100644 src/styles/adminUi.css create mode 100644 src/templates/layouts/admin.hbs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..88dec24 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,19 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "semi": true, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "printWidth": 80, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "embeddedLanguageFormatting": "auto", + "vueIndentScriptAndStyle": false, + "htmlWhitespaceSensitivity": "css", + "insertPragma": false +} \ No newline at end of file diff --git a/go.mod b/go.mod index 6f62166..9cceb57 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/HugoSmits86/nativewebp v1.2.0 + github.com/NarmadaWeb/gonify/v3 v3.0.0-beta github.com/caarlos0/env/v11 v11.3.1 github.com/disintegration/imaging v1.6.2 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd @@ -19,6 +20,7 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sirupsen/logrus v1.8.1 // indirect + github.com/tdewolff/parse/v2 v2.8.3 // indirect github.com/tinylib/msgp v1.4.0 // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect @@ -44,6 +46,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/tdewolff/minify/v2 v2.24.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.66.0 // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/go.sum b/go.sum index 941ae82..deb3a4f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4= github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= +github.com/NarmadaWeb/gonify/v3 v3.0.0-beta h1:tNj6Rq9S3UUnF2800h6Ns7wmx+q7MwoZBVD24fPCSlo= +github.com/NarmadaWeb/gonify/v3 v3.0.0-beta/go.mod h1:AoLhZCGC/9XGqOE+0amArp/dFIZSfZSvbyPI/IbQ7Q0= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= @@ -64,6 +66,12 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tdewolff/minify/v2 v2.24.3 h1:BaKgWSFLKbKDiUskbeRgbe2n5d1Ci1x3cN/eXna8zOA= +github.com/tdewolff/minify/v2 v2.24.3/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE= +github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I= +github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= +github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/src/main.go b/src/main.go index 10d5a19..02ec089 100644 --- a/src/main.go +++ b/src/main.go @@ -17,7 +17,6 @@ import ( "log/slog" "mime/multipart" "net/http" - "net/url" "os" "os/signal" "path/filepath" @@ -27,9 +26,11 @@ import ( "time" "github.com/HugoSmits86/nativewebp" + "github.com/NarmadaWeb/gonify/v3" "github.com/caarlos0/env/v11" "github.com/disintegration/imaging" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/compress" "github.com/gofiber/fiber/v3/middleware/helmet" "github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/template/handlebars/v2" @@ -43,7 +44,7 @@ import ( _ "modernc.org/sqlite" ) -//go:embed assets/** templates/** schema.sql +//go:embed assets/** templates/** schema.sql scripts/**.js styles/**.css var embeddedAssets embed.FS var devContent = `", content) + case ".css": + return fmt.Sprintf("", content) + default: + return string(content) + } }) engine.AddFunc("devContent", func() string { @@ -717,10 +732,6 @@ func main() { return "" }) - engine.AddFunc("eq", func(a, b any) bool { - return a == b - }) - router := fiber.New(fiber.Config{ Views: engine, }) @@ -732,6 +743,17 @@ func main() { return c.Redirect().To("/assets/favicon.ico") }) + router.Use(compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + + router.Use(gonify.New(gonify.Config{ + MinifySVG: !app.DevMode, + MinifyCSS: !app.DevMode, + MinifyJS: !app.DevMode, + MinifyHTML: !app.DevMode, + })) + router.Use("/", static.New("./public", static.Config{ Browse: false, MaxAge: 31536000, @@ -822,7 +844,7 @@ func main() { return c.Render("views/admin/index", fiber.Map{ "Categories": app.CategoryManager.GetCategories(), - }, "layouts/main") + }, "layouts/admin") }) api := router.Group("/api") @@ -879,9 +901,7 @@ func main() { }) } - filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) - - iconPath, err := UploadFile(file, filename, contentType, c) + iconPath, err := UploadFile(file, contentType, c) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to upload file, please try again!", @@ -974,9 +994,7 @@ func main() { }) } - filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) - - iconPath, err := UploadFile(file, filename, contentType, c) + iconPath, err := UploadFile(file, contentType, c) if err != nil { slog.Error("Failed to upload file", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -1068,9 +1086,7 @@ func main() { oldIconPath := category.Icon - filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) - - iconPath, err := UploadFile(file, filename, contentType, c) + iconPath, err := UploadFile(file, contentType, c) if err != nil { slog.Error("Failed to upload file", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ @@ -1188,9 +1204,7 @@ func main() { oldIconPath := link.Icon - filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_")) - - iconPath, err := UploadFile(file, filename, contentType, c) + iconPath, err := UploadFile(file, contentType, c) if err != nil { slog.Error("Failed to upload file", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ diff --git a/src/scripts/admin.js b/src/scripts/admin.js new file mode 100644 index 0000000..11fe07d --- /dev/null +++ b/src/scripts/admin.js @@ -0,0 +1,1106 @@ +// idfk what this variable capitalization is, it's a mess +let modalContainer = document.getElementById("modal-container"); +let modal = modalContainer.querySelector("div"); +let pageElement = document.getElementById("blur-target"); +let iconUploadInput = document.getElementById("icon-upload"); +let targetCategoryID = null; +let activeModal = null; + +let teleportStorage = document.getElementById("teleport-storage"); +let confirmActions = document.getElementById("confirm-actions"); +let selectIconButton = document.getElementById("select-icon-button"); + +document.addEventListener("DOMContentLoaded", () => { + modalContainer.classList.remove("hidden"); + modalContainer.classList.add("flex"); +}); + +/** + * Submits a form to the given URL + * @param {Event} event - The event that triggered the function + * @param {string} url - The URL to submit the form to + * @param {"category" | "link"} target - The target to close the modal for + * @returns {Promise} + */ +async function submitRequest(event, url, target) { + event.preventDefault(); + let data = new FormData(event.target); + + let res = await fetch(url, { + method: "POST", + body: data, + }); + + if (res.status === 201) { + closeModal(target); + document.getElementById(`${target}-form`).reset(); + location.reload(); + } else { + let json = await res.json(); + document.getElementById(`${target}-message`).innerText = json.message; + } +} + +/** + * Adds an event listener for the given from to error check after the first submit + * @param {"category" | "link"} form - The form to initialize + * @returns {void} + */ +function addErrorListener(form) { + document + .getElementById(`${form}-form`) + .querySelector("button") + .addEventListener("click", (event) => { + event.target.parentElement + .querySelectorAll("[required]") + .forEach((el) => { + el.classList.add("invalid:border-[#861024]!"); + }); + }); +} + +/** + * Currently editing link or category + * @typedef {Object} actionButtonObj + * @property {string} clickAction - The function to be called when this button is clicked + * @property {string} label - The label of the button + */ + +/** + * Clones the edit actions template and returns it + * @param {[actionButtonObj, actionButtonObj]} primaryActions - The primary actions to clone + * @returns {HTMLElement} The cloned edit actions element + */ +function cloneEditActions(primaryActions) { + let editActions = document + .getElementById("template-edit-actions") + .cloneNode(true); + editActions.removeAttribute("id"); + editActions.classList.remove("hidden"); + + let i = 0; + for (i = 0; i < primaryActions.length; i++) { + let actionButtonObj = primaryActions[i]; + + let actionButton = editActions.querySelector( + `div[data-primary-actions] button:nth-child(${i + 1})` + ); + actionButton.setAttribute("onclick", actionButtonObj.clickAction); + actionButton.setAttribute("aria-label", actionButtonObj.label); + } + + return editActions; +} + +addErrorListener("link"); +document + .getElementById("link-form") + .addEventListener("submit", async (event) => { + event.preventDefault(); + let data = new FormData(event.target); + + let res = await fetch(`/api/category/${targetCategoryID}/link`, { + method: "POST", + body: data, + }); + + if (res.status === 201) { + let json = await res.json(); + + let category = document.getElementById( + `${targetCategoryID}_category` + ); + let linkGrid = category.nextElementSibling; + + let newLinkCard = document + .getElementById("template-link-card") + .cloneNode(true); + + newLinkCard.classList.remove("hidden"); + newLinkCard.classList.add("link-card", "admin", "relative"); + + let newLinkImgElement = newLinkCard.querySelector( + "div[data-img-container] img" + ); + + newLinkImgElement.src = await processFile(data.get("icon")); + newLinkImgElement.alt = data.get("name"); + + newLinkCard.querySelector("h3").textContent = data.get("name"); + newLinkCard.querySelector("p").textContent = + data.get("description"); + + newLinkCard.setAttribute("id", `${json.link.id}_link`); + + let editActions = cloneEditActions([ + { + clickAction: "editLink(this)", + label: "Edit link", + }, + { + clickAction: "deleteLink(this)", + label: "Delete link", + }, + ]); + + editActions.classList.add("absolute", "right-1", "top-1"); + + newLinkCard.appendChild(editActions); + + // append the card as the second to last element + linkGrid.insertBefore(newLinkCard, linkGrid.lastElementChild); + closeModal("link"); + + // after the close animation plays + setTimeout(() => { + document.getElementById(`link-form`).reset(); + }, 300); + } else { + let json = await res.json(); + document.getElementById(`link-message`).innerText = json.message; + } + }); + +addErrorListener("category"); +document + .getElementById("category-form") + .addEventListener("submit", async (event) => { + event.preventDefault(); + let data = new FormData(event.target); + + let res = await fetch(`/api/category`, { + method: "POST", + body: data, + }); + + if (res.status === 201) { + let json = await res.json(); + + let newCategory = document + .getElementById("template-category") + .cloneNode(true); + + let linkGrid = newCategory.querySelector("div:nth-child(2)"); + let categoryHeader = newCategory.querySelector(".category-header"); + categoryHeader.setAttribute("id", `${json.category.id}_category`); + categoryHeader.querySelector("h2").textContent = json.category.name; + + let editActions = cloneEditActions([ + { + clickAction: "editCategory(this)", + label: "Edit category", + }, + { + clickAction: "deleteCategory(this)", + label: "Delete category", + }, + ]); + + editActions.classList.add("pl-2"); + + categoryHeader.appendChild(editActions); + + let categoryImg = categoryHeader.querySelector(".category-img"); + + categoryImg.querySelector("img").src = await processFile( + data.get("icon") + ); + + linkGrid + .querySelector("div") + .setAttribute( + "onclick", + `openModal('link', ${json.category.id})` + ); + + let addCategoryButton = document.getElementById( + "add-category-button" + ); + addCategoryButton.parentElement.insertBefore( + categoryHeader, + addCategoryButton + ); + addCategoryButton.parentElement.insertBefore( + linkGrid, + addCategoryButton + ); + + closeModal("category"); + + // after the close animation plays + setTimeout(() => { + document.getElementById(`category-form`).reset(); + }, 300); + } else { + let json = await res.json(); + document.getElementById(`category-message`).innerText = + json.message; + } + }); + +// when the background is clicked, close the modal +modalContainer.addEventListener("click", (event) => { + if (event.target === modalContainer) { + closeModal(); + } +}); + +function selectIcon() { + iconUploadInput.click(); +} + +/** + * Processes a file and returns a data URL. + * @param {File} file The file to process. + * @returns {Promise} A promise that resolves to a data URL. + */ +async function processFile(file) { + let reader = new FileReader(); + return new Promise((resolve) => { + if (file.type === "image/svg+xml") { + reader.addEventListener("load", async (event) => { + let svgString = event.target.result; + + svgString = svgString.replaceAll( + "currentColor", + "oklch(87% 0.015 286)" + ); + + // turn svgString into a data URL + resolve( + "data:image/svg+xml;base64," + + btoa(unescape(encodeURIComponent(svgString))) + ); + }); + + reader.readAsText(file); + } else { + // these should be jpg, png, or webp + // make a DataURL out of it + reader.addEventListener("load", async (event) => { + resolve(event.target.result); + }); + + reader.readAsDataURL(file); + } + }); +} + +let targetedImageElement = null; +iconUploadInput.addEventListener("change", async (event) => { + let file = event.target.files[0]; + if (file === null) { + return; + } + + if (targetedImageElement === null) { + throw new Error( + "icon upload element was clicked, but no target image element was set" + ); + } + + let dataURL = await processFile(file); + targetedImageElement.src = dataURL; +}); + +function openModal(modalKind, categoryID) { + activeModal = modalKind; + targetCategoryID = categoryID; + + pageElement.style.filter = "blur(20px)"; + document.getElementById(modalKind + "-contents").classList.remove("hidden"); + + modalContainer.classList.add("is-visible"); + modal.classList.add("is-visible"); + + if (document.getElementById(modalKind + "-form") !== null) { + document.getElementById(modalKind + "-form").reset(); + } +} + +function closeModal() { + pageElement.style.filter = ""; + + modalContainer.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + + setTimeout(() => { + document + .getElementById(activeModal + "-contents") + .classList.add("hidden"); + activeModal = null; + }, 300); + + if (document.getElementById(activeModal + "-form") !== null) { + document + .getElementById(activeModal + "-form") + .querySelectorAll("[required]") + .forEach((el) => { + el.classList.remove("invalid:border-[#861024]!"); + }); + } + + targetCategoryID = null; +} + +/** + * Currently editing link or category + * @typedef {Object} currentlyEditingObj + * @property {"link" | "category" | undefined} type - The type of the currently editing element + * @property {string | undefined} linkID - The ID of the link we are currently editing if we are editing a link + * @property {string | undefined} categoryID - The ID of the category we are currently editing, or that the link belongs to + * @property {string | undefined} originalText - The original text of the currently editing element + * @property {string | undefined} originalDescription - The original description of the currently editing element + * @property {string | undefined} icon - The original icon of the currently editing element + * @property {Function | undefined} cleanup - The cleanup function for the currently editing element + */ + +/** @type {currentlyEditingObj} */ +let currentlyEditing = {}; + +/** + * Teleports the upload overlay to the given image node + * @param {HTMLElement} element The node to teleport into the destination + * @param {HTMLElement} destination The image node to teleport the upload overlay into + * @returns {HTMLElement} A reference to the teleported element + */ +function teleportElement(element, destination) { + destination.appendChild(element); +} + +function unteleportElement(element) { + teleportElement(element, teleportStorage); +} + +function confirmEdit() { + if (currentlyEditing.cleanup !== undefined) { + // this function could be called via deleting something, which doesn't have a cleanup function + currentlyEditing.cleanup(); + } + + switch (currentlyEditing.type) { + case "link": + confirmLinkEdit(); + break; + case "category": + confirmCategoryEdit(); + break; + default: + console.error("Unknown currentlyEditing type"); + break; + } +} + +function cancelEdit() { + if (currentlyEditing.cleanup !== undefined) { + // this function could be called via deleting something, which doesn't have a cleanup function + currentlyEditing.cleanup(); + } + + switch (currentlyEditing.type) { + case "link": + cancelLinkEdit(); + break; + case "category": + cancelCategoryEdit(currentlyEditing.originalText); + break; + default: + console.error("Unknown currentlyEditing type"); + break; + } + + currentlyEditing = {}; +} + +/** + * Edits the link with the given html element + * @param {HTMLElement} target The target element that was clicked + */ +function editLink(target) { + let startTime = performance.now(); + + // we do it in this dynamic way so that if we add a new link without refreshing the page, it still works + let linkEl = target.closest(".link-card"); + let linkID = parseInt(linkEl.id); + let categoryID = parseInt(linkEl.parentElement.previousElementSibling.id); + + if (currentlyEditing.linkID !== undefined) { + // cancel the edit if it's already in progress + cancelEdit(); + } + + let linkImg = linkEl.querySelector("div[data-img-container] img"); + let linkName = linkEl.querySelector("div[data-text-container] h3"); + let linkDesc = linkEl.querySelector("div[data-text-container] p"); + let editActions = linkEl.querySelector("[data-edit-actions]"); + + currentlyEditing = { + type: "link", + linkID: linkID, + categoryID: categoryID, + originalText: linkName.textContent, + originalDescription: linkDesc.textContent, + icon: linkImg.src, + }; + + if (!currentlyEditing.linkID || !currentlyEditing.categoryID) { + throw new Error("failed to find link ID or category ID"); + } + + iconUploadInput.accept = "image/*"; + targetedImageElement = linkImg; + + teleportElement(selectIconButton, linkImg.parentElement); + teleportElement(confirmActions, editActions); + + editActions.querySelector("div[data-primary-actions]").style.display = + "none"; + + requestAnimationFrame(() => { + currentlyEditing.cleanup = replaceWithResizableTextarea([ + { targetEl: linkName, fill: false }, + { targetEl: linkDesc }, + ]); + // by adding a delay, we dont block the UI + setTimeout(() => { + linkEl.querySelector("textarea").focus(); + }, 0); + }); +} + +async function confirmLinkEdit() { + let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`); + let linkNameInput = linkEl.querySelector("textarea"); + let linkDescInput = linkNameInput.nextElementSibling; + + linkNameInput.value = linkNameInput.value.trim(); + linkDescInput.value = linkDescInput.value.trim(); + if (linkNameInput.value === "") { + return; + } + + let formData = new FormData(); + if (linkNameInput.value !== currentlyEditing.originalText) { + formData.append("name", linkNameInput.value); + } + + if (linkDescInput.value !== currentlyEditing.originalDescription) { + formData.append("description", linkDescInput.value); + } + + if (iconUploadInput.files.length > 0) { + formData.append("icon", iconUploadInput.files[0]); + } + + // nothing to update + if ( + formData.get("name") === null && + formData.get("description") === null && + formData.get("icon") === null + ) { + return; + } + + let res = await fetch( + `/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`, + { + method: "PATCH", + body: formData, + } + ); + + if (res.status === 200) { + iconUploadInput.value = ""; + + currentlyEditing.icon = undefined; + cancelLinkEdit(linkNameInput.value, linkDescInput.value); + currentlyEditing = {}; + } else { + console.error("Failed to edit category"); + } +} + +function cancelLinkEdit( + text = currentlyEditing.originalText, + description = currentlyEditing.originalDescription +) { + let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`); + let linkInput = linkEl.querySelector("textarea"); + let linkTextarea = linkInput.nextElementSibling; + let linkImg = linkEl.querySelector("div[data-img-container] img"); + let editActions = linkEl.querySelector("[data-edit-actions]"); + + if (currentlyEditing.icon !== undefined) { + linkImg.src = currentlyEditing.icon; + } + + editActions.querySelector("div[data-primary-actions]").style.display = ""; + + // teleport the teleported elements back to the body for literally safe keeping + unteleportElement(selectIconButton); + unteleportElement(confirmActions); + + restoreElementFromInput(linkInput, text); + restoreElementFromInput(linkTextarea, description); + + currentlyEditing = {}; + targetedImageElement = null; +} + +/** + * Deletes the link with the given html element + * @param {HTMLElement} target The target element that was clicked + */ +function deleteLink(target) { + // we do it in this dynamic way so that if we add a new link without refreshing the page, it still works + let linkEl = target.closest(".link-card"); + let linkID = parseInt(linkEl.id); + let categoryID = parseInt(linkEl.parentElement.previousElementSibling.id); + + if (currentlyEditing.linkID !== undefined) { + // cancel the edit if it's already in progress + cancelEdit(); + } + + currentlyEditing.linkID = linkID; + currentlyEditing.categoryID = categoryID; + + let linkNameSpan = document.getElementById("link-name"); + linkNameSpan.textContent = linkEl.querySelector("h3").textContent; + + openModal("link-delete"); +} + +async function confirmDeleteLink() { + let res = await fetch( + `/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`, + { + method: "DELETE", + } + ); + + if (res.status === 200) { + let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`); + linkEl.remove(); + + closeModal(); + currentlyEditing = {}; + } +} + +/** + * Edits the category with the given html element + * @param {HTMLElement} target The target element that was clicked + */ +function editCategory(target) { + let categoryEl = target.closest(".category-header"); + let categoryID = parseInt(categoryEl.id); + + if (currentlyEditing.linkID !== undefined) { + // cancel the edit if it's already in progress + cancelEdit(); + } + + let categoryName = categoryEl.querySelector("h2"); + let categoryIcon = categoryEl.querySelector("div[data-img-container] img"); + let editActions = categoryEl.querySelector("[data-edit-actions]"); + + currentlyEditing = { + type: "category", + categoryID: categoryID, + originalText: categoryName.textContent, + icon: categoryIcon.src, + }; + + if (!currentlyEditing.categoryID) { + throw new Error("failed to find category ID"); + } + + iconUploadInput.accept = "image/svg+xml"; + targetedImageElement = categoryIcon; + + teleportElement(selectIconButton, categoryIcon.parentElement); + teleportElement(confirmActions, editActions); + + editActions.querySelector("div[data-primary-actions]").style.display = + "none"; + + requestAnimationFrame(() => { + currentlyEditing.cleanup = replaceWithResizableTextarea([ + { targetEl: categoryName, fill: false }, + ]); + // by adding a delay, we dont block the UI + setTimeout(() => { + categoryEl.querySelector("textarea").focus(); + }, 0); + }); +} + +async function confirmCategoryEdit() { + let categoryEl = document.getElementById( + `${currentlyEditing.categoryID}_category` + ); + let categoryInput = categoryEl.querySelector("textarea"); + + if (categoryInput.value === "") { + return; + } + + categoryInput.value = categoryInput.value.trim(); + + let formData = new FormData(); + if (categoryInput.value !== currentlyEditing.originalText) { + formData.append("name", categoryInput.value); + } + + if (iconUploadInput.files.length > 0) { + formData.append("icon", iconUploadInput.files[0]); + } + + // nothing to update + if (formData.get("name") === null && formData.get("icon") === null) { + return; + } + + let res = await fetch(`/api/category/${currentlyEditing.categoryID}`, { + method: "PATCH", + body: formData, + }); + + if (res.status === 200) { + iconUploadInput.value = ""; + + currentlyEditing.icon = undefined; + + cancelCategoryEdit(categoryInput.value); + + currentlyEditing = {}; + } else { + console.error("Failed to edit category"); + } +} + +function cancelCategoryEdit(text = currentlyEditing.originalText) { + let categoryEl = document.getElementById( + `${currentlyEditing.categoryID}_category` + ); + + let categoryInput = categoryEl.querySelector("textarea"); + let categoryIcon = categoryEl.querySelector(".category-img img"); + let editActions = categoryEl.querySelector("[data-edit-actions]"); + + if (currentlyEditing.icon !== undefined) { + categoryIcon.src = currentlyEditing.icon; + } + + unteleportElement(selectIconButton); + unteleportElement(confirmActions); + + editActions.querySelector("div[data-primary-actions]").style.display = ""; + + restoreElementFromInput(categoryInput, text); + + currentlyEditing = {}; + targetedImageElement = null; +} + +/** + * Deletes the category with the given html element + * @param {HTMLElement} target The target element that was clicked + */ +function deleteCategory(target) { + let categoryEl = target.closest(".category-header"); + + if (currentlyEditing.categoryID !== undefined) { + // cancel the edit if it's already in progress + cancelEdit(); + } + + let categoryID = parseInt(categoryEl.id); + + currentlyEditing.categoryID = categoryID; + + let categoryNameSpan = document.getElementById("category-name"); + categoryNameSpan.textContent = categoryEl.querySelector("h2").textContent; + + openModal("category-delete"); +} + +async function confirmDeleteCategory() { + let res = await fetch(`/api/category/${currentlyEditing.categoryID}`, { + method: "DELETE", + }); + + if (res.status === 200) { + let categoryEl = document.getElementById( + `${currentlyEditing.categoryID}_category` + ); + // get the next element and remove it (its the link grid) + let nextEl = categoryEl.nextElementSibling; + nextEl.remove(); + categoryEl.remove(); + + closeModal(); + currentlyEditing = {}; + } +} + +function roundToNearestHundredth(num) { + return Math.round(num * 100) / 100; +} + +const stylesToCopy = [ + "font-family", + "font-size", + "font-weight", + "font-style", + "color", + "line-height", + "letter-spacing", + "text-transform", + "text-align", +]; + +let _textMeasuringSpan, + _textMeasuringDiv = null; + +/** + * @typedef {Object} ResizeableTextareaOptions + * @property {HTMLElement} targetEl The element to replace. + * @property {boolean} [fill=true] Whether to make the textarea fill the available space, or grow with the text inside. + */ + +/** + * Replaces an element with a resizable textarea containing the same text. + * @param {ResizeableTextareaOptions[]} targetEls The elements to replace. + * @returns (() => void) A cleanup function to remove event listeners + */ +function replaceWithResizableTextarea(targetEls) { + let startTime = performance.now(); + + /** + * @typedef {Object} TargetInfo + * @property {HTMLElement} targetEl The element to replace. + * @property {boolean} fill Whether to make the textarea fill the available space, or grow with the text inside. + * @property {string} originalText The original text of the element + * @property {CSSStyleDeclaration} computedStyle The computed style of the element + * @property {DOMRect} boundingRect The bounding rect of the element + * @property {number} borderWidth The border width of the element + * @property {number} borderHeight The border height of the element + * @property {number} maxWidth The maximum width of the element + */ + + /** + * @type {TargetInfo[]} + */ + let targetInfos = []; + + targetEls.forEach((target) => { + let targetEl = target.targetEl; + let fill = target.fill === undefined ? true : target.fill; + // step 1: batch reads + const originalText = targetEl.textContent; + const computedStyle = window.getComputedStyle(targetEl); + const boundingRect = targetEl.getBoundingClientRect(); + const parentBoundingRect = + targetEl.parentElement.getBoundingClientRect(); + + const borderWidth = + parseFloat(computedStyle.borderLeftWidth) + + parseFloat(computedStyle.borderRightWidth); + const borderHeight = + parseFloat(computedStyle.borderTopWidth) + + parseFloat(computedStyle.borderBottomWidth); + + let maxWidth = parentBoundingRect.width - borderWidth; + // take care of category headers specifically because the parent bounding box contains two other elements + if (targetEl.tagName === "H2") { + let imageWidth = + targetEl.previousElementSibling.getBoundingClientRect().width; + let actionButtonWidth = + targetEl.nextElementSibling.getBoundingClientRect().width; + + maxWidth -= imageWidth + actionButtonWidth; + } + + maxWidth = roundToNearestHundredth(maxWidth); + + targetInfos.push({ + targetEl, + fill, + originalText, + computedStyle, + boundingRect, + borderWidth, + borderHeight, + maxWidth, + }); + }); + + const caretBuffer = 10; + + // step 2: calculate styles + let elsInitialStyles = []; + + targetInfos.forEach((targetInfo) => { + let fill = targetInfo.fill; + + let initialStyles = {}; + initialStyles.width = ""; + initialStyles.height = `${parseFloat( + roundToNearestHundredth(targetInfo.boundingRect.height) + )}px`; + if (fill) { + initialStyles.width = `100%`; + } else { + if (!_textMeasuringSpan) { + _textMeasuringSpan = document.createElement("span"); + // Keep it off-screen and static once appended + Object.assign(_textMeasuringSpan.style, { + position: "absolute", + left: "-9999px", + top: "0", + visibility: "hidden", + whiteSpace: "nowrap", + }); + document.body.appendChild(_textMeasuringSpan); + } + + stylesToCopy.forEach((prop) => { + _textMeasuringSpan.style[prop] = targetInfo.computedStyle[prop]; + }); + + _textMeasuringSpan.textContent = + targetInfo.originalText === "" + ? targetInfo.boundingRect.placeholder || "W" + : targetInfo.originalText; + + let measuredTextWidth = roundToNearestHundredth( + _textMeasuringSpan.getBoundingClientRect().width + ); + + let finalWidth = Math.min( + measuredTextWidth + caretBuffer, + targetInfo.maxWidth + ); + initialStyles.width = `${finalWidth}px`; + } + + elsInitialStyles.push({ + originalText: targetInfo.originalText, + targetEl: targetInfo.targetEl, + targetElComputedStyle: targetInfo.computedStyle, + fill: fill, + initialStyles, + }); + }); + + // step 3: batch writes + let inputElements = []; + + elsInitialStyles.forEach((elInfo) => { + const inputElement = document.createElement("textarea"); + inputElement.value = elInfo.originalText; + inputElement.className = "resizable-input"; + inputElement.placeholder = elInfo.targetEl.dataset.placeholder; + inputElement.dataset.originalElementType = elInfo.targetEl.tagName; + inputElement.dataset.originalClassName = elInfo.targetEl.className; + + let computedStyles = {}; + // Apply inherited styles + stylesToCopy.forEach((prop) => { + computedStyles[prop] = elInfo.targetElComputedStyle[prop]; + }); + + // Apply custom styles and calculated dimensions + Object.assign(inputElement.style, { + backgroundColor: "var(--color-base)", + border: `1px solid var(--color-highlight-sm)`, + borderRadius: "0.375rem", + resize: "none", + overflow: "hidden", + outline: "none", + ...computedStyles, // Apply calculated width and height + ...elInfo.initialStyles, // Apply calculated width and height + }); + + inputElement.setAttribute( + "maxlength", + elInfo.targetEl.tagName[0] === "H" ? 50 : 150 + ); + + inputElements.push({ + targetEl: elInfo.targetEl, + fill: elInfo.fill, + element: inputElement, + }); + }); + + function resize(inputElement, fill = false) { + const currentInputComputedStyle = window.getComputedStyle(inputElement); + const currentInputBorderWidth = + parseFloat(currentInputComputedStyle.borderLeftWidth) + + parseFloat(currentInputComputedStyle.borderRightWidth); + + const currentParentElBoundingRectWidth = + inputElement.parentElement.getBoundingClientRect().width; + + let maxWidth = roundToNearestHundredth( + currentParentElBoundingRectWidth + ); + + // is it maybe a bit of some math that doesnt entirely make sense to me? you bet. But does it work? Hell yeah it does + if (inputElement.dataset.originalElementType === "H2") { + let imageWidth = + inputElement.previousElementSibling.getBoundingClientRect() + .width; + let actionButtonWidth = + inputElement.nextElementSibling.getBoundingClientRect().width; + + // the brain cells rub together and this vaguely makes sense to me I think but I cant explain it + maxWidth -= imageWidth + actionButtonWidth + caretBuffer; + maxWidth += currentInputBorderWidth; + } + + let currentContentWidth; + + if (!fill) { + if (!_textMeasuringSpan) { + // Should already be created, but just in case + _textMeasuringSpan = document.createElement("span"); + Object.assign(_textMeasuringSpan.style, { + position: "absolute", + left: "-9999px", + top: "0", + visibility: "hidden", + whiteSpace: "nowrap", + }); + document.body.appendChild(_textMeasuringSpan); + } + + stylesToCopy.forEach((prop) => { + _textMeasuringSpan.style[prop] = + currentInputComputedStyle[prop]; + }); + + _textMeasuringSpan.textContent = + inputElement.value === "" + ? inputElement.placeholder || "W" + : inputElement.value; + + let measuredTextWidth = + _textMeasuringSpan.getBoundingClientRect().width; + + currentContentWidth = Math.min( + roundToNearestHundredth( + measuredTextWidth + currentInputBorderWidth + ) + caretBuffer, + maxWidth + ); + } else { + // if fill is true, width is flexible, but for measuring we need to know the *actual* width of the content + currentContentWidth = maxWidth; + } + + if (!_textMeasuringDiv) { + _textMeasuringDiv = document.createElement("div"); + Object.assign(_textMeasuringDiv.style, { + position: "absolute", + left: "-9999px", + top: "0", + visibility: "hidden", + // Allow wrapping exactly like a textarea + whiteSpace: "pre-wrap", + wordWrap: "break-word", + }); + document.body.appendChild(_textMeasuringDiv); + } + + [ + "borderLeftWidth", + "borderRightWidth", + "borderTopWidth", + "borderBottomWidth", + ...stylesToCopy, + ].forEach((prop) => { + _textMeasuringDiv.style[prop] = currentInputComputedStyle[prop]; + }); + + _textMeasuringDiv.style.width = `${currentContentWidth}px`; + _textMeasuringDiv.textContent = + inputElement.value === "" + ? inputElement.placeholder || "W" + : inputElement.value; + let measuredContentHeight = + _textMeasuringDiv.getBoundingClientRect().height; + + // we set the height = 0 so that if a row is deleted, the height will be recalculated correctly + inputElement.style.width = `${currentContentWidth}px`; + inputElement.style.height = "0px"; + inputElement.style.height = `${measuredContentHeight}px`; + } + + function resizeAll() { + inputElements.forEach((inputEl) => { + resize(inputEl.element, inputEl.fill); + }); + } + + // step 4: append + inputElements.forEach((inputEl) => { + inputEl.targetEl.parentNode.replaceChild( + inputEl.element, + inputEl.targetEl + ); + inputEl.element.addEventListener("input", () => { + resize(inputEl.element, inputEl.fill); + }); + }); + + let resizeScheduled = false; + + function windowResize() { + if (!resizeScheduled) { + resizeScheduled = true; + requestAnimationFrame(() => { + resizeAll(); + resizeScheduled = false; + }); + } + } + + window.addEventListener("resize", windowResize); + + // if the caller wants to focus the textarea, they can do it themselves + + return () => { + window.removeEventListener("resize", windowResize); + }; +} + +/** + * Restores an element from a textarea + * @param {HTMLElement} inputEl The textarea to restore + * @param {string} originalText The original text of the textarea + */ +function restoreElementFromInput(inputEl, originalText) { + const computedStyle = window.getComputedStyle(inputEl); + let styles = {}; + + let elementType = inputEl.dataset.originalElementType; + const newElement = document.createElement(elementType); + newElement.textContent = originalText; + newElement.className = inputEl.dataset.originalClassName; + newElement.dataset.placeholder = inputEl.placeholder; + + stylesToCopy.forEach((prop) => { + styles[prop] = computedStyle[prop]; + }); + + Object.assign(newElement.style, { + ...styles, + border: "1px solid #0000", + }); + + inputEl.parentNode.replaceChild(newElement, inputEl); +} diff --git a/src/styles/adminUi.css b/src/styles/adminUi.css new file mode 100644 index 0000000..ffbb209 --- /dev/null +++ b/src/styles/adminUi.css @@ -0,0 +1,70 @@ +.modal-bg { + visibility: hidden; + opacity: 0; +} + +.modal-bg.is-visible { + visibility: visible; + opacity: 1; +} + +.modal { + opacity: 0; +} + +.modal.is-visible { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .modal-bg { + visibility: hidden; + opacity: 0; + + transition: opacity 0.3s ease, visibility 0s 0.3s; + transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1); + } + + .modal-bg.is-visible { + visibility: visible; + opacity: 1; + transition-delay: 0s; + } + + .modal { + opacity: 0; + transform: translateY(20px) scale(0.95); + + transition: opacity 0.3s ease, transform 0.3s ease; + transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1); + } + + .modal.is-visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); + transition-delay: 0s; + } +} + +.action-button { + display: flex; + width: fit-content; + height: fit-content; + padding: 0.25rem; + background-color: var(--color-highlight-sm); + border: 1px solid color-mix(in srgb, var(--color-highlight) 70%, #0000); + border-radius: 9999px; + box-shadow: var(--shadow-sm); + cursor: pointer; + transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1); + contain: layout style paint; + + &:hover { + filter: brightness(125%); + } + + &:active { + filter: brightness(95%); + } +} diff --git a/src/styles/main.scss b/src/styles/main.scss index 2ebfdd7..eef15fe 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -1,12 +1,11 @@ @import "tailwindcss"; - @theme { --color-accent: oklch(57.93% 0.258 294.12); --color-success: oklch(70.19% 0.158 160.44); --color-error: oklch(53% 0.251 28.48); - --color-base: oklch(11% .007 285); + --color-base: oklch(11% 0.007 285); --color-surface: oklch(19% 0.007 285.66); --color-overlay: oklch(26% 0.008 285.66); @@ -15,18 +14,21 @@ --color-text: oklch(87% 0.015 286); --color-highlight-sm: oklch(30.67% 0.007 286); - --color-highlight: oklch(39.26% 0.010 286); + --color-highlight: oklch(39.26% 0.01 286); --color-highlight-lg: oklch(47.72% 0.011 286); } @font-face { font-family: "Instrument Sans"; - src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2"); + src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") + format("woff2"); font-display: swap; } :root { - --default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --default-font-family: "Instrument Sans", ui-sans-serif, system-ui, + sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; } html { @@ -35,25 +37,27 @@ html { color: var(--color-text); } -h1, -h2, -h3, -h4, -h5, -h6 { - @apply font-semibold; -} +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 600; + } -h1 { - font-size: clamp(42px, 10vw, 64px); -} + h1 { + font-size: clamp(42px, 10vw, 64px); + } -h2 { - font-size: clamp(30px, 6vw, 36px); -} + h2 { + font-size: clamp(30px, 6vw, 36px); + } -h3 { - font-size: 1.25rem; + h3 { + font-size: 1.25rem; + } } button { @@ -62,7 +66,7 @@ button { input:not(.search) { @apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden; - + &[type="file"] { @apply p-0 cursor-pointer; @@ -88,7 +92,8 @@ input:not(.search) { &:not(.admin) { &:hover { - box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); transform: translateY(-4px); } @@ -116,7 +121,7 @@ input:not(.search) { /* Div that holds the image */ .link-card div[data-img-container] { flex-shrink: 0; - margin-right: 0.5rem; + padding-right: 0.5rem; } .link-card div[data-img-container] img { @@ -128,13 +133,36 @@ input:not(.search) { /* Div that holds the text */ .link-card div[data-text-container] { + display: flex; + flex-grow: 1; + flex-direction: column; + row-gap: 1px; + overflow: hidden; word-break: break-all; } .link-card div[data-text-container] p { color: var(--color-subtle); + white-space: pre-wrap; + border: 1px solid #0000; + min-height: 22px; } +.new-link-card { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.625rem; + border: 0.125rem dashed var(--color-subtle); + border-radius: 1rem; + transition: box-shadow, transofrm 150ms cubic-bezier(0.45, 0, 0.55, 1); + cursor: pointer; + user-select: none; + + &:hover { + text-decoration: underline; + } +} .categoy-header { display: flex; @@ -156,4 +184,12 @@ input:not(.search) { word-break: break-all; border-width: 1px; border-color: #0000; -} \ No newline at end of file +} + +.link-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); + gap: 0.5rem; + padding: 0.625rem; + contain: layout style paint; +} diff --git a/src/templates/layouts/admin.hbs b/src/templates/layouts/admin.hbs new file mode 100644 index 0000000..3c1df89 --- /dev/null +++ b/src/templates/layouts/admin.hbs @@ -0,0 +1,20 @@ + + + + + Passport + + + + {{{embedFile "assets/tailwind.css"}}} + {{{embedFile "styles/adminUi.css"}}} + + + + {{embed}} + + +{{{devContent}}} + + \ No newline at end of file diff --git a/src/templates/layouts/main.hbs b/src/templates/layouts/main.hbs index 8ab1f0d..87a5675 100644 --- a/src/templates/layouts/main.hbs +++ b/src/templates/layouts/main.hbs @@ -3,14 +3,17 @@ Passport - - - - + + + + {{{embedFile "assets/tailwind.css"}}} {{embed}} + {{{devContent}}} + \ No newline at end of file diff --git a/src/templates/views/admin/index.hbs b/src/templates/views/admin/index.hbs index e12308d..797169f 100644 --- a/src/templates/views/admin/index.hbs +++ b/src/templates/views/admin/index.hbs @@ -17,15 +17,14 @@
{{#each Categories}} -
+
{{this.Name}}
-

{{this.Name}}

-
+

{{~ this.Name ~}}

+
-
-
-
+