first commit

This commit is contained in:
Soph :3 2025-12-12 21:12:47 +02:00
commit 672f0deb6a
18 changed files with 1274 additions and 0 deletions

206
frontend/ts/dashboard.ts Normal file
View file

@ -0,0 +1,206 @@
import type { LinkEntry } from "../../common/lib.ts";
import {
getLinks,
createLink,
updateLink,
deleteLink,
login
} from "./api.ts";
import { Toast } from "./toast.ts";
const toast = new Toast();
const linkList = document.getElementById("link-list")!;
const editor = document.getElementById("editor")!;
const form = document.getElementById("link-form") as HTMLFormElement;
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
const statsEl = document.getElementById("stats")!;
const ogPreview = document.getElementById("og-preview")!;
let currentEditing: LinkEntry | null = null;
let tempIdCounter = 0;
async function renderList() {
linkList.innerHTML = "";
const res = await getLinks();
if (!res.success) {
toast.show(res.error || "Failed to fetch links.");
return;
}
res.data?.data?.forEach((link) => {
const el = document.createElement("button");
el.className = "btn text-left flex justify-between items-center";
el.textContent = `${link.short || "(empty)"}${link.target || "(empty)"}`;
el.addEventListener("click", () => editLink(link));
linkList.appendChild(el);
});
}
function editLink(link: LinkEntry) {
currentEditing = link;
editor.classList.remove("hidden");
(document.getElementById("link-target") as HTMLInputElement).value = link.target;
(document.getElementById("link-short") as HTMLInputElement).value = link.short;
(document.getElementById("og-title") as HTMLInputElement).value = link.ogTitle || "";
(document.getElementById("og-desc") as HTMLTextAreaElement).value = link.ogDesc || "";
(document.getElementById("og-image") as HTMLInputElement).value = link.ogImage || "";
updateStats();
updateSaveState();
updateOGPreview();
}
function updateSaveState() {
const end = (document.getElementById("link-target") as HTMLInputElement).value.trim();
const short = (document.getElementById("link-short") as HTMLInputElement).value.trim();
saveBtn.disabled = !(end && short);
}
function updateStats() {
if (!currentEditing) return;
statsEl.textContent = `Clicks: ${currentEditing?.clicks ?? 0}`;
}
document.getElementById("new-entry")!.addEventListener("click", () => {
const tempLink: LinkEntry = {
id: `temp-${tempIdCounter++}`,
target: "",
short: "",
clicks: 0
};
editLink(tempLink);
});
form.addEventListener("input", () => {
updateSaveState();
updateOGPreview();
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!currentEditing) return;
const updatedLink: Partial<LinkEntry> = {
target: (document.getElementById("link-target") as HTMLInputElement).value,
short: (document.getElementById("link-short") as HTMLInputElement).value,
ogTitle: (document.getElementById("og-title") as HTMLInputElement).value,
ogDesc: (document.getElementById("og-desc") as HTMLTextAreaElement).value,
ogImage: (document.getElementById("og-image") as HTMLInputElement).value,
};
if (currentEditing.id.startsWith("temp-")) {
const res = await createLink(updatedLink);
if (!res.success) {
toast.show(res.error || "Failed to create link.");
return;
}
currentEditing = res.data?.data!;
} else {
const res = await updateLink(currentEditing.id, updatedLink);
if (!res.success) {
toast.show(res.error || "Failed to update link.");
return;
}
currentEditing = res.data?.data!;
}
await renderList();
updateSaveState();
updateStats();
});
document.getElementById("delete-btn")!.addEventListener("click", async () => {
if (!currentEditing) return;
const res = await deleteLink(currentEditing.id);
if (!res.success) {
toast.show(res.error || "Failed to delete link.");
return;
}
currentEditing = null;
editor.classList.add("hidden");
await renderList();
});
function updateOGPreview() {
if (!currentEditing) return;
const title = (document.getElementById("og-title") as HTMLInputElement).value.trim();
const desc = (document.getElementById("og-desc") as HTMLTextAreaElement).value.trim();
const img = (document.getElementById("og-image") as HTMLInputElement).value.trim();
const hasTitle = title.length > 0;
const hasDesc = desc.length > 0;
const hasText = hasTitle || hasDesc;
const hasImg = img.length > 0;
if (!hasTitle && !hasDesc && !hasImg) {
ogPreview.innerHTML = "";
return;
}
if (!hasText && hasImg) {
ogPreview.innerHTML = `
<div class="w-80 h-40 border border-gray-600 rounded-lg overflow-hidden">
<img src="${img}" class="w-full h-full object-cover">
</div>
`;
return;
}
if (!hasImg && hasText) {
ogPreview.innerHTML = `
<div class="w-80 border border-gray-600 rounded-lg p-3 flex flex-col justify-center">
${
hasTitle ? `<h3 class="font-bold text-[var(--text)]">${title}</h3>` : ""
}
${
hasDesc ? `<p class="text-gray-400 text-sm mt-1">${desc}</p>` : ""
}
</div>
`;
return;
}
ogPreview.innerHTML = `
<div class="flex border border-gray-600 rounded-lg overflow-hidden w-80">
<img src="${img}" alt="OG Image" class="w-24 h-24 object-cover">
<div class="p-2 flex flex-col ${
hasTitle && hasDesc
? "justify-center"
: hasTitle && !hasDesc
? "justify-center"
: !hasTitle && hasDesc
? "justify-start"
: "justify-center"
}">
${
hasTitle
? `<h3 class="font-bold text-[var(--text)]">${title}</h3>`
: ""
}
${
hasDesc
? `<p class="text-gray-400 text-sm mt-1">${desc}</p>`
: ""
}
</div>
</div>
`;
}
(async () => {
const res = await login(localStorage.password);
if (!res.success) {
toast.show(res.error || "Login failed.");
localStorage.removeItem("password");
location.href = "/";
return;
}
await renderList();
})();