Profilbilder: Avatar-Upload, Anzeige auf Profil & Feed
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS user (
|
|||||||
username TEXT UNIQUE,
|
username TEXT UNIQUE,
|
||||||
password BLOB,
|
password BLOB,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
last_login INTEGER
|
last_login INTEGER,
|
||||||
|
avatar TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS session (
|
CREATE TABLE IF NOT EXISTS session (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Entry struct {
|
|||||||
LastActivity int64 `json:"last_activity"`
|
LastActivity int64 `json:"last_activity"`
|
||||||
Deleted int64 `json:"deleted"`
|
Deleted int64 `json:"deleted"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
||||||
@@ -38,7 +39,7 @@ type Entry struct {
|
|||||||
const entrySelect = `
|
const entrySelect = `
|
||||||
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
||||||
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
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`
|
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
|
||||||
|
|
||||||
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
||||||
@@ -48,7 +49,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e Entry
|
||||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
@@ -61,7 +62,7 @@ func entryByPID(pid int64) (Entry, error) {
|
|||||||
var e Entry
|
var e Entry
|
||||||
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
||||||
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
&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
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func routes() http.Handler {
|
|||||||
r.Post("/entry/{pid}/delete", handleDeleteEntry)
|
r.Post("/entry/{pid}/delete", handleDeleteEntry)
|
||||||
r.Get("/user/info", handleUserInfo)
|
r.Get("/user/info", handleUserInfo)
|
||||||
r.Post("/user/rename", handleUserRename)
|
r.Post("/user/rename", handleUserRename)
|
||||||
|
r.Post("/user/avatar", handleSetAvatar)
|
||||||
r.Post("/user/delete", handleUserDelete)
|
r.Post("/user/delete", handleUserDelete)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -57,7 +57,8 @@ CREATE TABLE user (
|
|||||||
username TEXT UNIQUE,
|
username TEXT UNIQUE,
|
||||||
password BLOB,
|
password BLOB,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
last_login INTEGER
|
last_login INTEGER,
|
||||||
|
avatar TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE TABLE session (
|
CREATE TABLE session (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -91,14 +92,18 @@ SCHEMA
|
|||||||
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
|
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
|
||||||
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
|
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
|
||||||
has_col() {
|
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"
|
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"
|
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_filter=""
|
||||||
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
|
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
|
||||||
@@ -114,8 +119,8 @@ ATTACH '$src_sql' AS src;
|
|||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
|
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
|
||||||
INSERT INTO user (id, uid, username, password, created_at, last_login)
|
INSERT INTO user (id, uid, username, password, created_at, last_login, avatar)
|
||||||
SELECT id, uid, username, password, created_at, last_login FROM src.user;
|
SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u;
|
||||||
|
|
||||||
-- Einträge transformieren:
|
-- Einträge transformieren:
|
||||||
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
|
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
|
||||||
|
|||||||
@@ -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
|
## `filepath` NULL → leerer String
|
||||||
|
|
||||||
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
|
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -56,7 +56,8 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
username := chi.URLParam(r, "username")
|
username := chi.URLParam(r, "username")
|
||||||
|
|
||||||
var uid, createdAt int64
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||||
return
|
return
|
||||||
@@ -65,6 +66,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"uid": uid,
|
"uid": uid,
|
||||||
"username": username,
|
"username": username,
|
||||||
"created_at": createdAt,
|
"created_at": createdAt,
|
||||||
|
"avatar": avatar,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,10 +74,10 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
var createdAt, lastLogin sql.NullInt64
|
var createdAt, lastLogin sql.NullInt64
|
||||||
var username string
|
var username, avatar string
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
`SELECT created_at, last_login, username FROM user WHERE uid = ?`, uid,
|
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
|
||||||
).Scan(&createdAt, &lastLogin, &username)
|
).Scan(&createdAt, &lastLogin, &username, &avatar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||||
return
|
return
|
||||||
@@ -85,6 +87,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"username": username,
|
"username": username,
|
||||||
"created_at": createdAt.Int64,
|
"created_at": createdAt.Int64,
|
||||||
"last_login": lastLogin.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})
|
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
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
||||||
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
|
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
|
||||||
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
|
// 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
|
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
|
var media []string
|
||||||
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
|
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -150,6 +188,10 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rows.Close()
|
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()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+31
-2
@@ -158,17 +158,46 @@ a.button:hover, button:hover {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card img {
|
/* Beitragsbild (nicht der Byline-Avatar). */
|
||||||
|
.card img:not(.avatar) {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
margin: 0.5rem auto;
|
margin: 0.5rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fokussierter Beitrag zeigt das Bild in voller Breite. */
|
/* Fokussierter Beitrag zeigt das Bild in voller Breite. */
|
||||||
#focus .card img {
|
#focus .card img:not(.avatar) {
|
||||||
max-width: 100%;
|
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. */
|
/* Ahnenkette: der Strich links symbolisiert den Thread oberhalb des Beitrags. */
|
||||||
#ancestors .card {
|
#ancestors .card {
|
||||||
border-left: 3px solid gray;
|
border-left: 3px solid gray;
|
||||||
|
|||||||
+9
-1
@@ -21,6 +21,11 @@ function escapeHtml(s) {
|
|||||||
return d.innerHTML;
|
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) {
|
function linkify(s) {
|
||||||
const escaped = escapeHtml(s);
|
const escaped = escapeHtml(s);
|
||||||
const url = /https?:\/\/[^\s<]+/g;
|
const url = /https?:\/\/[^\s<]+/g;
|
||||||
@@ -57,7 +62,10 @@ function renderEntry(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="muted"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
<div class="muted byline">
|
||||||
|
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
|
||||||
|
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</span>
|
||||||
|
</div>
|
||||||
<div class="content">${linkify(e.content)}</div>
|
<div class="content">${linkify(e.content)}</div>
|
||||||
${media}
|
${media}
|
||||||
<div class="interactions">
|
<div class="interactions">
|
||||||
|
|||||||
@@ -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) => {
|
document.getElementById("delete-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!confirm("Konto wirklich unwiderruflich löschen?")) return;
|
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-name").textContent = info.username;
|
||||||
|
document.getElementById("profile-avatar").src = avatarUrl(info.avatar);
|
||||||
const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE");
|
const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE");
|
||||||
document.getElementById("profile-info").textContent = `Mitglied seit ${since}`;
|
document.getElementById("profile-info").textContent = `Mitglied seit ${since}`;
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -19,12 +19,25 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="profile">
|
<section id="profile">
|
||||||
<h2 id="profile-name"></h2>
|
<div class="profile-head">
|
||||||
<p id="profile-info" class="muted"></p>
|
<img id="profile-avatar" class="avatar avatar-lg" src="/static/assets/no_profile_pic.jpg" alt="">
|
||||||
|
<div>
|
||||||
|
<h2 id="profile-name"></h2>
|
||||||
|
<p id="profile-info" class="muted"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nur für den Eigentümer sichtbar -->
|
<!-- Nur für den Eigentümer sichtbar -->
|
||||||
<div id="settings" hidden>
|
<div id="settings" hidden>
|
||||||
<h2>Einstellungen</h2>
|
<h2>Einstellungen</h2>
|
||||||
|
<details>
|
||||||
|
<summary>Profilbild ändern</summary>
|
||||||
|
<form id="avatar-form">
|
||||||
|
<input name="avatar" type="file" accept="image/*">
|
||||||
|
<button type="submit">Bild hochladen</button>
|
||||||
|
</form>
|
||||||
|
<p class="msg" id="avatar-msg"></p>
|
||||||
|
</details>
|
||||||
<details>
|
<details>
|
||||||
<summary>Name ändern</summary>
|
<summary>Name ändern</summary>
|
||||||
<form id="rename-form">
|
<form id="rename-form">
|
||||||
|
|||||||
Reference in New Issue
Block a user