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 0000000..8ba872d Binary files /dev/null and b/static/assets/no_profile_pic.jpg differ diff --git a/user.go b/user.go index 227d25c..0a72c42 100644 --- a/user.go +++ b/user.go @@ -56,7 +56,8 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") var uid, createdAt int64 - err := db.QueryRow(`SELECT uid, created_at FROM user WHERE username = ?`, username).Scan(&uid, &createdAt) + var avatar string + err := db.QueryRow(`SELECT uid, created_at, avatar FROM user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar) if err != nil { writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") return @@ -65,6 +66,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) { "uid": uid, "username": username, "created_at": createdAt, + "avatar": avatar, }) } @@ -72,10 +74,10 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) var createdAt, lastLogin sql.NullInt64 - var username string + var username, avatar string err := db.QueryRow( - `SELECT created_at, last_login, username FROM user WHERE uid = ?`, uid, - ).Scan(&createdAt, &lastLogin, &username) + `SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid, + ).Scan(&createdAt, &lastLogin, &username, &avatar) if err != nil { writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") return @@ -85,6 +87,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) { "username": username, "created_at": createdAt.Int64, "last_login": lastLogin.Int64, + "avatar": avatar, }) } @@ -120,6 +123,40 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"username": name}) } +// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten +// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes. +func handleSetAvatar(w http.ResponseWriter, r *http.Request) { + uid := uidFromContext(r.Context()) + + if err := r.ParseMultipartForm(16 << 20); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 { + writeError(w, http.StatusBadRequest, "Kein Bild hochgeladen.") + return + } + + stored, err := storeImage(r.MultipartForm.File["avatar"][0]) + if err != nil { + writeError(w, http.StatusBadRequest, "Bild konnte nicht verarbeitet werden.") + return + } + + var old string + db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&old) + + if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil { + os.Remove(stored) + writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") + return + } + if old != "" && old != stored { + os.Remove(old) + } + writeJSON(w, http.StatusOK, map[string]string{"avatar": stored}) +} + // handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung // durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer // Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als @@ -139,7 +176,8 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) { return } - // Mediendateien der Beiträge einsammeln, bevor die Pfade geleert werden. + // Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die + // Pfade geleert bzw. die user-Zeile gelöscht wird. var media []string if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil { for rows.Next() { @@ -150,6 +188,10 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) { } rows.Close() } + var avatar string + if db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&avatar) == nil && avatar != "" { + media = append(media, avatar) + } tx, err := db.Begin() if err != nil { diff --git a/web/css/app.css b/web/css/app.css index 57eaa83..bd01c5b 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -158,17 +158,46 @@ a.button:hover, button:hover { overflow-wrap: break-word; } -.card img { +/* Beitragsbild (nicht der Byline-Avatar). */ +.card img:not(.avatar) { display: block; max-width: 300px; margin: 0.5rem auto; } /* Fokussierter Beitrag zeigt das Bild in voller Breite. */ -#focus .card img { +#focus .card img:not(.avatar) { max-width: 100%; } +/* --- Avatare --- */ +img.avatar { + display: inline-block; + vertical-align: middle; + border-radius: 50%; + object-fit: cover; + background-color: #eee; + margin: 0; + max-width: none; +} + +img.avatar-sm { width: 50px; height: 50px; } +img.avatar-lg { width: 72px; height: 72px; } + +/* Byline mit kleinem Avatar neben Name + Zeitstempel. */ +.byline { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */ +.profile-head { + display: flex; + align-items: center; + gap: 0.75rem; +} + /* Ahnenkette: der Strich links symbolisiert den Thread oberhalb des Beitrags. */ #ancestors .card { border-left: 3px solid gray; diff --git a/web/js/feed.js b/web/js/feed.js index 42c8985..0431c04 100644 --- a/web/js/feed.js +++ b/web/js/feed.js @@ -21,6 +21,11 @@ function escapeHtml(s) { return d.innerHTML; } +// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar. +function avatarUrl(path) { + return path ? `/${path}` : "/static/assets/no_profile_pic.jpg"; +} + function linkify(s) { const escaped = escapeHtml(s); const url = /https?:\/\/[^\s<]+/g; @@ -57,7 +62,10 @@ function renderEntry(e) { } el.innerHTML = ` -
${escapeHtml(e.username)} · ${when}
+
+ + ${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 @@
-

-

+
+ +
+

+

+
+