V0.3.4: Many admin UI bug fixes and improvements
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 1h19m23s
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 1h19m23s
This commit includes many bug fixes and improvements to the admin UI. More errors are caught and displayed to the user. Submit buttons now display a loading state while the request is being processed. Several inconsistencies and style issues in the admin UI have been fixed. Image upload inputs now have an accurate accept attribute, so tat users cannot upload images that are unsupported. Finally, in development mode, the page is outlined in dashed yello to help me not go insane when I forget that I'm using the deployed site and thats why my changes arent doing anything, and the API returns 400 errors when images are unsupported formats.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
passport
|
passport
|
||||||
|
passport-*
|
||||||
.env
|
.env
|
||||||
passport.db*
|
passport.db*
|
||||||
public
|
public
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -5,21 +5,6 @@ RUN apt update && apt install -y upx unzip
|
|||||||
|
|
||||||
RUN curl -fsSL https://bun.com/install | BUN_INSTALL=/usr bash
|
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
|
RUN go install github.com/juls0730/zqdgr@latest
|
||||||
|
|
||||||
|
|||||||
24
README.md
24
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.
|
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 |
|
| Environment Variable | Description | Required | Default |
|
||||||
| ------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
|
| -------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
|
||||||
| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
|
| `PASSPORT_WEATHER_API_KEY` | The OpenWeather API key | true | |
|
||||||
| `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_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
|
||||||
| `WEATHER_LAT` | The latitude of your location | true | |
|
| `WEATHER_LAT` | The latitude of your location | true | |
|
||||||
| `WEATHER_LON` | The longitude 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 |
|
| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
|
||||||
|
|
||||||
#### Uptime configuration
|
#### 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.
|
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 |
|
| Environment Variable | Description | Required | Default |
|
||||||
| ------------------------ | ------------------------------------------------- | -------- | ------- |
|
| ------------------------- | ------------------------------------------------- | -------- | ------- |
|
||||||
| `UPTIME_API_KEY` | The UptimeRobot API key | true | |
|
| `PASSPORT_UPTIME_API_KEY` | The UptimeRobot API key | true | |
|
||||||
| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
|
| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
|
||||||
|
|
||||||
### Adding links and categories
|
### Adding links and categories
|
||||||
|
|
||||||
|
|||||||
5
bun.lock
5
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"name": "passport-css-compiler",
|
"name": "passport-css-compiler",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fullhuman/postcss-purgecss": "^7.0.2",
|
"@fullhuman/postcss-purgecss": "^7.0.2",
|
||||||
|
"baseline-browser-mapping": "^2.9.2",
|
||||||
"cssnano": "^7.1.1",
|
"cssnano": "^7.1.1",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"postcss-cli": "^11.0.0",
|
"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=="],
|
"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=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
@@ -478,6 +479,8 @@
|
|||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"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-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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fullhuman/postcss-purgecss": "^7.0.2",
|
"@fullhuman/postcss-purgecss": "^7.0.2",
|
||||||
|
"baseline-browser-mapping": "^2.9.2",
|
||||||
"cssnano": "^7.1.1",
|
"cssnano": "^7.1.1",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"postcss-cli": "^11.0.0",
|
"postcss-cli": "^11.0.0",
|
||||||
|
|||||||
37
src/main.go
37
src/main.go
@@ -80,7 +80,15 @@ socket.addEventListener('message', (event) => {
|
|||||||
setTimeout(testPage, 150);
|
setTimeout(testPage, 150);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>`
|
</script>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
outline-color: yellow;
|
||||||
|
outline-width: 5px;
|
||||||
|
outline-style: dashed;
|
||||||
|
outline-offset: -5px;
|
||||||
|
}
|
||||||
|
</style>`
|
||||||
|
|
||||||
var (
|
var (
|
||||||
insertCategoryStmt *sql.Stmt
|
insertCategoryStmt *sql.Stmt
|
||||||
@@ -1011,8 +1019,13 @@ func main() {
|
|||||||
iconPath, err := UploadFile(file, contentType, c)
|
iconPath, err := UploadFile(file, contentType, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
status := fiber.StatusInternalServerError
|
||||||
"message": "Failed to upload file, please try again!",
|
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)
|
iconPath, err := UploadFile(file, contentType, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
status := fiber.StatusInternalServerError
|
||||||
"message": "Failed to upload file, please try again!",
|
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)
|
iconPath, err := UploadFile(file, contentType, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to upload file", "error", err)
|
slog.Error("Failed to upload file", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
status := fiber.StatusInternalServerError
|
||||||
"message": "Failed to upload file, please try again!",
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,38 +11,13 @@ let activeModal = null;
|
|||||||
let teleportStorage = document.getElementById("teleport-storage");
|
let teleportStorage = document.getElementById("teleport-storage");
|
||||||
let confirmActions = document.getElementById("confirm-actions");
|
let confirmActions = document.getElementById("confirm-actions");
|
||||||
let selectIconButton = document.getElementById("select-icon-button");
|
let selectIconButton = document.getElementById("select-icon-button");
|
||||||
|
let loadingSpinner = document.getElementById("template-loading-spinner");
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
modalContainer.classList.remove("hidden");
|
modalContainer.classList.remove("hidden");
|
||||||
modalContainer.classList.add("flex");
|
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<void>}
|
|
||||||
*/
|
|
||||||
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
|
* Adds an event listener for the given from to error check after the first submit
|
||||||
* @param {"category" | "link"} form - The form to initialize
|
* @param {"category" | "link"} form - The form to initialize
|
||||||
@@ -76,9 +51,7 @@ function addErrorListener(form) {
|
|||||||
function cloneEditActions(primaryActions) {
|
function cloneEditActions(primaryActions) {
|
||||||
let editActions = document
|
let editActions = document
|
||||||
.getElementById("template-edit-actions")
|
.getElementById("template-edit-actions")
|
||||||
.cloneNode(true);
|
.content.firstElementChild.cloneNode(true);
|
||||||
editActions.removeAttribute("id");
|
|
||||||
editActions.classList.remove("hidden");
|
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (i = 0; i < primaryActions.length; i++) {
|
for (i = 0; i < primaryActions.length; i++) {
|
||||||
@@ -94,6 +67,46 @@ function cloneEditActions(primaryActions) {
|
|||||||
return editActions;
|
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");
|
addErrorListener("link");
|
||||||
document
|
document
|
||||||
.getElementById("link-form")
|
.getElementById("link-form")
|
||||||
@@ -101,66 +114,77 @@ document
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let data = new FormData(event.target);
|
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}<span>Adding link...</span>`;
|
||||||
|
|
||||||
|
await fetch(`/api/category/${targetCategoryID}/link`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
let json = await res.json();
|
||||||
|
|
||||||
if (res.status === 201) {
|
if (!res.ok) {
|
||||||
let json = await res.json();
|
throw new Error(json.message);
|
||||||
|
}
|
||||||
|
|
||||||
let category = document.getElementById(
|
let category = document.getElementById(
|
||||||
`${targetCategoryID}_category`
|
`${targetCategoryID}_category`
|
||||||
);
|
);
|
||||||
let linkGrid = category.nextElementSibling;
|
let linkGrid = category.nextElementSibling;
|
||||||
|
|
||||||
let newLinkCard = document
|
let newLinkCard = document
|
||||||
.getElementById("template-link-card")
|
.getElementById("template-link-card")
|
||||||
.cloneNode(true);
|
.content.firstElementChild.cloneNode(true);
|
||||||
|
|
||||||
newLinkCard.classList.remove("hidden");
|
let newLinkImgElement = newLinkCard.querySelector(
|
||||||
newLinkCard.classList.add("link-card", "admin", "relative");
|
"div:first-child img"
|
||||||
|
);
|
||||||
|
|
||||||
let newLinkImgElement = newLinkCard.querySelector(
|
newLinkImgElement.src = await processFile(data.get("icon"));
|
||||||
"div:first-child img"
|
newLinkImgElement.alt = data.get("name");
|
||||||
);
|
|
||||||
|
|
||||||
newLinkImgElement.src = await processFile(data.get("icon"));
|
newLinkCard.querySelector("h3").textContent = data.get("name");
|
||||||
newLinkImgElement.alt = data.get("name");
|
newLinkCard.querySelector("p").textContent =
|
||||||
|
data.get("description");
|
||||||
|
|
||||||
newLinkCard.querySelector("h3").textContent = data.get("name");
|
newLinkCard.setAttribute("id", `${json.link.id}_link`);
|
||||||
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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
let editActions = cloneEditActions([
|
editActions.classList.add("absolute", "right-1", "top-1");
|
||||||
{
|
|
||||||
clickAction: "editLink(this)",
|
|
||||||
label: "Edit link",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
clickAction: "deleteLink(this)",
|
|
||||||
label: "Delete link",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
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
|
// after the close animation plays
|
||||||
linkGrid.insertBefore(newLinkCard, linkGrid.lastElementChild);
|
setTimeout(() => {
|
||||||
closeModal("link");
|
document.getElementById(`link-form`).reset();
|
||||||
|
document.getElementById(`link-message`).innerText = "";
|
||||||
// after the close animation plays
|
}, 300);
|
||||||
setTimeout(() => {
|
})
|
||||||
document.getElementById(`link-form`).reset();
|
.catch((err) => {
|
||||||
}, 300);
|
document.getElementById(`link-message`).innerText = err.message;
|
||||||
} else {
|
})
|
||||||
let json = await res.json();
|
.finally(() => {
|
||||||
document.getElementById(`link-message`).innerText = json.message;
|
submitButton.disabled = false;
|
||||||
}
|
submitButton.innerHTML = originalContents;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
addErrorListener("category");
|
addErrorListener("category");
|
||||||
@@ -170,74 +194,94 @@ document
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let data = new FormData(event.target);
|
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}<span>Adding category...</span>`;
|
||||||
|
|
||||||
|
await fetch(`/api/category`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: data,
|
body: data,
|
||||||
});
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
let json = await res.json();
|
||||||
|
|
||||||
if (res.status === 201) {
|
if (!res.ok) {
|
||||||
let json = await res.json();
|
throw new Error(json.message);
|
||||||
|
}
|
||||||
|
|
||||||
let newCategory = document
|
let newCategory = document
|
||||||
.getElementById("template-category")
|
.getElementById("template-category")
|
||||||
.cloneNode(true);
|
.content.firstElementChild.cloneNode(true);
|
||||||
|
|
||||||
let linkGrid = newCategory.querySelector("div:nth-child(2)");
|
let linkGrid = newCategory.querySelector("div:nth-child(2)");
|
||||||
let categoryHeader = newCategory.querySelector(".category-header");
|
let categoryHeader =
|
||||||
categoryHeader.setAttribute("id", `${json.category.id}_category`);
|
newCategory.querySelector(".category-header");
|
||||||
categoryHeader.querySelector("h2").textContent = json.category.name;
|
categoryHeader.setAttribute(
|
||||||
|
"id",
|
||||||
|
`${json.category.id}_category`
|
||||||
|
);
|
||||||
|
categoryHeader.querySelector("h2").textContent =
|
||||||
|
json.category.name;
|
||||||
|
|
||||||
let editActions = cloneEditActions([
|
let editActions = cloneEditActions([
|
||||||
{
|
{
|
||||||
clickAction: "editCategory(this)",
|
clickAction: "editCategory(this)",
|
||||||
label: "Edit category",
|
label: "Edit category",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
clickAction: "deleteCategory(this)",
|
clickAction: "deleteCategory(this)",
|
||||||
label: "Delete category",
|
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(
|
categoryImg.querySelector("img").src = await processFile(
|
||||||
data.get("icon")
|
data.get("icon")
|
||||||
);
|
|
||||||
|
|
||||||
linkGrid
|
|
||||||
.querySelector("div")
|
|
||||||
.setAttribute(
|
|
||||||
"onclick",
|
|
||||||
`openModal('link', ${json.category.id})`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let addCategoryButton = document.getElementById(
|
linkGrid
|
||||||
"add-category-button"
|
.querySelector("div")
|
||||||
);
|
.setAttribute(
|
||||||
addCategoryButton.parentElement.insertBefore(
|
"onclick",
|
||||||
categoryHeader,
|
`openModal('link', ${json.category.id})`
|
||||||
addCategoryButton
|
);
|
||||||
);
|
|
||||||
addCategoryButton.parentElement.insertBefore(
|
|
||||||
linkGrid,
|
|
||||||
addCategoryButton
|
|
||||||
);
|
|
||||||
|
|
||||||
closeModal("category");
|
let addCategoryButton = document.getElementById(
|
||||||
|
"add-category-button"
|
||||||
|
);
|
||||||
|
addCategoryButton.parentElement.insertBefore(
|
||||||
|
categoryHeader,
|
||||||
|
addCategoryButton
|
||||||
|
);
|
||||||
|
addCategoryButton.parentElement.insertBefore(
|
||||||
|
linkGrid,
|
||||||
|
addCategoryButton
|
||||||
|
);
|
||||||
|
|
||||||
// after the close animation plays
|
closeModal("category");
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById(`category-form`).reset();
|
// after the close animation plays
|
||||||
}, 300);
|
setTimeout(() => {
|
||||||
} else {
|
document.getElementById(`category-form`).reset();
|
||||||
let json = await res.json();
|
document.getElementById(`category-message`).innerText = "";
|
||||||
document.getElementById(`category-message`).innerText =
|
}, 300);
|
||||||
json.message;
|
})
|
||||||
}
|
.catch((err) => {
|
||||||
|
document.getElementById(`category-message`).innerText =
|
||||||
|
err.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.innerHTML = originalContents;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// when the background is clicked, close the modal
|
// when the background is clicked, close the modal
|
||||||
@@ -330,6 +374,9 @@ function closeModal() {
|
|||||||
document
|
document
|
||||||
.getElementById(activeModal + "-contents")
|
.getElementById(activeModal + "-contents")
|
||||||
.classList.add("hidden");
|
.classList.add("hidden");
|
||||||
|
if (document.getElementById(`${activeModal}-message`) !== null) {
|
||||||
|
document.getElementById(`${activeModal}-message`).innerText = "";
|
||||||
|
}
|
||||||
activeModal = null;
|
activeModal = null;
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
@@ -374,22 +421,37 @@ function unteleportElement(element) {
|
|||||||
teleportElement(element, teleportStorage);
|
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) {
|
if (currentlyEditing.cleanup !== undefined) {
|
||||||
// this function could be called via deleting something, which doesn't have a cleanup function
|
// this function could be called via deleting something, which doesn't have a cleanup function
|
||||||
currentlyEditing.cleanup();
|
currentlyEditing.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (currentlyEditing.type) {
|
let confirmButton = ev.target.closest("button");
|
||||||
case "link":
|
|
||||||
confirmLinkEdit();
|
try {
|
||||||
break;
|
setConfirmLoading(confirmButton);
|
||||||
case "category":
|
|
||||||
confirmCategoryEdit();
|
switch (currentlyEditing.type) {
|
||||||
break;
|
case "link":
|
||||||
default:
|
await confirmLinkEdit();
|
||||||
console.error("Unknown currentlyEditing type");
|
break;
|
||||||
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");
|
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;
|
targetedImageElement = linkImg;
|
||||||
|
|
||||||
teleportElement(selectIconButton, linkImg.parentElement);
|
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() {
|
async function confirmLinkEdit() {
|
||||||
let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`);
|
let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`);
|
||||||
let linkNameInput = linkEl.querySelector("textarea");
|
let linkNameInput = linkEl.querySelector("textarea");
|
||||||
@@ -504,23 +570,19 @@ async function confirmLinkEdit() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await fetch(
|
await fetch(
|
||||||
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
|
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: formData,
|
body: formData,
|
||||||
}
|
}
|
||||||
);
|
).then(() => {
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
iconUploadInput.value = "";
|
iconUploadInput.value = "";
|
||||||
|
|
||||||
currentlyEditing.icon = undefined;
|
currentlyEditing.icon = undefined;
|
||||||
cancelLinkEdit(linkNameInput.value, linkDescInput.value);
|
cancelLinkEdit(linkNameInput.value, linkDescInput.value);
|
||||||
currentlyEditing = {};
|
currentlyEditing = {};
|
||||||
} else {
|
});
|
||||||
console.error("Failed to edit category");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelLinkEdit(
|
function cancelLinkEdit(
|
||||||
@@ -556,7 +618,7 @@ function cancelLinkEdit(
|
|||||||
*/
|
*/
|
||||||
function deleteLink(target) {
|
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
|
// 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 linkID = parseInt(linkEl.id);
|
||||||
let categoryID = parseInt(linkEl.parentElement.previousElementSibling.id);
|
let categoryID = parseInt(linkEl.parentElement.previousElementSibling.id);
|
||||||
|
|
||||||
@@ -577,21 +639,47 @@ function deleteLink(target) {
|
|||||||
openModal("link-delete");
|
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}<span>Deleting link...</span>`;
|
||||||
|
|
||||||
|
await fetch(
|
||||||
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
|
`/api/category/${currentlyEditing.categoryID}/link/${currentlyEditing.linkID}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
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(
|
||||||
let linkEl = document.getElementById(`${currentlyEditing.linkID}_link`);
|
`${currentlyEditing.linkID}_link`
|
||||||
linkEl.remove();
|
);
|
||||||
|
linkEl.remove();
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
currentlyEditing = {};
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = await fetch(`/api/category/${currentlyEditing.categoryID}`, {
|
await fetch(`/api/category/${currentlyEditing.categoryID}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
}).then(() => {
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
iconUploadInput.value = "";
|
iconUploadInput.value = "";
|
||||||
|
|
||||||
currentlyEditing.icon = undefined;
|
currentlyEditing.icon = undefined;
|
||||||
@@ -684,9 +770,7 @@ async function confirmCategoryEdit() {
|
|||||||
cancelCategoryEdit(categoryInput.value);
|
cancelCategoryEdit(categoryInput.value);
|
||||||
|
|
||||||
currentlyEditing = {};
|
currentlyEditing = {};
|
||||||
} else {
|
});
|
||||||
console.error("Failed to edit category");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCategoryEdit(text = currentlyEditing.originalText) {
|
function cancelCategoryEdit(text = currentlyEditing.originalText) {
|
||||||
@@ -739,22 +823,42 @@ function deleteCategory(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteCategory() {
|
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}<span>Deleting category...</span>`;
|
||||||
|
|
||||||
|
await fetch(`/api/category/${currentlyEditing.categoryID}`, {
|
||||||
method: "DELETE",
|
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(
|
||||||
let categoryEl = document.getElementById(
|
`${currentlyEditing.categoryID}_category`
|
||||||
`${currentlyEditing.categoryID}_category`
|
);
|
||||||
);
|
// get the next element and remove it (its the link grid)
|
||||||
// get the next element and remove it (its the link grid)
|
let nextEl = categoryEl.nextElementSibling;
|
||||||
let nextEl = categoryEl.nextElementSibling;
|
nextEl.remove();
|
||||||
nextEl.remove();
|
categoryEl.remove();
|
||||||
categoryEl.remove();
|
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
currentlyEditing = {};
|
currentlyEditing = {};
|
||||||
}
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
document.getElementById(`delete-category-message`).innerText =
|
||||||
|
err.message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
deleteButton.disabled = false;
|
||||||
|
cancelButton.disabled = false;
|
||||||
|
deleteButton.innerHTML = originalContents;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundToNearestHundredth(num) {
|
function roundToNearestHundredth(num) {
|
||||||
@@ -771,6 +875,7 @@ const stylesToCopy = [
|
|||||||
"letter-spacing",
|
"letter-spacing",
|
||||||
"text-transform",
|
"text-transform",
|
||||||
"text-align",
|
"text-align",
|
||||||
|
"text-wrap-style",
|
||||||
];
|
];
|
||||||
|
|
||||||
let _textMeasuringSpan,
|
let _textMeasuringSpan,
|
||||||
@@ -788,8 +893,6 @@ let _textMeasuringSpan,
|
|||||||
* @returns (() => void) A cleanup function to remove event listeners
|
* @returns (() => void) A cleanup function to remove event listeners
|
||||||
*/
|
*/
|
||||||
function replaceWithResizableTextarea(targetEls) {
|
function replaceWithResizableTextarea(targetEls) {
|
||||||
let startTime = performance.now();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} TargetInfo
|
* @typedef {Object} TargetInfo
|
||||||
* @property {HTMLElement} targetEl The element to replace.
|
* @property {HTMLElement} targetEl The element to replace.
|
||||||
@@ -824,7 +927,7 @@ function replaceWithResizableTextarea(targetEls) {
|
|||||||
parseFloat(computedStyle.borderTopWidth) +
|
parseFloat(computedStyle.borderTopWidth) +
|
||||||
parseFloat(computedStyle.borderBottomWidth);
|
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
|
// take care of category headers specifically because the parent bounding box contains two other elements
|
||||||
if (targetEl.tagName === "H2") {
|
if (targetEl.tagName === "H2") {
|
||||||
let imageComputedStyle = window.getComputedStyle(
|
let imageComputedStyle = window.getComputedStyle(
|
||||||
@@ -957,9 +1060,6 @@ function replaceWithResizableTextarea(targetEls) {
|
|||||||
|
|
||||||
function resize(inputElement, fill = false) {
|
function resize(inputElement, fill = false) {
|
||||||
const currentInputComputedStyle = window.getComputedStyle(inputElement);
|
const currentInputComputedStyle = window.getComputedStyle(inputElement);
|
||||||
const currentInputBorderWidth =
|
|
||||||
parseFloat(currentInputComputedStyle.borderLeftWidth) +
|
|
||||||
parseFloat(currentInputComputedStyle.borderRightWidth);
|
|
||||||
|
|
||||||
const currentParentElBoundingRectWidth =
|
const currentParentElBoundingRectWidth =
|
||||||
inputElement.parentElement.getBoundingClientRect().width;
|
inputElement.parentElement.getBoundingClientRect().width;
|
||||||
@@ -977,14 +1077,13 @@ function replaceWithResizableTextarea(targetEls) {
|
|||||||
let imageWidth =
|
let imageWidth =
|
||||||
inputElement.previousElementSibling.getBoundingClientRect()
|
inputElement.previousElementSibling.getBoundingClientRect()
|
||||||
.width +
|
.width +
|
||||||
imageComputedStyle.marginLeft +
|
parseFloat(imageComputedStyle.marginLeft) +
|
||||||
imageComputedStyle.marginRight;
|
parseFloat(imageComputedStyle.marginRight);
|
||||||
let actionButtonWidth =
|
let actionButtonWidth =
|
||||||
inputElement.nextElementSibling.getBoundingClientRect().width;
|
inputElement.nextElementSibling.getBoundingClientRect().width;
|
||||||
|
|
||||||
// the brain cells rub together and this vaguely makes sense to me I think but I cant explain it
|
// the brain cells rub together and this vaguely makes sense to me I think but I cant explain it
|
||||||
maxWidth -= imageWidth + actionButtonWidth + caretBuffer;
|
maxWidth -= imageWidth + actionButtonWidth;
|
||||||
maxWidth += currentInputBorderWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentContentWidth;
|
let currentContentWidth;
|
||||||
@@ -1017,9 +1116,7 @@ function replaceWithResizableTextarea(targetEls) {
|
|||||||
_textMeasuringSpan.getBoundingClientRect().width;
|
_textMeasuringSpan.getBoundingClientRect().width;
|
||||||
|
|
||||||
currentContentWidth = Math.min(
|
currentContentWidth = Math.min(
|
||||||
roundToNearestHundredth(
|
roundToNearestHundredth(measuredTextWidth) + caretBuffer,
|
||||||
measuredTextWidth + currentInputBorderWidth
|
|
||||||
) + caretBuffer,
|
|
||||||
maxWidth
|
maxWidth
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
& > button {
|
& > button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
column-gap: calc(var(--spacing) * 2);
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: calc(var(--spacing) * 1.5);
|
border-radius: calc(var(--spacing) * 1.5);
|
||||||
@@ -104,6 +107,9 @@
|
|||||||
row-gap: calc(var(--spacing) * 2);
|
row-gap: calc(var(--spacing) * 2);
|
||||||
|
|
||||||
& > button {
|
& > button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
column-gap: calc(var(--spacing) * 2);
|
||||||
padding-inline: calc(var(--spacing) * 4);
|
padding-inline: calc(var(--spacing) * 4);
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
border-radius: calc(var(--spacing) * 1.5);
|
border-radius: calc(var(--spacing) * 1.5);
|
||||||
@@ -120,12 +126,14 @@
|
|||||||
background-color: #0000;
|
background-color: #0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:not([disabled]) {
|
||||||
filter: brightness(125%);
|
&:hover {
|
||||||
}
|
filter: brightness(125%);
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
filter: brightness(95%);
|
filter: brightness(95%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,12 +200,14 @@
|
|||||||
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
|
transition: filter 0.15s cubic-bezier(0.45, 0, 0.55, 1);
|
||||||
contain: layout style paint;
|
contain: layout style paint;
|
||||||
|
|
||||||
&:hover {
|
&:not([disabled]) {
|
||||||
filter: brightness(125%);
|
&:hover {
|
||||||
}
|
filter: brightness(125%);
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
filter: brightness(95%);
|
filter: brightness(95%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +224,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: brightness(75%);
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -111,7 +111,6 @@
|
|||||||
|
|
||||||
.category-header h2 {
|
.category-header h2 {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
word-break: break-all;
|
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: #0000;
|
border-color: #0000;
|
||||||
}
|
}
|
||||||
@@ -146,3 +145,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-2 {
|
||||||
|
padding-left: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
|
<img width="32" height="32" draggable="false" alt="{{this.Name}}" src="{{this.Icon}}" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
<h2>{{this.Name}}</h2>
|
||||||
{{#if IsAdmin}}
|
{{#if IsAdmin}}
|
||||||
<div>
|
<div class="flex-shrink-0 pl-2">
|
||||||
<div class="action-container">
|
<div class="action-container">
|
||||||
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
|
<button aria-label="Edit category" onclick="editCategory(this)" class="action-button">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
undone.
|
undone.
|
||||||
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
|
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
|
||||||
<div>
|
<div>
|
||||||
<button onclick="confirmDeleteCategory()">Delete
|
<button onclick="confirmDeleteCategory(event)">Delete
|
||||||
category</button>
|
category</button>
|
||||||
<button onclick="closeModal()">Cancel</button>
|
<button onclick="closeModal()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="delete-category-message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
you sure you
|
you sure you
|
||||||
want to continue?</p>
|
want to continue?</p>
|
||||||
<div>
|
<div>
|
||||||
<button onclick="confirmDeleteLink()">Delete
|
<button onclick="confirmDeleteLink(event)">Delete
|
||||||
link</button>
|
link</button>
|
||||||
<button onclick="closeModal()">Cancel</button>
|
<button onclick="closeModal()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="delete-link-message"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="linkIcon">Icon</label>
|
<label for="linkIcon">Icon</label>
|
||||||
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
|
<input required type="file" name="icon" id="linkIcon"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/svg+xml" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Add
|
<button type="submit">Add
|
||||||
link</button>
|
link</button>
|
||||||
|
|||||||
@@ -40,65 +40,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
|
<template id="template-loading-spinner">
|
||||||
<div id="template-link-card" class="hidden">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
<div>
|
<!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE
|
||||||
<img width="64" height="64" draggable="false" />
|
-->
|
||||||
</div>
|
<path fill="#EAEAEA" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||||
<div>
|
opacity=".25" />
|
||||||
<h3></h3>
|
<path fill="#EAEAEA"
|
||||||
<!-- add 2 to the height to account for the border -->
|
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
|
||||||
<p></p>
|
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0
|
||||||
</div>
|
12 12;360 12 12" />
|
||||||
</div>
|
</path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div id="template-category" class="hidden">
|
<!-- store a blank link card so that if we add a new link we can clone it to make the editing experience easier -->
|
||||||
<div class="category-header">
|
<template id="template-link-card">
|
||||||
|
<div data-card>
|
||||||
<div>
|
<div>
|
||||||
<img width="32" height="32" draggable="false" />
|
<img width="64" height="64" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3></h3>
|
||||||
|
<!-- add 2 to the height to account for the border -->
|
||||||
|
<p></p>
|
||||||
</div>
|
</div>
|
||||||
<h2></h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="link-grid">
|
</template>
|
||||||
<div class="new-link-card link-card admin">
|
|
||||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
<template id="template-category">
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
<div>
|
||||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
<div class="category-header">
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Add a link</h3>
|
<img width="32" height="32" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<h2></h2>
|
||||||
|
</div>
|
||||||
|
<div class="link-grid">
|
||||||
|
<div class="new-link-card link-card admin">
|
||||||
|
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3>Add a link</h3>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div id="template-edit-actions" class="hidden">
|
<template id="template-edit-actions">
|
||||||
<div class="flex flex-row gap-2">
|
<div>
|
||||||
<button class="action-button">
|
<div class="action-container">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
<button class="action-button">
|
||||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
stroke-width="2">
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
stroke-width="2">
|
||||||
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
<path d="M7 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-1" />
|
||||||
</g>
|
<path d="M20.385 6.585a2.1 2.1 0 0 0-2.97-2.97L9 12v3h3zM16 5l3 3" />
|
||||||
</svg>
|
</g>
|
||||||
</button>
|
</svg>
|
||||||
<button class="text-error action-button">
|
</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
<button class="text-error action-button">
|
||||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
stroke-width="2"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
stroke-width="2"
|
||||||
</svg>
|
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
|
<div id="teleport-storage" class="absolute -top-full -left-full hidden">
|
||||||
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
|
<!-- These are the elements that appear when the user enters edit mode, they allow for the cancelation/confirmation of the edit -->
|
||||||
<div class="action-container" id="confirm-actions">
|
<div class="action-container" id="confirm-actions">
|
||||||
<button class="action-button text-success" onclick="confirmEdit()">
|
<button class="action-button text-success" onclick="confirmEdit(event)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "passport",
|
"name": "passport",
|
||||||
"version": "0.3.3",
|
"version": "0.3.4",
|
||||||
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
||||||
"author": "juls0730",
|
"author": "juls0730",
|
||||||
"license": "BSL-1.0",
|
"license": "BSL-1.0",
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "go generate ./src/",
|
"generate": "go generate ./src/",
|
||||||
|
|
||||||
"dev": "zqdgr generate; PASSPORT_DEV_MODE=true go run src/main.go",
|
"dev": "zqdgr generate; PASSPORT_DEV_MODE=true go run src/main.go",
|
||||||
"build": "zqdgr generate && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go && upx passport",
|
"build": "zqdgr generate && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go && upx passport",
|
||||||
"build:all": "zqdgr build && zqdgr build:arm64 && zqdgr build:amd64",
|
"build:all": "zqdgr build && zqdgr build:arm64 && zqdgr build:amd64",
|
||||||
@@ -19,6 +18,8 @@
|
|||||||
"build:arm64": "zqdgr generate && GOOS=linux GOARCH=arm64 go build -tags netgo,prod -ldflags=\"-w -s\" -o passport-linux-arm64 src/main.go && upx passport-linux-arm64"
|
"build:arm64": "zqdgr generate && GOOS=linux GOARCH=arm64 go build -tags netgo,prod -ldflags=\"-w -s\" -o passport-linux-arm64 src/main.go && upx passport-linux-arm64"
|
||||||
},
|
},
|
||||||
"pattern": "src/**/*.{go,hbs,css,js,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
"pattern": "src/**/*.{go,hbs,css,js,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
||||||
"excluded_files": ["src/assets/styles"],
|
"excluded_files": [
|
||||||
|
"src/assets/styles"
|
||||||
|
],
|
||||||
"shutdown_signal": "SIGINT"
|
"shutdown_signal": "SIGINT"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user