feat: add user avatars and settings page
Backend:
- Migration v7: avatar_path TEXT column on users table
- POST /api/users/me/avatar — upload avatar (resized to 256px, JPEG)
- GET /api/users/{id}/avatar — serve avatar image
- avatar_url included in both list and detail image responses
Frontend:
- New /settings page with avatar upload (file picker, preview, save)
- SETTINGS link in nav bar (visible when logged in)
- Uploader avatar shown in browse card overlay, ImageViewer info bar,
and image detail page
💘 Generated with Crush
Assisted-by: Crush:deepseek-reasoner
jump to
@@ -0,0 +1,118 @@
+package api + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "io" + "net/http" + "strconv" + "time" + + "golang.org/x/image/draw" +) + +const ( + avatarMaxDimension = 256 + avatarQuality = 85 +) + +func (a *API) HandleUploadAvatar(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if err := r.ParseMultipartForm(8 << 20); err != nil { + writeError(w, http.StatusBadRequest, "invalid multipart form") + return + } + + file, _, err := r.FormFile("avatar") + if err != nil { + writeError(w, http.StatusBadRequest, "avatar field is required") + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read file") + return + } + + // Decode image. + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid image format") + return + } + + // Resize to fit within avatarMaxDimension. + bounds := src.Bounds() + wDim, hDim := bounds.Dx(), bounds.Dy() + if wDim > avatarMaxDimension || hDim > avatarMaxDimension { + ratio := float64(avatarMaxDimension) / float64(max(wDim, hDim)) + newW := int(float64(wDim) * ratio) + newH := int(float64(hDim) * ratio) + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + src = dst + } + + // Encode as JPEG. + var buf bytes.Buffer + if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: avatarQuality}); err != nil { + writeError(w, http.StatusInternalServerError, "failed to encode avatar") + return + } + + avatarRel := fmt.Sprintf("avatars/%d_%d%s", user.ID, time.Now().Unix(), ".jpg") + if err := a.Storage.StoreAt(avatarRel, buf.Bytes()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store avatar") + return + } + + // Delete old avatar if exists. + if user.AvatarPath != nil { + a.Storage.Delete(*user.AvatarPath) + } + + // Update DB. + if err := a.DB.SetUserAvatar(r.Context(), user.ID, &avatarRel); err != nil { + writeError(w, http.StatusInternalServerError, "failed to save avatar") + return + } + + avatarURL := "/api/users/" + strconv.FormatInt(user.ID, 10) + "/avatar" + writeJSON(w, http.StatusOK, map[string]any{ + "avatar_url": avatarURL, + }) +} + +func (a *API) HandleServeAvatar(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + user, err := a.DB.GetUserByID(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + if user.AvatarPath == nil { + writeError(w, http.StatusNotFound, "no avatar") + return + } + + absPath := a.Storage.AbsPath(*user.AvatarPath) + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeFile(w, r, absPath) +}
@@ -38,6 +38,9 @@
user, err := a.DB.GetUserByID(r.Context(), img.UploadedBy) if err == nil { img.UploaderUsername = user.Username + if user.AvatarPath != nil { + img.AvatarURL = "/api/users/" + strconv.FormatInt(img.UploadedBy, 10) + "/avatar" + } } }@@ -69,9 +72,13 @@ tagsDetail = make([]db.Tag, 0)
} uploaderUsername := "" + avatarURL := "" user, err := a.DB.GetUserByID(r.Context(), img.UploadedBy) if err == nil { uploaderUsername = user.Username + if user.AvatarPath != nil { + avatarURL = "/api/users/" + strconv.FormatInt(img.UploadedBy, 10) + "/avatar" + } } writeJSON(w, http.StatusOK, map[string]any{@@ -81,6 +88,7 @@ "storage_path": img.StoragePath,
"thumbnail_path": img.ThumbnailPath, "uploaded_by": img.UploadedBy, "uploader_username": uploaderUsername, + "avatar_url": avatarURL, "file_size": img.FileSize, "width": img.Width, "height": img.Height,
@@ -7,6 +7,7 @@ ID int64 `json:"id"`
Username string `json:"username"` PasswordHash string `json:"-"` IsAdmin bool `json:"is_admin"` + AvatarPath *string `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` }@@ -26,6 +27,7 @@ StoragePath string `json:"storage_path"`
ThumbnailPath *string `json:"thumbnail_path"` UploadedBy int64 `json:"uploaded_by"` UploaderUsername string `json:"uploader_username"` + AvatarURL string `json:"avatar_url"` FileSize int64 `json:"file_size"` Width int `json:"width"` Height int `json:"height"`
@@ -35,15 +35,23 @@
// GetUserByID returns a single user by primary key. func (d *DB) GetUserByID(ctx context.Context, id int64) (*User, error) { row := d.db.QueryRowContext(ctx, - `SELECT id, username, password_hash, is_admin, created_at, updated_at + `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users WHERE id = ?`, id) return scanUser(row) } +// SetUserAvatar updates the avatar_path for a user. Pass nil to clear. +func (d *DB) SetUserAvatar(ctx context.Context, id int64, avatarPath *string) error { + _, err := d.db.ExecContext(ctx, + `UPDATE users SET avatar_path = ?, updated_at = ? WHERE id = ?`, + avatarPath, now(), id) + return err +} + // GetUserByUsername returns a single user by username. func (d *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { row := d.db.QueryRowContext(ctx, - `SELECT id, username, password_hash, is_admin, created_at, updated_at + `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users WHERE username = ?`, username) return scanUser(row) }@@ -51,7 +59,7 @@
// ListUsers returns all users ordered by username. func (d *DB) ListUsers(ctx context.Context) ([]User, error) { rows, err := d.db.QueryContext(ctx, - `SELECT id, username, password_hash, is_admin, created_at, updated_at + `SELECT id, username, password_hash, is_admin, created_at, updated_at, avatar_path FROM users ORDER BY username`) if err != nil { return nil, fmt.Errorf("list users: %w", err)@@ -105,12 +113,14 @@ func scanUser(s userScanner) (*User, error) {
var u User var isAdmin int var createdAt, updatedAt string - if err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &isAdmin, &createdAt, &updatedAt); err != nil { + var avatarPath *string + if err := s.Scan(&u.ID, &u.Username, &u.PasswordHash, &isAdmin, &createdAt, &updatedAt, &avatarPath); err != nil { return nil, err } u.IsAdmin = isAdmin == 1 u.CreatedAt, _ = scanTime(createdAt) u.UpdatedAt, _ = scanTime(updatedAt) + u.AvatarPath = avatarPath return &u, nil }
@@ -127,6 +127,12 @@ CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); `, }, + { + version: 7, + sql: ` + ALTER TABLE users ADD COLUMN avatar_path TEXT; + `, + }, } for _, m := range migrations {
@@ -49,6 +49,10 @@
// Tags (authenticated). mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest)) + // Users (authenticated). + mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) + mux.HandleFunc("GET /api/users/{id}/avatar", s.requireAuth(h.HandleServeAvatar)) + // SPA — serve index.html for all non-API routes (client-side routing). mux.Handle("/", s.spaHandler(webFS))
@@ -150,6 +150,9 @@ <div class="viewer-info" class:controls-hidden={!showControls}>
<div class="viewer-info-left"> <div class="viewer-filename">{current.filename}</div> <div class="viewer-meta"> + {#if current.avatar_url} + <img src={current.avatar_url} alt="" class="viewer-avatar" /> + {/if} #{current.id} <span class="meta-dot">·</span> {current.width}×{current.height}@@ -318,6 +321,15 @@ .viewer-meta {
color: rgba(255, 255, 255, 0.5); font-size: 0.78rem; font-weight: 500; + display: flex; + align-items: center; + gap: 0.35rem; + } + .viewer-avatar { + width: 1.2rem; + height: 1.2rem; + object-fit: cover; + flex-shrink: 0; } .meta-dot { margin: 0 0.35rem;
@@ -15,6 +15,7 @@ storage_path: string;
thumbnail_path: string | null; uploaded_by: number; uploader_username: string; + avatar_url: string; file_size: number; width: number; height: number;@@ -112,3 +113,9 @@
export async function suggestTags(query: string): Promise<Tag[]> { return request<Tag[]>(`/tags/suggest?q=${encodeURIComponent(query)}`); } + +export async function uploadAvatar(file: File): Promise<{ avatar_url: string }> { + const fd = new FormData(); + fd.append('avatar', file); + return request<{ avatar_url: string }>('/users/me/avatar', { method: 'POST', body: fd }); +}
@@ -33,6 +33,7 @@ <a href="/browse">BROWSE</a>
<a href="/upload">UPLOAD</a> {#if !checking} {#if loggedIn} + <a href="/settings">SETTINGS</a> <button class="link logout" onclick={handleLogout}>LOGOUT</button> {:else} <a href="/login">LOGIN</a>
@@ -111,7 +111,12 @@ {/if}
</div> <div class="card-overlay"> <div class="card-title">{img.filename}</div> - <div class="card-user">#{img.id} · {img.uploader_username}</div> + <div class="card-user"> + {#if img.avatar_url} + <img src={img.avatar_url} alt="" class="card-avatar" /> + {/if} + #{img.id} · {img.uploader_username} + </div> </div> </button> {/each}@@ -303,6 +308,16 @@ font-family: 'Inter', sans-serif;
font-size: 0.68rem; font-weight: 500; margin-top: 0.05rem; + display: flex; + align-items: center; + gap: 0.3rem; + } + .card-avatar { + width: 1rem; + height: 1rem; + border-radius: 0; + object-fit: cover; + flex-shrink: 0; } .pagination {
@@ -50,6 +50,15 @@ {/if}
</div> <div class="meta"> + <div class="meta-block uploader-block"> + <span class="meta-label">UPLOADED BY</span> + <div class="uploader-row"> + {#if img.avatar_url} + <img src={img.avatar_url} alt="" class="avatar-img" /> + {/if} + <span class="meta-value">{img.uploader_username}</span> + </div> + </div> <div class="meta-block"> <span class="meta-label">ID</span> <span class="meta-value">#{img.id}</span>@@ -65,10 +74,6 @@ </div>
<div class="meta-block"> <span class="meta-label">TYPE</span> <span class="meta-value">{img.mime_type}</span> - </div> - <div class="meta-block"> - <span class="meta-label">UPLOADED BY</span> - <span class="meta-value">{img.uploader_username}</span> </div> <div class="meta-block"> <span class="meta-label">UPLOADED</span>@@ -175,6 +180,18 @@ font-family: 'Inter', sans-serif;
font-size: 0.9rem; font-weight: 600; color: #1a1a1a; + } + + .uploader-row { + display: flex; + align-items: center; + gap: 0.4rem; + } + .avatar-img { + width: 1.5rem; + height: 1.5rem; + object-fit: cover; + flex-shrink: 0; } .tags-block {
@@ -0,0 +1,200 @@
+<script lang="ts"> + import { uploadAvatar } from '$lib/api'; + + let file = $state<File | null>(null); + let previewUrl = $state(''); + let error = $state(''); + let success = $state(''); + let loading = $state(false); + + function handleFileSelect(e: Event) { + const f = (e.target as HTMLInputElement).files?.[0]; + if (!f) return; + file = f; + previewUrl = URL.createObjectURL(f); + error = ''; + success = ''; + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + if (!file) return; + error = ''; + success = ''; + loading = true; + try { + const result = await uploadAvatar(file); + success = 'Avatar updated.'; + previewUrl = result.avatar_url; + file = null; + } catch (err) { + error = err instanceof Error ? err.message : 'Upload failed'; + } finally { + loading = false; + } + } +</script> + +<div class="page-head"> + <h1>SETTINGS</h1> + <div class="head-line"></div> +</div> + +<form onsubmit={handleSubmit}> + {#if error} + <p class="error">{error}</p> + {/if} + {#if success} + <p class="success">{success}</p> + {/if} + + <div class="avatar-section"> + <div class="avatar-preview"> + {#if previewUrl} + <img src={previewUrl} alt="Avatar preview" class="avatar-img" /> + {:else} + <div class="avatar-placeholder">NO AVATAR</div> + {/if} + </div> + + <label> + <span class="label-text">AVATAR IMAGE</span> + <div class="file-wrap"> + <input type="file" id="avatar-input" accept="image/*" + onchange={handleFileSelect} + disabled={loading} + /> + <label for="avatar-input" class="file-label"> + {file ? file.name : 'CHOOSE IMAGE'} + </label> + </div> + </label> + </div> + + <button type="submit" class="btn" disabled={loading || !file}> + {loading ? 'UPLOADING...' : 'UPLOAD AVATAR'} + </button> +</form> + +<style> + .page-head { + margin-bottom: 1.5rem; + } + .page-head h1 { + margin: 0; + } + .head-line { + height: 3px; + width: 3rem; + background: #c62828; + margin-top: 0.4rem; + } + + form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 400px; + } + + .error { + color: #c62828; + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + font-weight: 600; + } + .success { + color: #2e7d32; + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + font-weight: 600; + } + + .avatar-section { + display: flex; + gap: 1.5rem; + align-items: flex-start; + } + .avatar-preview { + width: 100px; + height: 100px; + background: #1a1a1a; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + } + .avatar-placeholder { + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.7rem; + letter-spacing: 0.12em; + color: #555; + } + + .label-text { + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.75rem; + letter-spacing: 0.15em; + color: #888; + } + .file-wrap { + position: relative; + margin-top: 0.25rem; + } + #avatar-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + } + .file-label { + display: flex; + align-items: center; + padding: 0.55rem 0.75rem; + border: 2px solid #1a1a1a; + font-family: 'Inter', sans-serif; + font-size: 0.85rem; + font-weight: 500; + color: #888; + background: #fff; + cursor: pointer; + transition: border-color 0.15s; + } + .file-label:hover { + border-color: #c62828; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.6rem 1.5rem; + background: #1a1a1a; + color: #fff; + border: none; + font-family: 'Oswald', sans-serif; + font-weight: 500; + font-size: 0.85rem; + letter-spacing: 0.1em; + cursor: pointer; + text-transform: uppercase; + transition: background 0.15s; + align-self: flex-start; + } + .btn:hover { + background: #c62828; + } + .btn:disabled { + opacity: 0.4; + cursor: default; + } +</style>