first commit
This commit is contained in:
commit
672f0deb6a
18 changed files with 1274 additions and 0 deletions
88
frontend/ts/api.ts
Normal file
88
frontend/ts/api.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { LinkEntry } from "../../common/lib";
|
||||
|
||||
const API_BASE = location.hostname == "localhost" || location.hostname == "127.0.0.1" ? "http://localhost:3001" : "";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getAuthHeader() {
|
||||
const password = localStorage.getItem("password");
|
||||
if (!password) throw new Error("No password found in localStorage");
|
||||
return { "X-Password": password };
|
||||
}
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
|
||||
const headers = { ...options.headers, ...getAuthHeader() };
|
||||
let res: Response;
|
||||
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/${url}`, { ...options, headers });
|
||||
} catch {
|
||||
return { success: false, error: "Network error" };
|
||||
}
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return { success: false, error: `Server returned status ${res.status}` };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: json?.error || `Request failed with status ${res.status}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: json as T
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<ApiResponse<void>> {
|
||||
localStorage.setItem("password", password);
|
||||
const res = await request<void>(`login`, { method: "POST" });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<ApiResponse<void>> {
|
||||
localStorage.removeItem("password");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getLinks(): Promise<ApiResponse<LinkEntry[]>> {
|
||||
return request<LinkEntry[]>("links");
|
||||
}
|
||||
|
||||
export async function getLink(id: string): Promise<ApiResponse<LinkEntry>> {
|
||||
return request<LinkEntry>(`links/${id}`);
|
||||
}
|
||||
|
||||
export async function createLink(link: Partial<LinkEntry>): Promise<ApiResponse<LinkEntry>> {
|
||||
return request<LinkEntry>("links", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(link),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLink(id: string, link: Partial<LinkEntry>): Promise<ApiResponse<LinkEntry>> {
|
||||
return request<LinkEntry>(`links/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(link),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLink(id: string): Promise<ApiResponse<void>> {
|
||||
return request<void>(`links/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getLinkStats(id: string): Promise<ApiResponse<{ clicks: number }>> {
|
||||
return request<{ clicks: number }>(`links/${id}/stats`);
|
||||
}
|
||||
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();
|
||||
})();
|
||||
35
frontend/ts/login.ts
Normal file
35
frontend/ts/login.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { login } from "./api";
|
||||
|
||||
const form = document.querySelector("form")!;
|
||||
const passwordInput = document.getElementById("password") as HTMLInputElement;
|
||||
|
||||
const errorEl = document.createElement("p");
|
||||
errorEl.className = "text-red-500 mt-2 text-sm hidden";
|
||||
form.appendChild(errorEl);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
const password = passwordInput.value.trim();
|
||||
if (!password) {
|
||||
errorEl.textContent = "Please enter a password.";
|
||||
errorEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await login(password);
|
||||
|
||||
if (res.success) {
|
||||
localStorage.setItem("password", password);
|
||||
window.location.href = "/dashboard.html";
|
||||
} else {
|
||||
errorEl.textContent = res.error || "Invalid password.";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
} catch {
|
||||
errorEl.textContent = "Network error. Please try again.";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
55
frontend/ts/toast.ts
Normal file
55
frontend/ts/toast.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
type ToastOptions = {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export class Toast {
|
||||
private container: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.style.position = 'fixed';
|
||||
this.container.style.top = '10px';
|
||||
this.container.style.right = '10px';
|
||||
this.container.style.display = 'flex';
|
||||
this.container.style.flexDirection = 'column';
|
||||
this.container.style.gap = '10px';
|
||||
this.container.style.zIndex = '9999';
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
show(message: string, options?: ToastOptions) {
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
|
||||
Object.assign(toast.style, {
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
color: 'white',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '5px',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '14px',
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
|
||||
opacity: '0', // start invisible
|
||||
transform: 'translateX(100%)', // slide in from right
|
||||
transition: 'opacity 0.3s, transform 0.3s'
|
||||
});
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.style.opacity = '1';
|
||||
toast.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
const duration = options?.duration ?? 3000;
|
||||
setTimeout(() => this.removeToast(toast), duration);
|
||||
}
|
||||
|
||||
private removeToast(toast: HTMLDivElement) {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue