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

88
frontend/ts/api.ts Normal file
View 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
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();
})();

35
frontend/ts/login.ts Normal file
View 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
View 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();
});
}
}