all repos — weimar @ c9643af6a7047483b00143b1304a7aff7ce61173

Unnamed repository; edit this file 'description' to name the repository.

feat: default avatar SVG, nav bar avatar, fix settings preview

- Avatar.svelte component: shows uploaded image or red SVG fallback
  with first letter of username
- Nav bar shows avatar (28px) in top-right corner next to LOGOUT,
  links to /settings
- Layout now fetches GET /api/users/me instead of pinging images
  endpoint for auth state
- Settings page loads current avatar on mount via getMe()
- ImageViewer uses Avatar component for uploader display
- Cleaned up unused CSS classes

💘 Generated with Crush

Assisted-by: Crush:deepseek-reasoner
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 16:53:37 +0200
commit

c9643af6a7047483b00143b1304a7aff7ce61173

parent

8d72190a752e3e1eaff992da40d75e01c6149993

A internal/api/users.go

@@ -0,0 +1,28 @@

+package api + +import ( + "net/http" + "strconv" +) + +func (a *API) HandleGetMe(w http.ResponseWriter, r *http.Request) { + user := UserFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + avatarURL := "" + if user.AvatarPath != nil { + avatarURL = "/api/users/" + strconv.FormatInt(user.ID, 10) + "/avatar" + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": user.ID, + "username": user.Username, + "is_admin": user.IsAdmin, + "avatar_url": avatarURL, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + }) +}
M internal/server/server.gointernal/server/server.go

@@ -50,6 +50,7 @@ // Tags (authenticated).

mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest)) // Users (authenticated). + mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe)) mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) mux.HandleFunc("GET /api/users/{id}/avatar", s.requireAuth(h.HandleServeAvatar))
A web/src/lib/Avatar.svelte

@@ -0,0 +1,40 @@

+<script lang="ts"> + let { + src = '', + username = '', + size = 32, + class: className = '' + }: { + src?: string; + username?: string; + size?: number; + class?: string; + } = $props(); + + let initial = $derived(username.charAt(0).toUpperCase() || '?'); +</script> + +{#if src} + <img {src} alt={username} class={className} style="width: {size}px; height: {size}px; object-fit: cover; flex-shrink: 0;" /> +{:else} + <svg + width={size} + height={size} + viewBox="0 0 32 32" + class={className} + style="flex-shrink: 0;" + aria-label={username} + role="img" + > + <rect width="32" height="32" fill="#c62828" /> + <text + x="16" + y="22" + text-anchor="middle" + fill="#fff" + font-family="Inter, sans-serif" + font-weight="700" + font-size="16" + >{initial}</text> + </svg> +{/if}
M web/src/lib/ImageViewer.svelteweb/src/lib/ImageViewer.svelte

@@ -1,5 +1,6 @@

<script lang="ts"> import { imageDownloadUrl, getImage, type Image, type TagDetail } from '$lib/api'; + import Avatar from '$lib/Avatar.svelte'; let { images,

@@ -148,11 +149,12 @@ </div>

<div class="viewer-info" class:controls-hidden={!showControls}> <div class="viewer-info-left"> + <div class="viewer-uploader"> + <Avatar src={current.avatar_url} username={current.uploader_username} size={40} /> + <span class="viewer-uploader-name">{current.uploader_username}</span> + </div> <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">&middot;</span> {current.width}&times;{current.height}

@@ -160,8 +162,6 @@ <span class="meta-dot">&middot;</span>

{formatSize(current.file_size)} <span class="meta-dot">&middot;</span> {current.mime_type} - <span class="meta-dot">&middot;</span> - UPLOADED BY {current.uploader_username} </div> </div> <div class="viewer-info-right">

@@ -305,31 +305,40 @@ }

.viewer-info-left { display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.15rem; min-width: 0; } - .viewer-filename { + + .viewer-uploader { + display: flex; + align-items: center; + gap: 1.2rem; + margin-bottom: 0.8rem; + } + .viewer-uploader-name { color: #fff; font-family: 'Inter', sans-serif; font-weight: 600; font-size: 0.95rem; + } + + .viewer-filename { + color: rgba(255, 255, 255, 0.65); + font-family: 'Inter', sans-serif; + font-weight: 500; + font-size: 0.82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .viewer-meta { - color: rgba(255, 255, 255, 0.5); - font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + font-size: 0.75rem; 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; + flex-wrap: wrap; } .meta-dot { margin: 0 0.35rem;
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -44,6 +44,15 @@ export interface ImageDetail extends Image {

tags_detail: TagDetail[]; } +export interface CurrentUser { + id: number; + username: string; + is_admin: boolean; + avatar_url: string; + created_at: string; + updated_at: string; +} + export interface ApiError { error: string; }

@@ -119,3 +128,7 @@ const fd = new FormData();

fd.append('avatar', file); return request<{ avatar_url: string }>('/users/me/avatar', { method: 'POST', body: fd }); } + +export async function getMe(): Promise<CurrentUser> { + return request<CurrentUser>('/users/me'); +}
M web/src/routes/+layout.svelteweb/src/routes/+layout.svelte

@@ -1,24 +1,23 @@

<script lang="ts"> import { onMount } from 'svelte'; - import { logout } from '$lib/api'; + import { logout, getMe, type CurrentUser } from '$lib/api'; + import Avatar from '$lib/Avatar.svelte'; let { children } = $props(); - let loggedIn = $state(false); + let user = $state<CurrentUser | null>(null); let checking = $state(true); onMount(() => { - fetch('/api/images?per_page=1', { credentials: 'include' }) - .then((r) => { - if (r.ok) loggedIn = true; - }) + getMe() + .then((u) => { user = u; }) .catch(() => {}) .finally(() => { checking = false; }); }); async function handleLogout() { await logout(); - loggedIn = false; + user = null; window.location.href = '/login'; } </script>

@@ -32,8 +31,10 @@ <div class="nav-links">

<a href="/browse">BROWSE</a> <a href="/upload">UPLOAD</a> {#if !checking} - {#if loggedIn} - <a href="/settings">SETTINGS</a> + {#if user} + <a href="/settings" class="nav-avatar-link"> + <Avatar src={user.avatar_url} username={user.username} size={28} /> + </a> <button class="link logout" onclick={handleLogout}>LOGOUT</button> {:else} <a href="/login">LOGIN</a>

@@ -166,6 +167,16 @@ }

.nav-links a:hover::after { height: 3px; + } + + .nav-avatar-link { + padding: 0 0.75rem !important; + } + .nav-avatar-link::after { + display: none !important; + } + .nav-avatar-link:hover { + background: none !important; } .logout {
M web/src/routes/settings/+page.svelteweb/src/routes/settings/+page.svelte

@@ -1,11 +1,24 @@

<script lang="ts"> - import { uploadAvatar } from '$lib/api'; + import { onMount } from 'svelte'; + import { uploadAvatar, getMe } from '$lib/api'; + import Avatar from '$lib/Avatar.svelte'; let file = $state<File | null>(null); let previewUrl = $state(''); + let username = $state(''); let error = $state(''); let success = $state(''); let loading = $state(false); + + onMount(async () => { + try { + const u = await getMe(); + username = u.username; + if (u.avatar_url) { + previewUrl = u.avatar_url; + } + } catch {} + }); function handleFileSelect(e: Event) { const f = (e.target as HTMLInputElement).files?.[0];

@@ -50,11 +63,7 @@ {/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} + <Avatar src={previewUrl} username={username} size={100} /> </div> <label>

@@ -124,18 +133,6 @@ 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 {