diff --git a/.gitignore b/.gitignore
index 6460633..9aad317 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
passport
+passport-*
.env
passport.db*
public
diff --git a/Dockerfile b/Dockerfile
index 6dee7f7..6a26787 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,21 +4,6 @@ FROM golang:1.25 AS builder
RUN apt update && apt install -y upx unzip
RUN curl -fsSL https://bun.com/install | BUN_INSTALL=/usr bash
-
-ARG TARGETARCH
-RUN set -eux; \
- echo "Building for architecture: ${TARGETARCH}"; \
- case "${TARGETARCH}" in \
- "amd64") \
- arch_suffix='x64' ;; \
- "arm64") \
- arch_suffix='arm64' ;; \
- *) \
- echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
- esac; \
- curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-${arch_suffix}"; \
- mv "tailwindcss-linux-${arch_suffix}" /usr/local/bin/tailwindcss; \
- chmod +x /usr/local/bin/tailwindcss;
RUN go install github.com/juls0730/zqdgr@latest
diff --git a/README.md b/README.md
index 39946e3..964afc0 100644
--- a/README.md
+++ b/README.md
@@ -72,23 +72,23 @@ You can then run the binary.
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.
-| Environment Variable | Description | Required | Default |
-| ------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
-| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
-| `WEATHER_API_KEY` | The OpenWeather API key | true | |
-| `WEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
-| `WEATHER_LAT` | The latitude of your location | true | |
-| `WEATHER_LON` | The longitude of your location | true | |
-| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
+| Environment Variable | Description | Required | Default |
+| -------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
+| `PASSPORT_WEATHER_API_KEY` | The OpenWeather API key | true | |
+| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
+| `WEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
+| `WEATHER_LAT` | The latitude of your location | true | |
+| `WEATHER_LON` | The longitude of your location | true | |
+| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
#### Uptime configuration
The uptime integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the UptimeRobot integration.
-| Environment Variable | Description | Required | Default |
-| ------------------------ | ------------------------------------------------- | -------- | ------- |
-| `UPTIME_API_KEY` | The UptimeRobot API key | true | |
-| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
+| Environment Variable | Description | Required | Default |
+| ------------------------- | ------------------------------------------------- | -------- | ------- |
+| `PASSPORT_UPTIME_API_KEY` | The UptimeRobot API key | true | |
+| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
### Adding links and categories
diff --git a/bun.lock b/bun.lock
index f8b10fc..3c526e7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"name": "passport-css-compiler",
"devDependencies": {
"@fullhuman/postcss-purgecss": "^7.0.2",
+ "baseline-browser-mapping": "^2.9.2",
"cssnano": "^7.1.1",
"postcss": "^8.4.35",
"postcss-cli": "^11.0.0",
@@ -122,7 +123,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.8.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA=="],
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.2", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-PxSsosKQjI38iXkmb3d0Y32efqyA0uW4s41u4IVBsLlWLhCiYNpH/AfNOVWRqCQBlD8TFJTz6OUWNd4DFJCnmw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@@ -478,6 +479,8 @@
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA=="],
+
"css-blank-pseudo/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
"css-has-pseudo/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
diff --git a/package.json b/package.json
index 9dfe9ee..29ea8e4 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@fullhuman/postcss-purgecss": "^7.0.2",
+ "baseline-browser-mapping": "^2.9.2",
"cssnano": "^7.1.1",
"postcss": "^8.4.35",
"postcss-cli": "^11.0.0",
diff --git a/src/main.go b/src/main.go
index ea5afc9..e9621d4 100644
--- a/src/main.go
+++ b/src/main.go
@@ -80,7 +80,15 @@ socket.addEventListener('message', (event) => {
setTimeout(testPage, 150);
}
});
-`
+
+`
var (
insertCategoryStmt *sql.Stmt
@@ -1011,8 +1019,13 @@ func main() {
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{
- "message": "Failed to upload file, please try again!",
+ status := fiber.StatusInternalServerError
+ if strings.Contains(err.Error(), "unsupported file type") {
+ status = fiber.StatusBadRequest
+ }
+
+ return c.Status(status).JSON(fiber.Map{
+ "message": "Failed to upload file: " + err.Error(),
})
}
@@ -1103,8 +1116,13 @@ func main() {
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{
- "message": "Failed to upload file, please try again!",
+ status := fiber.StatusInternalServerError
+ if strings.Contains(err.Error(), "unsupported file type") {
+ status = fiber.StatusBadRequest
+ }
+
+ return c.Status(status).JSON(fiber.Map{
+ "message": "Failed to upload file: " + err.Error(),
})
}
@@ -1221,8 +1239,13 @@ func main() {
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{
- "message": "Failed to upload file, please try again!",
+ status := fiber.StatusInternalServerError
+ if strings.Contains(err.Error(), "unsupported file type") {
+ status = fiber.StatusBadRequest
+ }
+
+ return c.Status(status).JSON(fiber.Map{
+ "message": "Failed to upload file: " + err.Error(),
})
}
diff --git a/src/scripts/admin.js b/src/scripts/admin.js
index 3018873..7849e3e 100644
--- a/src/scripts/admin.js
+++ b/src/scripts/admin.js
@@ -11,38 +11,13 @@ let activeModal = null;
let teleportStorage = document.getElementById("teleport-storage");
let confirmActions = document.getElementById("confirm-actions");
let selectIconButton = document.getElementById("select-icon-button");
+let loadingSpinner = document.getElementById("template-loading-spinner");
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
@@ -76,9 +51,7 @@ function addErrorListener(form) {
function cloneEditActions(primaryActions) {
let editActions = document
.getElementById("template-edit-actions")
- .cloneNode(true);
- editActions.removeAttribute("id");
- editActions.classList.remove("hidden");
+ .content.firstElementChild.cloneNode(true);
let i = 0;
for (i = 0; i < primaryActions.length; i++) {
@@ -94,6 +67,46 @@ function cloneEditActions(primaryActions) {
return editActions;
}
+function strToBase64(str) {
+ // TextEncoder: Always UTF8
+ const uint8Array = new TextEncoder().encode(str);
+ let binary = "";
+
+ for (let i = 0; i < uint8Array.length; ++i) {
+ binary += String.fromCharCode(uint8Array[i]);
+ }
+
+ return btoa(binary);
+}
+function base64ToStr(base64Str) {
+ return atob(base64Str);
+}
+
+/**
+ * Sets the edit actions to be loading
+ * @param {HTMLElement} confirmButton The confirm button to set to loading
+ */
+function setConfirmLoading(confirmButton) {
+ const originalContents = strToBase64(confirmButton.innerHTML);
+ const loadingContents = `${loadingSpinner.innerHTML}`;
+ confirmButton.disabled = true;
+ // disable the cancel button too
+ confirmButton.nextElementSibling.disabled = true;
+ confirmButton.dataset.originContents = originalContents;
+ confirmButton.innerHTML = loadingContents;
+}
+
+/**
+ * Clears the loading state of the confirm button
+ * @param {HTMLElement} confirmButton The confirm button to clear the loading state of
+ */
+function clearConfirmLoading(confirmButton) {
+ confirmButton.disabled = false;
+ confirmButton.nextElementSibling.disabled = false;
+ confirmButton.innerHTML = base64ToStr(confirmButton.dataset.originContents);
+ confirmButton.dataset.originContents = "";
+}
+
addErrorListener("link");
document
.getElementById("link-form")
@@ -101,66 +114,77 @@ document
event.preventDefault();
let data = new FormData(event.target);
- let res = await fetch(`/api/category/${targetCategoryID}/link`, {
+ const submitButton = event.target.querySelector("button");
+ let originalContents = submitButton.innerHTML;
+
+ submitButton.disabled = true;
+ submitButton.innerHTML = `${loadingSpinner.innerHTML}Adding link...`;
+
+ await fetch(`/api/category/${targetCategoryID}/link`, {
method: "POST",
body: data,
- });
+ })
+ .then(async (res) => {
+ let json = await res.json();
- if (res.status === 201) {
- let json = await res.json();
+ if (!res.ok) {
+ throw new Error(json.message);
+ }
- let category = document.getElementById(
- `${targetCategoryID}_category`
- );
- let linkGrid = category.nextElementSibling;
+ let category = document.getElementById(
+ `${targetCategoryID}_category`
+ );
+ let linkGrid = category.nextElementSibling;
- let newLinkCard = document
- .getElementById("template-link-card")
- .cloneNode(true);
+ let newLinkCard = document
+ .getElementById("template-link-card")
+ .content.firstElementChild.cloneNode(true);
- newLinkCard.classList.remove("hidden");
- newLinkCard.classList.add("link-card", "admin", "relative");
+ let newLinkImgElement = newLinkCard.querySelector(
+ "div:first-child img"
+ );
- let newLinkImgElement = newLinkCard.querySelector(
- "div:first-child img"
- );
+ newLinkImgElement.src = await processFile(data.get("icon"));
+ newLinkImgElement.alt = data.get("name");
- 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.querySelector("h3").textContent = data.get("name");
- newLinkCard.querySelector("p").textContent =
- data.get("description");
+ newLinkCard.setAttribute("id", `${json.link.id}_link`);
- newLinkCard.setAttribute("id", `${json.link.id}_link`);
+ let editActions = cloneEditActions([
+ {
+ clickAction: "editLink(this)",
+ label: "Edit link",
+ },
+ {
+ clickAction: "deleteLink(this)",
+ label: "Delete link",
+ },
+ ]);
- let editActions = cloneEditActions([
- {
- clickAction: "editLink(this)",
- label: "Edit link",
- },
- {
- clickAction: "deleteLink(this)",
- label: "Delete link",
- },
- ]);
+ editActions.classList.add("absolute", "right-1", "top-1");
- editActions.classList.add("absolute", "right-1", "top-1");
+ newLinkCard.appendChild(editActions);
- newLinkCard.appendChild(editActions);
+ // append the card as the second to last element
+ linkGrid.insertBefore(newLinkCard, linkGrid.lastElementChild);
+ closeModal("link");
- // 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;
- }
+ // after the close animation plays
+ setTimeout(() => {
+ document.getElementById(`link-form`).reset();
+ document.getElementById(`link-message`).innerText = "";
+ }, 300);
+ })
+ .catch((err) => {
+ document.getElementById(`link-message`).innerText = err.message;
+ })
+ .finally(() => {
+ submitButton.disabled = false;
+ submitButton.innerHTML = originalContents;
+ });
});
addErrorListener("category");
@@ -170,74 +194,94 @@ document
event.preventDefault();
let data = new FormData(event.target);
- let res = await fetch(`/api/category`, {
+ const submitButton = event.target.querySelector("button");
+ const originalContents = submitButton.innerHTML;
+
+ submitButton.disabled = true;
+ submitButton.innerHTML = `${loadingSpinner.innerHTML}Adding category...`;
+
+ await fetch(`/api/category`, {
method: "POST",
body: data,
- });
+ })
+ .then(async (res) => {
+ let json = await res.json();
- if (res.status === 201) {
- let json = await res.json();
+ if (!res.ok) {
+ throw new Error(json.message);
+ }
- let newCategory = document
- .getElementById("template-category")
- .cloneNode(true);
+ let newCategory = document
+ .getElementById("template-category")
+ .content.firstElementChild.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 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",
- },
- ]);
+ let editActions = cloneEditActions([
+ {
+ clickAction: "editCategory(this)",
+ label: "Edit category",
+ },
+ {
+ clickAction: "deleteCategory(this)",
+ label: "Delete category",
+ },
+ ]);
- editActions.classList.add("pl-2");
+ editActions.classList.add("pl-2", "flex-shrink-0");
- categoryHeader.appendChild(editActions);
+ categoryHeader.appendChild(editActions);
- let categoryImg = categoryHeader.querySelector("div:first-child");
+ let categoryImg =
+ categoryHeader.querySelector("div:first-child");
- categoryImg.querySelector("img").src = await processFile(
- data.get("icon")
- );
-
- linkGrid
- .querySelector("div")
- .setAttribute(
- "onclick",
- `openModal('link', ${json.category.id})`
+ categoryImg.querySelector("img").src = await processFile(
+ data.get("icon")
);
- let addCategoryButton = document.getElementById(
- "add-category-button"
- );
- addCategoryButton.parentElement.insertBefore(
- categoryHeader,
- addCategoryButton
- );
- addCategoryButton.parentElement.insertBefore(
- linkGrid,
- addCategoryButton
- );
+ linkGrid
+ .querySelector("div")
+ .setAttribute(
+ "onclick",
+ `openModal('link', ${json.category.id})`
+ );
- closeModal("category");
+ let addCategoryButton = document.getElementById(
+ "add-category-button"
+ );
+ addCategoryButton.parentElement.insertBefore(
+ categoryHeader,
+ addCategoryButton
+ );
+ addCategoryButton.parentElement.insertBefore(
+ linkGrid,
+ addCategoryButton
+ );
- // 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;
- }
+ closeModal("category");
+
+ // after the close animation plays
+ setTimeout(() => {
+ document.getElementById(`category-form`).reset();
+ document.getElementById(`category-message`).innerText = "";
+ }, 300);
+ })
+ .catch((err) => {
+ document.getElementById(`category-message`).innerText =
+ err.message;
+ })
+ .finally(() => {
+ submitButton.disabled = false;
+ submitButton.innerHTML = originalContents;
+ });
});
// when the background is clicked, close the modal
@@ -330,6 +374,9 @@ function closeModal() {
document
.getElementById(activeModal + "-contents")
.classList.add("hidden");
+ if (document.getElementById(`${activeModal}-message`) !== null) {
+ document.getElementById(`${activeModal}-message`).innerText = "";
+ }
activeModal = null;
}, 300);
@@ -374,22 +421,37 @@ function unteleportElement(element) {
teleportElement(element, teleportStorage);
}
-function confirmEdit() {
+/**
+ * Confirms the edit
+ * @param {Event} ev The event that triggered the function
+ */
+async function confirmEdit(ev) {
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;
+ let confirmButton = ev.target.closest("button");
+
+ try {
+ setConfirmLoading(confirmButton);
+
+ switch (currentlyEditing.type) {
+ case "link":
+ await confirmLinkEdit();
+ break;
+ case "category":
+ await confirmCategoryEdit();
+ break;
+ default:
+ console.error("Unknown currentlyEditing type");
+ break;
+ }
+ } catch (err) {
+ // TODO: tell the user that something went wrong?
+ console.error(err);
+ } finally {
+ clearConfirmLoading(confirmButton);
}
}
@@ -450,7 +512,7 @@ function editLink(target) {
throw new Error("failed to find link ID or category ID");
}
- iconUploadInput.accept = "image/*";
+ iconUploadInput.accept = "image/jpeg,image/png,image/webp,image/svg+xml";
targetedImageElement = linkImg;
teleportElement(selectIconButton, linkImg.parentElement);
@@ -470,6 +532,10 @@ function editLink(target) {
});
}
+/**
+ * Confirms the edit of the link
+ * @param {Event} ev The event that triggered the function
+ */
async function confirmLinkEdit() {
let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`);
let linkNameInput = linkEl.querySelector("textarea");
@@ -504,23 +570,19 @@ async function confirmLinkEdit() {
return;
}
- let res = await fetch(
+ await fetch(
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
{
method: "PATCH",
body: formData,
}
- );
-
- if (res.status === 200) {
+ ).then(() => {
iconUploadInput.value = "";
currentlyEditing.icon = undefined;
cancelLinkEdit(linkNameInput.value, linkDescInput.value);
currentlyEditing = {};
- } else {
- console.error("Failed to edit category");
- }
+ });
}
function cancelLinkEdit(
@@ -556,7 +618,7 @@ function cancelLinkEdit(
*/
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 linkEl = target.closest("[data-card]");
let linkID = parseInt(linkEl.id);
let categoryID = parseInt(linkEl.parentElement.previousElementSibling.id);
@@ -577,21 +639,47 @@ function deleteLink(target) {
openModal("link-delete");
}
-async function confirmDeleteLink() {
- let res = await fetch(
+/**
+ * Confirms the deletion of the link
+ * @param {Event} ev The event that triggered the function
+ */
+async function confirmDeleteLink(ev) {
+ const originalContents = ev.target.innerHTML;
+ const deleteButton = ev.target;
+ const cancelButton = deleteButton.nextElementSibling;
+ deleteButton.disabled = true;
+ cancelButton.disabled = true;
+ deleteButton.innerHTML = `${loadingSpinner.innerHTML}Deleting link...`;
+
+ await fetch(
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
{
method: "DELETE",
}
- );
+ )
+ .then(async (res) => {
+ if (!res.ok) {
+ let json = await res.json();
+ throw new Error(json.message);
+ }
- if (res.status === 200) {
- let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`);
- linkEl.remove();
+ let linkEl = document.getElementById(
+ `${currentlyEditing.linkID}_link`
+ );
+ linkEl.remove();
- closeModal();
- currentlyEditing = {};
- }
+ closeModal();
+ currentlyEditing = {};
+ })
+ .catch((err) => {
+ document.getElementById(`delete-link-message`).innerText =
+ err.message;
+ })
+ .finally(() => {
+ deleteButton.disabled = false;
+ cancelButton.disabled = false;
+ deleteButton.innerHTML = originalContents;
+ });
}
/**
@@ -671,12 +759,10 @@ async function confirmCategoryEdit() {
return;
}
- let res = await fetch(`/api/category/${currentlyEditing.categoryID}`, {
+ await fetch(`/api/category/${currentlyEditing.categoryID}`, {
method: "PATCH",
body: formData,
- });
-
- if (res.status === 200) {
+ }).then(() => {
iconUploadInput.value = "";
currentlyEditing.icon = undefined;
@@ -684,9 +770,7 @@ async function confirmCategoryEdit() {
cancelCategoryEdit(categoryInput.value);
currentlyEditing = {};
- } else {
- console.error("Failed to edit category");
- }
+ });
}
function cancelCategoryEdit(text = currentlyEditing.originalText) {
@@ -739,22 +823,42 @@ function deleteCategory(target) {
}
async function confirmDeleteCategory() {
- let res = await fetch(`/api/category/${currentlyEditing.categoryID}`, {
+ const originalContents = ev.target.innerHTML;
+ const deleteButton = ev.target;
+ const cancelButton = deleteButton.nextElementSibling;
+ deleteButton.disabled = true;
+ cancelButton.disabled = true;
+ deleteButton.innerHTML = `${loadingSpinner.innerHTML}Deleting category...`;
+
+ await fetch(`/api/category/${currentlyEditing.categoryID}`, {
method: "DELETE",
- });
+ })
+ .then(async (res) => {
+ if (!res.ok) {
+ let json = await res.json();
+ throw new Error(json.message);
+ }
- 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();
+ 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 = {};
- }
+ closeModal();
+ currentlyEditing = {};
+ })
+ .catch((err) => {
+ document.getElementById(`delete-category-message`).innerText =
+ err.message;
+ })
+ .finally(() => {
+ deleteButton.disabled = false;
+ cancelButton.disabled = false;
+ deleteButton.innerHTML = originalContents;
+ });
}
function roundToNearestHundredth(num) {
@@ -771,6 +875,7 @@ const stylesToCopy = [
"letter-spacing",
"text-transform",
"text-align",
+ "text-wrap-style",
];
let _textMeasuringSpan,
@@ -788,8 +893,6 @@ let _textMeasuringSpan,
* @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.
@@ -824,7 +927,7 @@ function replaceWithResizableTextarea(targetEls) {
parseFloat(computedStyle.borderTopWidth) +
parseFloat(computedStyle.borderBottomWidth);
- let maxWidth = parentBoundingRect.width - borderWidth;
+ let maxWidth = parentBoundingRect.width;
// take care of category headers specifically because the parent bounding box contains two other elements
if (targetEl.tagName === "H2") {
let imageComputedStyle = window.getComputedStyle(
@@ -957,9 +1060,6 @@ function replaceWithResizableTextarea(targetEls) {
function resize(inputElement, fill = false) {
const currentInputComputedStyle = window.getComputedStyle(inputElement);
- const currentInputBorderWidth =
- parseFloat(currentInputComputedStyle.borderLeftWidth) +
- parseFloat(currentInputComputedStyle.borderRightWidth);
const currentParentElBoundingRectWidth =
inputElement.parentElement.getBoundingClientRect().width;
@@ -977,14 +1077,13 @@ function replaceWithResizableTextarea(targetEls) {
let imageWidth =
inputElement.previousElementSibling.getBoundingClientRect()
.width +
- imageComputedStyle.marginLeft +
- imageComputedStyle.marginRight;
+ parseFloat(imageComputedStyle.marginLeft) +
+ parseFloat(imageComputedStyle.marginRight);
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;
+ maxWidth -= imageWidth + actionButtonWidth;
}
let currentContentWidth;
@@ -1017,9 +1116,7 @@ function replaceWithResizableTextarea(targetEls) {
_textMeasuringSpan.getBoundingClientRect().width;
currentContentWidth = Math.min(
- roundToNearestHundredth(
- measuredTextWidth + currentInputBorderWidth
- ) + caretBuffer,
+ roundToNearestHundredth(measuredTextWidth) + caretBuffer,
maxWidth
);
} else {
diff --git a/src/styles/adminUi.css b/src/styles/adminUi.css
index dad923c..0b63597 100644
--- a/src/styles/adminUi.css
+++ b/src/styles/adminUi.css
@@ -82,6 +82,9 @@
}
& > button {
+ display: flex;
+ justify-content: center;
+ column-gap: calc(var(--spacing) * 2);
background-color: var(--color-accent);
color: #fff;
border-radius: calc(var(--spacing) * 1.5);
@@ -104,6 +107,9 @@
row-gap: calc(var(--spacing) * 2);
& > button {
+ display: flex;
+ justify-content: center;
+ column-gap: calc(var(--spacing) * 2);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
border-radius: calc(var(--spacing) * 1.5);
@@ -120,12 +126,14 @@
background-color: #0000;
}
- &:hover {
- filter: brightness(125%);
- }
+ &:not([disabled]) {
+ &:hover {
+ filter: brightness(125%);
+ }
- &:active {
- filter: brightness(95%);
+ &:active {
+ filter: brightness(95%);
+ }
}
}
}
@@ -192,12 +200,14 @@
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
contain: layout style paint;
- &:hover {
- filter: brightness(125%);
- }
+ &:not([disabled]) {
+ &:hover {
+ filter: brightness(125%);
+ }
- &:active {
- filter: brightness(95%);
+ &:active {
+ filter: brightness(95%);
+ }
}
}
@@ -214,6 +224,11 @@
align-items: center;
}
+ button[disabled] {
+ cursor: not-allowed;
+ filter: brightness(75%);
+ }
+
header {
display: flex;
width: 100%;
diff --git a/src/styles/card.css b/src/styles/card.css
index 96b49aa..d960721 100644
--- a/src/styles/card.css
+++ b/src/styles/card.css
@@ -111,7 +111,6 @@
.category-header h2 {
text-transform: capitalize;
- word-break: break-all;
border-width: 1px;
border-color: #0000;
}
@@ -146,3 +145,13 @@
}
}
}
+
+@layer utilities {
+ .flex-shrink-0 {
+ flex-shrink: 0;
+ }
+
+ .pl-2 {
+ padding-left: calc(var(--spacing) * 2);
+ }
+}
diff --git a/src/templates/partials/category-grid.hbs b/src/templates/partials/category-grid.hbs
index 4a85e28..635cd5c 100644
--- a/src/templates/partials/category-grid.hbs
+++ b/src/templates/partials/category-grid.hbs
@@ -5,9 +5,9 @@
-
{{this.Name}}
+
{{this.Name}}
{{#if IsAdmin}}
-
+
\ No newline at end of file
diff --git a/src/templates/partials/modals/delete-link.hbs b/src/templates/partials/modals/delete-link.hbs
index 16b48a7..d422c9b 100644
--- a/src/templates/partials/modals/delete-link.hbs
+++ b/src/templates/partials/modals/delete-link.hbs
@@ -4,8 +4,9 @@
you sure you
want to continue?
-
+
\ No newline at end of file
diff --git a/src/templates/partials/modals/link-form.hbs b/src/templates/partials/modals/link-form.hbs
index ea775b7..31ad9c2 100644
--- a/src/templates/partials/modals/link-form.hbs
+++ b/src/templates/partials/modals/link-form.hbs
@@ -15,7 +15,8 @@
-
+
Add
link
diff --git a/src/templates/views/admin/index.hbs b/src/templates/views/admin/index.hbs
index 56f5f80..1d7b699 100644
--- a/src/templates/views/admin/index.hbs
+++ b/src/templates/views/admin/index.hbs
@@ -40,65 +40,85 @@
-
-