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}}}
+