From 15ef7db5f70718574822edf26ffd47913250ab4d Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sun, 7 Jun 2026 18:20:14 +0200 Subject: [PATCH] Profilbilder: Avatar-Upload, Anzeige auf Profil & Feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user.avatar-Spalte (Schema, migrate.sh, Migrationsdoku) - POST /user/avatar (geschützt): Upload via storeImage, ersetzt/löscht altes Bild - avatar in /u/{name}/info, /user/info und im entrySelect-Join (Feed-Karten) - Account-Löschung entfernt auch das Avatar-File - Frontend: Avatar im Profilkopf + Byline der Karten (50px), Upload-Form, Platzhalterbild no_profile_pic.jpg als Fallback - .card img per :not(.avatar) von Beitragsbildern getrennt Co-Authored-By: Claude Opus 4.8 --- db.go | 3 +- entry.go | 7 +++-- main.go | 1 + migrate.sh | 17 ++++++---- notes/migrations.md | 18 +++++++++++ static/assets/no_profile_pic.jpg | Bin 0 -> 5427 bytes user.go | 52 ++++++++++++++++++++++++++++--- web/css/app.css | 33 ++++++++++++++++++-- web/js/feed.js | 10 +++++- web/js/user.js | 15 +++++++++ web/user.html | 17 ++++++++-- 11 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 static/assets/no_profile_pic.jpg diff --git a/db.go b/db.go index bf37a44..7b2deb0 100644 --- a/db.go +++ b/db.go @@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS user ( username TEXT UNIQUE, password BLOB, created_at INTEGER, - last_login INTEGER + last_login INTEGER, + avatar TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS session ( diff --git a/entry.go b/entry.go index 1ec08a3..de25cf0 100644 --- a/entry.go +++ b/entry.go @@ -31,6 +31,7 @@ type Entry struct { LastActivity int64 `json:"last_activity"` Deleted int64 `json:"deleted"` Username string `json:"username"` + Avatar string `json:"avatar"` } // LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als @@ -38,7 +39,7 @@ type Entry struct { const entrySelect = ` SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, e.reply_to, e.reply_count, e.last_activity, e.deleted, - COALESCE(u.username, '') + COALESCE(u.username, ''), COALESCE(u.avatar, '') FROM entry e LEFT JOIN user u ON u.uid = e.uid` // scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. @@ -48,7 +49,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) { for rows.Next() { var e Entry if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username); err != nil { + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar); err != nil { return nil, err } entries = append(entries, e) @@ -61,7 +62,7 @@ func entryByPID(pid int64) (Entry, error) { var e Entry err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username) + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar) return e, err } diff --git a/main.go b/main.go index 120bc9e..cfd6d96 100644 --- a/main.go +++ b/main.go @@ -61,6 +61,7 @@ func routes() http.Handler { r.Post("/entry/{pid}/delete", handleDeleteEntry) r.Get("/user/info", handleUserInfo) r.Post("/user/rename", handleUserRename) + r.Post("/user/avatar", handleSetAvatar) r.Post("/user/delete", handleUserDelete) }) diff --git a/migrate.sh b/migrate.sh index d1b0109..53d7728 100755 --- a/migrate.sh +++ b/migrate.sh @@ -57,7 +57,8 @@ CREATE TABLE user ( username TEXT UNIQUE, password BLOB, created_at INTEGER, - last_login INTEGER + last_login INTEGER, + avatar TEXT NOT NULL DEFAULT '' ); CREATE TABLE session ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -91,14 +92,18 @@ SCHEMA # Prüfen, welche optionalen Spalten in der Quelle vorhanden sind. # Ältere Python-DBs haben reply_count und last_activity noch nicht. has_col() { - sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('entry') WHERE name='$1';" + sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('$1') WHERE name='$2';" } reply_count_expr="0" -[[ "$(has_col reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)" +[[ "$(has_col entry reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)" last_activity_expr="e.created_at" -[[ "$(has_col last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)" +[[ "$(has_col entry last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)" + +# Profilbild: alte DBs haben die Spalte nicht -> leerer String. +avatar_expr="''" +[[ "$(has_col user avatar)" == "1" ]] && avatar_expr="COALESCE(u.avatar, '')" replies_filter="" [[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0" @@ -114,8 +119,8 @@ ATTACH '$src_sql' AS src; BEGIN; -- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben) -INSERT INTO user (id, uid, username, password, created_at, last_login) -SELECT id, uid, username, password, created_at, last_login FROM src.user; +INSERT INTO user (id, uid, username, password, created_at, last_login, avatar) +SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u; -- Einträge transformieren: -- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string) diff --git a/notes/migrations.md b/notes/migrations.md index 401ac93..e4f5067 100644 --- a/notes/migrations.md +++ b/notes/migrations.md @@ -5,6 +5,24 @@ Eine frische DB (Schema aus `db.go`) braucht keine davon. --- +## `avatar`-Spalte in `user` + +**Wann:** beim Deploy der Profilbild-Version gegen eine bestehende `kver.db`. + +**Hintergrund:** Profilbilder werden als Pfad (`static/media/...`) in der neuen Spalte +`user.avatar` gespeichert (leerer String = kein Bild, Frontend zeigt dann das +Platzhalterbild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer +vorhandenen Tabelle nicht. + +**Migration:** +```sql +ALTER TABLE user ADD COLUMN avatar TEXT NOT NULL DEFAULT ''; +``` + +Bestehende Nutzer haben damit kein Bild (`''`). Nur einmalig ausführen. + +--- + ## `filepath` NULL → leerer String **Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält. diff --git a/static/assets/no_profile_pic.jpg b/static/assets/no_profile_pic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8ba872d7c9ffe8c680dd398377a5844825bfd35f GIT binary patch literal 5427 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 ez-n*nF(g(T;;_>0Q_k|U|Bl${escapeHtml(e.username)} · ${when} +
${linkify(e.content)}
${media}
diff --git a/web/js/user.js b/web/js/user.js index 543531c..6357c7c 100644 --- a/web/js/user.js +++ b/web/js/user.js @@ -14,6 +14,20 @@ document.getElementById("rename-form").addEventListener("submit", async (e) => { } }); +document.getElementById("avatar-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const res = await api.post("/user/avatar", new FormData(e.target)); + if (res.ok) { + // Cache umgehen, damit das neue Bild sofort erscheint. + document.getElementById("profile-avatar").src = `${avatarUrl(res.data.avatar)}?t=${Date.now()}`; + e.target.reset(); + document.getElementById("avatar-msg").textContent = ""; + } else { + document.getElementById("avatar-msg").textContent = + res.data.error || "Hochladen fehlgeschlagen."; + } +}); + document.getElementById("delete-form").addEventListener("submit", async (e) => { e.preventDefault(); if (!confirm("Konto wirklich unwiderruflich löschen?")) return; @@ -34,6 +48,7 @@ async function init() { } document.getElementById("profile-name").textContent = info.username; + document.getElementById("profile-avatar").src = avatarUrl(info.avatar); const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE"); document.getElementById("profile-info").textContent = `Mitglied seit ${since}`; diff --git a/web/user.html b/web/user.html index 17cfb6f..8d6d4c0 100644 --- a/web/user.html +++ b/web/user.html @@ -19,12 +19,25 @@
-

-

+
+ +
+

+

+
+