Rewrite backend in Go with JSON API and JS frontend

Reimplements the Flask app as a Go HTTP API (chi + modernc sqlite) with a
minimal vanilla-JS frontend in web/. Endpoints mirror the original Flask
routes but return JSON instead of HTML.

- auth: login/logout/register/sessioninfo/headerbar with crypto/rand tokens
- entry: paginated feed (single JOIN), create with image scaling
- user: profile, userinfo; delete still a stub
- requireAuth middleware passes uid via context
- notes/api.md documents the API and schema

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 10:17:02 +02:00
parent c2a8199a84
commit cb237c248e
13 changed files with 1160 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
:root {
--bg: #15171a;
--card: #1e2126;
--fg: #e8e8e8;
--muted: #9aa0a6;
--accent: #4a9eff;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #2a2e35;
position: sticky;
top: 0;
background: var(--bg);
}
header h1 { margin: 0; font-size: 1.4rem; }
main {
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
section { margin-bottom: 1.5rem; }
input, textarea, button {
font: inherit;
border-radius: 6px;
border: 1px solid #2a2e35;
background: var(--card);
color: var(--fg);
padding: 0.6rem 0.8rem;
}
input, textarea { width: 100%; margin-bottom: 0.6rem; }
textarea { min-height: 80px; resize: vertical; }
button {
cursor: pointer;
background: var(--accent);
border-color: var(--accent);
color: #fff;
width: auto;
}
button:hover { opacity: 0.9; }
.entry {
background: var(--card);
border: 1px solid #2a2e35;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.entry .meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.5rem; }
.entry .content { white-space: pre-wrap; word-wrap: break-word; }
.entry img { max-width: 100%; border-radius: 6px; margin-top: 0.6rem; }
.entry a { color: var(--accent); }
.msg { color: #ff6b6b; font-size: 0.9rem; min-height: 1.2em; }
.end { text-align: center; color: var(--muted); padding: 2rem 0; }
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>kver</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<h1>kver</h1>
<div id="headerbar"></div>
</header>
<main>
<!-- Login / Registrierung -->
<section id="auth-box">
<form id="login-form">
<h2>Login</h2>
<input name="user" placeholder="Nutzername" autocomplete="username">
<input name="pass" type="password" placeholder="Passwort" autocomplete="current-password">
<input name="timeout" type="hidden" value="86400">
<button type="submit">Einloggen</button>
</form>
<p class="msg" id="auth-msg"></p>
</section>
<!-- Neuer Beitrag -->
<section id="compose" hidden>
<form id="entry-form">
<textarea name="content" maxlength="1000" placeholder="Was gibt's Neues?"></textarea>
<input name="file" type="file" accept="image/*">
<button type="submit">Posten</button>
</form>
<p class="msg" id="entry-msg"></p>
</section>
<!-- Feed -->
<section id="feed"></section>
<div id="sentinel"></div>
</main>
<script src="/js/app.js"></script>
</body>
</html>
+139
View File
@@ -0,0 +1,139 @@
"use strict";
const api = {
async get(url) {
const r = await fetch(url, { credentials: "same-origin" });
return r.json();
},
async post(url, formData) {
const r = await fetch(url, {
method: "POST",
body: formData,
credentials: "same-origin",
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
},
};
// --- Headerbar (eingeloggt?) ---
let loggedIn = false;
async function refreshHeader() {
const h = await api.get("/auth/headerbar");
loggedIn = !!h.loggedin;
const bar = document.getElementById("headerbar");
document.getElementById("auth-box").hidden = loggedIn;
document.getElementById("compose").hidden = !loggedIn;
if (loggedIn) {
bar.innerHTML = `<button id="logout-btn">Logout</button>`;
document.getElementById("logout-btn").addEventListener("click", async () => {
await api.get("/auth/logout");
await refreshHeader();
});
} else {
bar.innerHTML = "";
}
}
// --- Login ---
document.getElementById("login-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/auth/login", new FormData(e.target));
const msg = document.getElementById("auth-msg");
if (res.ok) {
msg.textContent = "";
await refreshHeader();
} else {
msg.textContent = res.data.error || "Login fehlgeschlagen.";
}
});
// --- Neuer Beitrag ---
document.getElementById("entry-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/entry/create", new FormData(e.target));
const msg = document.getElementById("entry-msg");
if (res.ok) {
msg.textContent = "";
e.target.reset();
resetFeed();
} else {
msg.textContent = res.data.error || "Beitrag fehlgeschlagen.";
}
});
// --- Feed mit Infinite Scroll ---
let page = 0;
let loading = false;
let done = false;
function renderEntry(e) {
const el = document.createElement("article");
el.className = "entry";
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
let media = "";
if (e.filepath && e.filepath !== "none") {
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
}
el.innerHTML = `
<div class="meta"><strong>${escapeHtml(e.username)}</strong> · ${when}</div>
<div class="content">${linkify(e.content)}</div>
${media}
`;
return el;
}
async function loadMore() {
if (loading || done) return;
loading = true;
const entries = await api.get(`/entry/feed/${page}`);
const feed = document.getElementById("feed");
if (!entries.length) {
done = true;
const end = document.createElement("p");
end.className = "end";
end.textContent = "YOU REACHED THE END!";
feed.appendChild(end);
} else {
entries.forEach((e) => feed.appendChild(renderEntry(e)));
page++;
}
loading = false;
}
function resetFeed() {
page = 0;
done = false;
document.getElementById("feed").innerHTML = "";
loadMore();
}
const observer = new IntersectionObserver((items) => {
if (items.some((i) => i.isIntersecting)) loadMore();
});
observer.observe(document.getElementById("sentinel"));
// --- Helpers ---
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function linkify(s) {
const escaped = escapeHtml(s);
const url = /https?:\/\/[^\s<]+/g;
return escaped
.replace(url, (m) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
.replace(/\n/g, "<br>");
}
// --- Start ---
refreshHeader();
loadMore();