Overhaul code org, and improve image uploading

This commit introduces breaking changes. It overhauls how and where
services are configured and placed in the codebase, as well as moving
the entire source into src/ It also changes how these integrations
are configured via environment variables. Old configs will still work
for now, but it is strongly suggested that you migrate your config.
This commit is contained in:
Zoe
2025-09-29 17:50:57 +00:00
parent 8c9ad40776
commit cd6ac6e771
18 changed files with 376 additions and 283 deletions

View File

@@ -0,0 +1,292 @@
<div id="blur-target"
class="transition-[filter] motion-reduce:transition-none ease-[cubic-bezier(0.45,0,0.55,1)] duration-300">
<header class="flex w-full p-3">
<a href="/"
class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="m9 14l-4-4l4-4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</g>
</svg>
Return to home
</a>
</header>
<section class="flex justify-center w-full">
<div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}}
<div class="flex items-center" key="category-{{this.ID}}">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false"
alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize break-all">{{this.Name}}</h2>
<button onclick="deleteCategory({{this.ID}})"
class="w-fit h-fit flex p-0.5 bg-base border border-highlight rounded-md hover:filter hover:brightness-125 cursor-pointer"><svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
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" />
</svg></button>
</div>
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
{{#each this.Links}}
<div key="link-{{this.ID}}" class="link-card relative">
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<div>
<h3>{{this.Name}}</h3>
<p>{{this.Description}}</p>
</div>
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
class="w-fit h-fit flex p-0.5 bg-base border border-highlight/70 rounded-md hover:filter hover:brightness-125 cursor-pointer absolute right-1 top-1"><svg
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
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" />
</svg></button>
</div>
{{/each}}
<div onclick="openModal('link', {{this.ID}})"
class="rounded-2xl border border-dashed border-subtle p-2.5 flex flex-row items-center hover:underline transition-[box-shadow,transform] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 pointer-cursor select-none cursor-pointer">
<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>
{{/each}}
<div class="flex items-center">
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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>
<h2 onclick="openModal('category')" class="text-subtle underline decoration-dashed cursor-pointer">
Add a new category
</h2>
</div>
</div>
</section>
</div>
<div id="modal-container"
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
<div id="category-contents" class="hidden">
<h3>Create A category</h3>
<form id="category-form" action="/api/categories" method="post"
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
<div>
<label for="categoryName">Name</label>
<input required type="text" name="name" id="categoryName" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
</div>
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
category</button>
</form>
<span id="category-message"></span>
</div>
<div id="link-contents" class="hidden">
<h3>Add A link</h3>
<form id="link-form" action="/api/links" method="post"
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
<div>
<label for="linkName">Name</label>
<input required type="text" name="name" id="linkName" />
</div>
<div>
<label for="linkDesc">Description (optional)</label>
<input type="text" name="description" id="linkDesc" />
</div>
<div>
<label for="linkURL">URL</label>
<input required type="url" name="url" id="linkURL" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
</div>
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
link</button>
</form>
<span id="link-message"></span>
</div>
</div>
</div>
<script>
// 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 targetCategoryID = null;
let activeModal = null;
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");
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)
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
el.classList.remove("invalid:border-[#861024]!");
});
targetCategoryID = null;
}
modalContainer.addEventListener("click", (event) => {
if (event.target === modalContainer) {
closeModal();
}
});
async function deleteLink(linkID, categoryID) {
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
method: "DELETE"
});
if (res.status === 200) {
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
linkEl.remove();
}
}
async function deleteCategory(categoryID) {
let res = await fetch(`/api/category/${categoryID}`, {
method: "DELETE"
});
if (res.status === 200) {
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`);
// get the next element and remove it (its the link grid)
let nextEl = categoryEl.nextElementSibling;
nextEl.remove();
categoryEl.remove();
}
}
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
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) {
closeModal('link');
document.getElementById("link-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("category-message").innerText = json.message;
}
});
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => {
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => {
el.classList.add("invalid:border-[#861024]!");
});
});
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) {
closeModal('category');
document.getElementById("category-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("link-message").innerText = json.message;
}
});
</script>
<style>
.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;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<main class="flex justify-center items-center h-screen relative bg-base">
<div class="flex bg-surface rounded-xl overflow-hidden">
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
<div class="flex flex-col p-4 text-center">
<h2 class="text-2xl">
Login
</h2>
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Login</button>
</form>
<span id="message"></span>
</div>
</div>
</main>
<script>
let message = document.getElementById("message");
let form = document.querySelector("form");
form.addEventListener("submit", async (event) => {
event.preventDefault();
let data = {
"username": form.username.value,
"password": form.password.value
};
console.log(data);
let res = await fetch("/admin/login", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json"
}
});
if (res.status === 200) {
window.location.href = "/admin";
return;
}
message.innerText = (await res.json()).message;
});
</script>