first commit
This commit is contained in:
commit
672f0deb6a
18 changed files with 1274 additions and 0 deletions
206
frontend/ts/dashboard.ts
Normal file
206
frontend/ts/dashboard.ts
Normal 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();
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue