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:
@@ -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; }
|
||||
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user