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

58
frontend/assets/style.css Normal file
View file

@ -0,0 +1,58 @@
@import "tailwindcss";
/* Dark theme variables */
:root {
--primary: #83c5be;
--secondary: #006d77;
--accent: #d4a373;
--bg: #121212;
--text: #edf6f9;
--input-bg: #1e1e1e;
--input-border: #83c5be;
--card-bg: #1e1e1e;
}
body {
@apply bg-[var(--bg)] text-[var(--text)] font-sans;
}
.btn {
@apply px-4 py-2 rounded-lg font-semibold text-white;
background-color: var(--primary);
transition: background-color 0.2s;
}
.btn:hover {
background-color: var(--secondary);
}
.btn:disabled {
@apply opacity-50 cursor-not-allowed;
}
.input {
@apply border rounded-md px-3 py-2 w-full;
background-color: var(--input-bg);
border-color: var(--input-border);
color: var(--text);
}
.input::placeholder {
color: #aaaaaa;
}
.card {
@apply bg-[var(--card-bg)] p-6 rounded-xl shadow-lg;
}
.sidebar {
@apply bg-[#1b1b1b] p-4 flex flex-col;
}
.sidebar hr {
@apply border-gray-700 my-4;
}
.sidebar-list {
@apply flex flex-col gap-2 overflow-y-auto;
}

60
frontend/dashboard.html Normal file
View file

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="flex h-screen">
<div class="sidebar w-72">
<button id="new-entry" class="btn mb-4 w-full">+ New Link</button>
<hr>
<div id="link-list" class="sidebar-list"></div>
</div>
<div class="flex-1 p-8 overflow-y-auto">
<div id="editor" class="card hidden">
<h2 class="text-xl font-bold mb-4">Edit Link</h2>
<form id="link-form" class="flex flex-col gap-4">
<label>Link Target URL
<input type="text" id="link-target" class="input" placeholder="https://example.com">
</label>
<label>Shortened Link
<input type="text" id="link-short" class="input" placeholder="example">
</label>
<fieldset>
<legend class="font-semibold mb-2">OpenGraph Settings</legend>
<label>Title
<input type="text" id="og-title" class="input" placeholder="Page Title">
</label>
<label>Description
<textarea id="og-desc" class="input" placeholder="Description"></textarea>
</label>
<label>Image URL
<input type="text" id="og-image" class="input" placeholder="https://image.url">
</label>
</fieldset>
<div class="mt-6">
<h3 class="font-semibold mb-2">OpenGraph Preview</h3>
<div id="og-preview" class="flex"></div>
</div>
<fieldset>
<legend class="font-semibold mb-2">Statistics</legend>
<p id="stats" class="text-sm text-gray-400">No stats yet</p>
</fieldset>
<div class="flex gap-2 mt-4">
<button type="submit" id="save-btn" class="btn flex-1" disabled>Save</button>
<button type="button" id="delete-btn" class="btn flex-1 bg-red-600 hover:bg-red-500">Delete</button>
</div>
</form>
</div>
</div>
<script type="module" src="/ts/dashboard.js"></script>
</body>
</html>

19
frontend/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="flex items-center justify-center min-h-screen">
<div class="bg-[#1e1e1e] p-8 rounded-xl shadow-lg w-96">
<h1 class="text-2xl font-bold mb-6 text-[var(--primary)]">Login</h1>
<form>
<label for="password" class="block mb-2 font-medium text-[var(--text)]">Password</label>
<input type="password" id="password" class="input mb-4" placeholder="Enter your password">
<button type="submit" class="btn w-full">Log In</button>
</form>
</div>
<script type="module" src="/ts/login.js"></script>
</body>
</html>

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();
});
}
}