diff --git a/entry.go b/entry.go index 0856e78..7932d68 100644 --- a/entry.go +++ b/entry.go @@ -28,26 +28,31 @@ type Entry struct { Username string `json:"username"` } -func handleFeed(w http.ResponseWriter, r *http.Request) { - page, _ := strconv.Atoi(chi.URLParam(r, "page")) +// feedPage liefert eine Feed-Seite (20 Beiträge, neueste zuerst). Ist uid != 0, +// werden nur die Beiträge dieses Nutzers geliefert. +func feedPage(page int, uid int64) ([]Entry, error) { if page < 0 { page = 0 } if page > 100 { page = 100 } - const count = 20 - offset := page * count - rows, err := db.Query(` + query := ` SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, u.username - FROM entry e JOIN user u ON u.uid = e.uid - ORDER BY e.created_at DESC - LIMIT ? OFFSET ?`, count, offset) + FROM entry e JOIN user u ON u.uid = e.uid` + args := []any{} + if uid != 0 { + query += ` WHERE e.uid = ?` + args = append(args, uid) + } + query += ` ORDER BY e.created_at DESC LIMIT ? OFFSET ?` + args = append(args, count, page*count) + + rows, err := db.Query(query, args...) if err != nil { - writeError(w, http.StatusInternalServerError, "Datenbankfehler") - return + return nil, err } defer rows.Close() @@ -59,6 +64,33 @@ func handleFeed(w http.ResponseWriter, r *http.Request) { } entries = append(entries, e) } + return entries, rows.Err() +} + +func handleFeed(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(chi.URLParam(r, "page")) + entries, err := feedPage(page, 0) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } + writeJSON(w, http.StatusOK, entries) +} + +// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich). +func handleUserFeed(w http.ResponseWriter, r *http.Request) { + var uid int64 + if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil { + writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") + return + } + + page, _ := strconv.Atoi(chi.URLParam(r, "page")) + entries, err := feedPage(page, uid) + if err != nil { + writeError(w, http.StatusInternalServerError, "Datenbankfehler") + return + } writeJSON(w, http.StatusOK, entries) } diff --git a/main.go b/main.go index 7498861..bd1999f 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,9 @@ func routes() http.Handler { r.Get("/entry/{pid}/votes", handleVotes) // --- User (öffentlich) --- - r.Get("/u/{username}", handleUserPage) + r.Get("/u/{username}", serveUserPage) + r.Get("/u/{username}/info", handleUserPage) + r.Get("/u/{username}/feed/{page}", handleUserFeed) // --- Geschützt (Session erforderlich) --- r.Group(func(r chi.Router) { diff --git a/user.go b/user.go index dd3c36f..51f8063 100644 --- a/user.go +++ b/user.go @@ -36,6 +36,12 @@ func idFromUsername(username string) int64 { return id } +// serveUserPage liefert die statische Profilseite; das Frontend lädt die Daten +// dann über /u/{username}/info und /u/{username}/feed/{page}. +func serveUserPage(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "web/user.html") +} + func handleUserPage(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") diff --git a/web/index.html b/web/index.html index e483566..2680fbe 100644 --- a/web/index.html +++ b/web/index.html @@ -46,25 +46,12 @@

- - -
+ diff --git a/web/js/app.js b/web/js/app.js index ad7813c..ba9477b 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,20 +1,5 @@ "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; @@ -22,43 +7,24 @@ 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; - document.getElementById("account").hidden = !loggedIn; + const bar = document.getElementById("headerbar"); if (loggedIn) { - bar.innerHTML = ``; + const me = await api.get("/user/info"); + const user = encodeURIComponent(me.username); + bar.innerHTML = `mein Profil `; document.getElementById("logout-btn").addEventListener("click", async () => { await api.get("/auth/logout"); await refreshHeader(); }); - loadAccount(); } else { bar.innerHTML = ""; showAuth("login"); } } -async function loadAccount() { - const info = await api.get("/user/info"); - const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE"); - document.getElementById("account-info").textContent = - `Angemeldet als ${info.username} · Mitglied seit ${since}`; -} - -document.getElementById("delete-form").addEventListener("submit", async (e) => { - e.preventDefault(); - if (!confirm("Konto wirklich unwiderruflich löschen?")) return; - const res = await api.post("/user/delete", new FormData(e.target)); - if (res.ok) { - await refreshHeader(); - } else { - document.getElementById("delete-msg").textContent = - res.data.error || "Löschen fehlgeschlagen."; - } -}); - // --- Login / Registrierung --- const loginForm = document.getElementById("login-form"); const registerForm = document.getElementById("register-form"); @@ -108,6 +74,13 @@ registerForm.addEventListener("submit", async (e) => { await refreshHeader(); }); +// --- Feed --- +const feed = createFeed( + document.getElementById("feed"), + document.getElementById("sentinel"), + (page) => `/entry/feed/${page}` +); + // --- Neuer Beitrag --- document.getElementById("entry-form").addEventListener("submit", async (e) => { e.preventDefault(); @@ -116,117 +89,11 @@ document.getElementById("entry-form").addEventListener("submit", async (e) => { if (res.ok) { msg.textContent = ""; e.target.reset(); - resetFeed(); + feed.reset(); } 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 = "card"; - - const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); - let media = ""; - if (e.filepath) { - media = ``; - } - - el.innerHTML = ` -
${escapeHtml(e.username)} · ${when}
-
${linkify(e.content)}
- ${media} -
- - – / – - -
- `; - setupVoting(el, e.pid); - return el; -} - -function setupVoting(el, pid) { - const box = el.querySelector(".row"); - const counts = box.querySelector("span"); - const buttons = box.querySelectorAll(".ghost"); - - function render(state) { - if (state.error) { - counts.textContent = state.error; - return; - } - counts.textContent = `${state.left} / ${state.right}`; - buttons.forEach((b) => - b.classList.toggle("selected", b.dataset.mode === state.selected) - ); - } - - buttons.forEach((b) => - b.addEventListener("click", async () => { - const fd = new FormData(); - fd.append("mode", b.dataset.mode); - const res = await api.post(`/entry/${pid}/vote`, fd); - render(res.data); - }) - ); - - // Anfangszustand laden (read-only). - api.get(`/entry/${pid}/votes`).then(render); -} - -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 = "muted"; - 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) => `${m}`) - .replace(/\n/g, "
"); -} - // --- Start --- refreshHeader(); -loadMore(); diff --git a/web/js/feed.js b/web/js/feed.js new file mode 100644 index 0000000..993a67a --- /dev/null +++ b/web/js/feed.js @@ -0,0 +1,124 @@ +"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(() => ({})) }; + }, +}; + +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) => `${m}`) + .replace(/\n/g, "
"); +} + +function renderEntry(e) { + const el = document.createElement("article"); + el.className = "card"; + + const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); + const user = encodeURIComponent(e.username); + let media = ""; + if (e.filepath) { + media = ``; + } + + el.innerHTML = ` +
${escapeHtml(e.username)} · ${when}
+
${linkify(e.content)}
+ ${media} +
+ + – / – + +
+ `; + setupVoting(el, e.pid); + return el; +} + +function setupVoting(el, pid) { + const box = el.querySelector(".row"); + const counts = box.querySelector("span"); + const buttons = box.querySelectorAll(".ghost"); + + function render(state) { + if (state.error) { + counts.textContent = state.error; + return; + } + counts.textContent = `${state.left} / ${state.right}`; + buttons.forEach((b) => + b.classList.toggle("selected", b.dataset.mode === state.selected) + ); + } + + buttons.forEach((b) => + b.addEventListener("click", async () => { + const fd = new FormData(); + fd.append("mode", b.dataset.mode); + const res = await api.post(`/entry/${pid}/vote`, fd); + render(res.data); + }) + ); + + api.get(`/entry/${pid}/votes`).then(render); +} + +// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über +// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden. +function createFeed(feedEl, sentinelEl, urlFor) { + let page = 0; + let loading = false; + let done = false; + + async function loadMore() { + if (loading || done) return; + loading = true; + + const entries = await api.get(urlFor(page)); + if (!entries.length) { + done = true; + const end = document.createElement("p"); + end.className = "muted"; + end.textContent = "YOU REACHED THE END!"; + feedEl.appendChild(end); + } else { + entries.forEach((e) => feedEl.appendChild(renderEntry(e))); + page++; + } + loading = false; + } + + function reset() { + page = 0; + done = false; + feedEl.innerHTML = ""; + loadMore(); + } + + const observer = new IntersectionObserver((items) => { + if (items.some((i) => i.isIntersecting)) loadMore(); + }); + observer.observe(sentinelEl); + + loadMore(); + return { reset }; +} diff --git a/web/js/user.js b/web/js/user.js new file mode 100644 index 0000000..55cd6d6 --- /dev/null +++ b/web/js/user.js @@ -0,0 +1,43 @@ +"use strict"; + +// Nutzername aus dem Pfad /u/. +const username = decodeURIComponent(location.pathname.split("/")[2] || ""); + +document.getElementById("delete-form").addEventListener("submit", async (e) => { + e.preventDefault(); + if (!confirm("Konto wirklich unwiderruflich löschen?")) return; + const res = await api.post("/user/delete", new FormData(e.target)); + if (res.ok) { + location.href = "/"; + } else { + document.getElementById("delete-msg").textContent = + res.data.error || "Löschen fehlgeschlagen."; + } +}); + +async function init() { + const info = await api.get(`/u/${encodeURIComponent(username)}/info`); + if (info.error) { + document.getElementById("profile-name").textContent = "Nutzer nicht gefunden"; + return; + } + + document.getElementById("profile-name").textContent = info.username; + const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE"); + document.getElementById("profile-info").textContent = `Mitglied seit ${since}`; + + // Eigene Seite? -> Einstellungen einblenden. + const me = await api.get("/user/info"); + const isOwner = me.username === info.username; + document.getElementById("settings").hidden = !isOwner; + document.getElementById("feed-heading").textContent = + isOwner ? "Deine Beiträge" : `Beiträge von ${info.username}`; + + createFeed( + document.getElementById("feed"), + document.getElementById("sentinel"), + (page) => `/u/${encodeURIComponent(username)}/feed/${page}` + ); +} + +init(); diff --git a/web/user.html b/web/user.html new file mode 100644 index 0000000..7002bee --- /dev/null +++ b/web/user.html @@ -0,0 +1,41 @@ + + + + + + kver + + + +
+

kver

+ +
+ +
+

+

+ + + + +

Beiträge

+
+
+
+ + + + +