fabrica

hanna/fabrica

feat(web): user profiles, settings editor, and avatars

dd5f6ed · hanna committed on 2026-07-25

Redesign the user page as a profile: avatar, name, `User · username`,
pronouns, bio, and location/website/GitHub/Mastodon links with their icons.
Add a settings editor (display name, pronouns, bio, location, website, and
named social handles) plus an avatar uploader. Serve `/avatar/{username}`
from an uploaded PNG/JPEG/GIF/WebP (≤ 1 MiB, stored on disk) or a stable
FNV-derived identicon SVG — no gravatar, no network requests (§9.5). Both
mutating routes verify the double-submit CSRF token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +495 −39UnifiedSplit
Cargo.lock +18 −0
@@ -348,6 +348,7 @@ dependencies = [
348348 "matchit",
349349 "memchr",
350350 "mime",
351+ "multer",
351352 "percent-encoding",
352353 "pin-project-lite",
353354 "serde_core",
@@ -2946,6 +2947,23 @@ dependencies = [
29462947 ]
29472948
29482949 [[package]]
2950+name = "multer"
2951+version = "3.1.0"
2952+source = "registry+https://github.com/rust-lang/crates.io-index"
2953+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2954+dependencies = [
2955+ "bytes",
2956+ "encoding_rs",
2957+ "futures-util",
2958+ "http",
2959+ "httparse",
2960+ "memchr",
2961+ "mime",
2962+ "spin",
2963+ "version_check",
2964+]
2965+
2966+[[package]]
29492967 name = "native-tls"
29502968 version = "0.2.18"
29512969 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +1 −1
@@ -138,7 +138,7 @@ lettre = { version = "0.11", default-features = false, features = [
138138 # Web stack. No TLS/rustls features anywhere (TLS terminates at a reverse proxy),
139139 # so `ring` stays out of the graph. Versions are the current mutually-compatible
140140 # axum 0.8 set.
141-axum = "0.8"
141+axum = { version = "0.8", features = ["multipart"] }
142142 axum-extra = { version = "0.12", features = ["cookie"] }
143143 maud = { version = "0.27", features = ["axum"] }
144144 tower = { version = "0.5", features = ["util"] }
assets/base.css +73 −0
@@ -395,6 +395,75 @@ select:focus {
395395 font-size: 0.82em;
396396 }
397397
398+/* ---- Avatars & profile header ---- */
399+.avatar {
400+ border-radius: 50%;
401+ object-fit: cover;
402+ background: var(--fb-bg-inset);
403+ border: 1px solid var(--fb-border);
404+}
405+.avatar-lg {
406+ width: 5rem;
407+ height: 5rem;
408+}
409+.avatar-xl {
410+ width: 6rem;
411+ height: 6rem;
412+}
413+.profile-head {
414+ display: flex;
415+ gap: 1.25rem;
416+ align-items: flex-start;
417+ margin-bottom: 1.5rem;
418+ padding-bottom: 1.5rem;
419+ border-bottom: 1px solid var(--fb-border);
420+}
421+.profile-meta {
422+ min-width: 0;
423+}
424+.profile-meta h1 {
425+ margin-bottom: 0.15rem;
426+}
427+.profile-sub {
428+ display: inline-flex;
429+ align-items: center;
430+ gap: 0.4rem;
431+}
432+.profile-pronouns {
433+ margin-left: 0.5rem;
434+ padding: 0.05rem 0.5rem;
435+ border: 1px solid var(--fb-border);
436+ border-radius: 999px;
437+ font-size: 0.82em;
438+}
439+.profile-bio {
440+ margin: 0.6rem 0 0;
441+ white-space: pre-wrap;
442+}
443+.profile-links {
444+ list-style: none;
445+ margin: 0.6rem 0 0;
446+ padding: 0;
447+ display: flex;
448+ flex-wrap: wrap;
449+ gap: 0.25rem 1.25rem;
450+}
451+.profile-links li {
452+ display: inline-flex;
453+ align-items: center;
454+ gap: 0.4rem;
455+}
456+.avatar-settings {
457+ display: flex;
458+ align-items: center;
459+ gap: 1.25rem;
460+ flex-wrap: wrap;
461+}
462+.avatar-settings form {
463+ flex: 1;
464+ min-width: 15rem;
465+}
466+
398467 /* ---- Tables ---- */
399468 table.list {
400469 width: 100%;
@@ -703,4 +772,8 @@ td.diff-empty {
703772 display: block;
704773 overflow-x: auto;
705774 }
775+ /* Stack the profile header on narrow screens. */
776+ .profile-head {
777+ flex-direction: column;
778+ }
706779 }
crates/web/src/avatar.rs +165 −0
@@ -0,0 +1,165 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Avatars: an uploaded image when the user has one, else a deterministic
6+//! identicon generated from the username. Bytes live on disk under
7+//! `{storage.data_dir}/avatars/{id}`; the `users.avatar_mime` column records the
8+//! content type (its presence means "serve the file"). No network requests and
9+//! no remote gravatar (§9.5).
10+
11+use std::fmt::Write as _;
12+use std::path::PathBuf;
13+
14+use axum::extract::{Multipart, Path, State};
15+use axum::http::{StatusCode, header};
16+use axum::response::{IntoResponse, Redirect, Response};
17+use axum_extra::extract::cookie::CookieJar;
18+
19+use crate::AppState;
20+use crate::error::AppError;
21+use crate::session::RequireUser;
22+
23+/// The largest avatar upload accepted, in bytes (1 `MiB`).
24+const MAX_AVATAR_BYTES: usize = 1024 * 1024;
25+
26+/// The raster image types accepted for upload. SVG is deliberately excluded to
27+/// avoid serving user-supplied markup; identicons are our own SVG.
28+const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
29+
30+/// The on-disk path of a user's uploaded avatar.
31+fn avatar_path(state: &AppState, user_id: &str) -> PathBuf {
32+ state.config.storage.data_dir.join("avatars").join(user_id)
33+}
34+
35+/// `GET /avatar/{username}` — the user's uploaded avatar, or a generated
36+/// identicon. Always renders an image so `<img>` tags never break.
37+pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {
38+ let user = state.store.user_by_username(&username).await.ok().flatten();
39+ if let Some(user) = &user
40+ && let Some(mime) = &user.avatar_mime
41+ && let Ok(bytes) = tokio::fs::read(avatar_path(&state, &user.id)).await
42+ {
43+ return (
44+ [
45+ (header::CONTENT_TYPE, mime.as_str()),
46+ (header::CACHE_CONTROL, "private, max-age=60"),
47+ (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
48+ ],
49+ bytes,
50+ )
51+ .into_response();
52+ }
53+ (
54+ [
55+ (header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
56+ (header::CACHE_CONTROL, "public, max-age=3600"),
57+ ],
58+ identicon(&username),
59+ )
60+ .into_response()
61+}
62+
63+/// `POST /settings/avatar` — accept an image upload, store it, and record its
64+/// content type. Multipart fields: `_csrf` (token) and `avatar` (the file).
65+pub async fn upload(
66+ State(state): State<AppState>,
67+ RequireUser(user): RequireUser,
68+ jar: CookieJar,
69+ mut multipart: Multipart,
70+) -> Response {
71+ let mut csrf: Option<String> = None;
72+ let mut mime: Option<String> = None;
73+ let mut data: Option<Vec<u8>> = None;
74+
75+ while let Ok(Some(field)) = multipart.next_field().await {
76+ match field.name() {
77+ Some("_csrf") => csrf = field.text().await.ok(),
78+ Some("avatar") => {
79+ let content_type = field.content_type().map(str::to_string);
80+ match field.bytes().await {
81+ Ok(bytes) if !bytes.is_empty() => {
82+ mime = content_type;
83+ data = Some(bytes.to_vec());
84+ }
85+ _ => {}
86+ }
87+ }
88+ _ => {}
89+ }
90+ }
91+
92+ // CSRF: the double-submit cookie must match the submitted token.
93+ if crate::session::verify_csrf(&jar, &axum::http::HeaderMap::new(), csrf.as_deref()).is_err() {
94+ return AppError::Forbidden.into_response();
95+ }
96+
97+ let (Some(bytes), Some(mime)) = (data, mime) else {
98+ // Nothing chosen — treat as a no-op and return to settings.
99+ return Redirect::to("/settings").into_response();
100+ };
101+ if bytes.len() > MAX_AVATAR_BYTES {
102+ return AppError::BadRequest("Avatar must be 1 MiB or smaller.".to_string())
103+ .into_response();
104+ }
105+ if !ALLOWED_MIME.contains(&mime.as_str()) {
106+ return AppError::BadRequest("Avatar must be a PNG, JPEG, GIF, or WebP image.".to_string())
107+ .into_response();
108+ }
109+
110+ let dir = state.config.storage.data_dir.join("avatars");
111+ if let Err(e) = tokio::fs::create_dir_all(&dir).await {
112+ return AppError::internal(e).into_response();
113+ }
114+ if let Err(e) = tokio::fs::write(dir.join(&user.id), &bytes).await {
115+ return AppError::internal(e).into_response();
116+ }
117+ if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await {
118+ return AppError::from(e).into_response();
119+ }
120+ (StatusCode::SEE_OTHER, Redirect::to("/settings")).into_response()
121+}
122+
123+/// Render a deterministic 5×5 mirrored identicon SVG for `seed` (the username).
124+///
125+/// The layout and hue derive from a stable FNV-1a hash, so the same name always
126+/// yields the same badge across restarts and machines.
127+fn identicon(seed: &str) -> Vec<u8> {
128+ let hash = fnv1a(seed.to_lowercase().as_bytes());
129+ // Hue from the top bits; a calm, legible fill against the light plate.
130+ let hue = u32::try_from(hash % 360).unwrap_or(210);
131+ let fill = format!("hsl({hue}, 55%, 55%)");
132+
133+ // 3 columns decide the pattern; columns 3 and 4 mirror 1 and 0.
134+ let mut cells = String::new();
135+ for row in 0..5u32 {
136+ for col in 0..3u32 {
137+ let bit = row * 3 + col;
138+ if (hash >> bit) & 1 == 1 {
139+ let mirror = 4 - col;
140+ let _ = write!(cells, r#"<rect x="{col}" y="{row}" width="1" height="1"/>"#);
141+ if mirror != col {
142+ let _ = write!(
143+ cells,
144+ r#"<rect x="{mirror}" y="{row}" width="1" height="1"/>"#
145+ );
146+ }
147+ }
148+ }
149+ }
150+
151+ format!(
152+ r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" shape-rendering="crispEdges" role="img"><rect width="5" height="5" fill="#e8e8e8"/><g fill="{fill}">{cells}</g></svg>"##
153+ )
154+ .into_bytes()
155+}
156+
157+/// FNV-1a 64-bit hash — small, dependency-free, and stable across runs.
158+fn fnv1a(bytes: &[u8]) -> u64 {
159+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
160+ for &b in bytes {
161+ hash ^= u64::from(b);
162+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
163+ }
164+ hash
165+}
crates/web/src/icons.rs +0 −19
@@ -11,9 +11,6 @@
1111 use maud::{Markup, PreEscaped, html};
1212
1313 /// A vendored icon.
14-// The full glyph set is vendored up front; the profile, explore, and settings
15-// views wire in the remaining variants. Removed once every variant is consumed.
16-#[allow(dead_code)]
1714 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1815 pub enum Icon {
1916 PanelLeft,
@@ -21,23 +18,18 @@ pub enum Icon {
2118 Folder,
2219 File,
2320 GitBranch,
24- Tag,
2521 Search,
2622 Copy,
27- ChevronRight,
2823 Box,
2924 Link,
3025 User,
3126 Settings,
3227 LogOut,
3328 Upload,
34- Plus,
3529 Dashboard,
3630 Compass,
3731 MapPin,
3832 Globe,
39- Download,
40- AtSign,
4133 Github,
4234 Mastodon,
4335 }
@@ -66,14 +58,10 @@ impl Icon {
6658 Icon::GitBranch => {
6759 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#
6860 }
69- Icon::Tag => {
70- r#"<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>"#
71- }
7261 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
7362 Icon::Copy => {
7463 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#
7564 }
76- Icon::ChevronRight => r#"<path d="m9 18 6-6-6-6"/>"#,
7765 Icon::Box => {
7866 r#"<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>"#
7967 }
@@ -92,7 +80,6 @@ impl Icon {
9280 Icon::Upload => {
9381 r#"<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>"#
9482 }
95- Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#,
9683 Icon::Dashboard => {
9784 r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#
9885 }
@@ -105,12 +92,6 @@ impl Icon {
10592 Icon::Globe => {
10693 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
10794 }
108- Icon::Download => {
109- r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#
110- }
111- Icon::AtSign => {
112- r#"<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>"#
113- }
11495 Icon::Github => {
11596 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#
11697 }
crates/web/src/lib.rs +7 −1
@@ -11,6 +11,7 @@
1111 //! without JavaScript; htmx only enhances.
1212
1313 mod assets;
14+mod avatar;
1415 mod diff;
1516 mod error;
1617 mod git_http;
@@ -127,8 +128,13 @@ pub fn build_router(state: AppState) -> Router {
127128 get(pages::invite_form).post(pages::invite_submit),
128129 )
129130 .route("/search", get(search::global))
130- .route("/settings", get(pages::settings))
131+ .route(
132+ "/settings",
133+ get(pages::settings).post(pages::settings_submit),
134+ )
135+ .route("/settings/avatar", post(avatar::upload))
131136 .route("/settings/theme", get(pages::set_theme))
137+ .route("/avatar/{username}", get(avatar::show))
132138 .route("/healthz", get(serve_assets::healthz))
133139 .route("/version", get(serve_assets::version))
134140 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/pages.rs +123 −11
@@ -19,6 +19,7 @@ use serde::Deserialize;
1919 use crate::AppState;
2020 use crate::assets::Scheme;
2121 use crate::error::{AppError, AppResult};
22+use crate::icons::{Icon, icon};
2223 use crate::layout::{Chrome, page};
2324 use crate::repo::{RepoEntry, repo_card};
2425 use crate::session::{
@@ -390,7 +391,7 @@ async fn live_invite(state: &AppState, token: &str) -> AppResult<model::Invite>
390391 Ok(invite)
391392 }
392393
393-/// `GET /settings` — the viewer's profile summary.
394+/// `GET /settings` — the viewer's profile editor.
394395 pub async fn settings(
395396 State(state): State<AppState>,
396397 RequireUser(user): RequireUser,
@@ -398,19 +399,130 @@ pub async fn settings(
398399 uri: Uri,
399400 ) -> Response {
400401 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
401- let body = html! {
402+ let body = settings_body(&user, &chrome.csrf, None);
403+ (jar, page(&chrome, "Settings", body)).into_response()
404+}
405+
406+/// Profile settings form fields. Empty strings clear the corresponding column.
407+#[derive(Debug, Deserialize)]
408+pub struct SettingsForm {
409+ /// Display name.
410+ #[serde(default)]
411+ display_name: String,
412+ /// Pronouns.
413+ #[serde(default)]
414+ pronouns: String,
415+ /// Free-text bio.
416+ #[serde(default)]
417+ bio: String,
418+ /// Website URL.
419+ #[serde(default)]
420+ website: String,
421+ /// Location.
422+ #[serde(default)]
423+ location: String,
424+ /// `GitHub` handle.
425+ #[serde(default)]
426+ social_github: String,
427+ /// Mastodon handle.
428+ #[serde(default)]
429+ social_mastodon: String,
430+ /// CSRF token (`_csrf`).
431+ #[serde(rename = "_csrf")]
432+ csrf: String,
433+}
434+
435+/// `POST /settings` — persist the viewer's profile fields.
436+pub async fn settings_submit(
437+ State(state): State<AppState>,
438+ RequireUser(user): RequireUser,
439+ jar: CookieJar,
440+ headers: HeaderMap,
441+ Form(form): Form<SettingsForm>,
442+) -> Response {
443+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
444+ return AppError::Forbidden.into_response();
445+ }
446+ let update = store::ProfileUpdate {
447+ display_name: norm(&form.display_name),
448+ pronouns: norm(&form.pronouns),
449+ bio: norm(&form.bio),
450+ website: norm(&form.website),
451+ location: norm(&form.location),
452+ social_github: norm(&form.social_github).map(|s| s.trim_start_matches('@').to_string()),
453+ social_mastodon: norm(&form.social_mastodon),
454+ };
455+ match state.store.update_profile(&user.id, update).await {
456+ Ok(_) => Redirect::to("/settings").into_response(),
457+ Err(err) => AppError::from(err).into_response(),
458+ }
459+}
460+
461+/// Trim a form string, mapping the empty result to `None` (clears the column).
462+fn norm(s: &str) -> Option<String> {
463+ let t = s.trim();
464+ (!t.is_empty()).then(|| t.to_string())
465+}
466+
467+/// Render the settings page: avatar, profile fields, and read-only account info.
468+fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
469+ let val = |v: &Option<String>| v.clone().unwrap_or_default();
470+ html! {
402471 h1 { "Settings" }
403- div class="card" {
404- table class="list" {
405- tr { th { "Username" } td { (user.username) } }
406- tr { th { "Email" } td { (user.email) } }
407- tr { th { "Display name" } td { (user.display_name.clone().unwrap_or_default()) } }
408- tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }
472+ @if let Some(msg) = notice {
473+ div class="notice notice-success" { (msg) }
474+ }
475+ section class="listing" {
476+ h2 { "Avatar" }
477+ div class="card avatar-settings" {
478+ img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar";
479+ form method="post" action="/settings/avatar" enctype="multipart/form-data" {
480+ input type="hidden" name="_csrf" value=(csrf);
481+ p class="muted" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." }
482+ input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp";
483+ button class="btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } }
484+ }
409485 }
410486 }
411- p class="muted" { "Key and token management arrive with the settings pages." }
412- };
413- (jar, page(&chrome, "Settings", body)).into_response()
487+ section class="listing" {
488+ h2 { "Profile" }
489+ div class="card" {
490+ form method="post" action="/settings" {
491+ input type="hidden" name="_csrf" value=(csrf);
492+ label for="display_name" { "Display name" }
493+ input type="text" id="display_name" name="display_name"
494+ value=(val(&user.display_name)) autocomplete="name";
495+ label for="pronouns" { "Pronouns" }
496+ input type="text" id="pronouns" name="pronouns"
497+ value=(val(&user.pronouns)) placeholder="they/them";
498+ label for="bio" { "Bio" }
499+ textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }
500+ label for="location" { "Location" }
501+ input type="text" id="location" name="location" value=(val(&user.location));
502+ label for="website" { "Website" }
503+ input type="text" id="website" name="website"
504+ value=(val(&user.website)) placeholder="https://example.com";
505+ label for="social_github" { "GitHub" }
506+ input type="text" id="social_github" name="social_github"
507+ value=(val(&user.social_github)) placeholder="username";
508+ label for="social_mastodon" { "Mastodon" }
509+ input type="text" id="social_mastodon" name="social_mastodon"
510+ value=(val(&user.social_mastodon)) placeholder="@user@instance";
511+ button class="btn btn-primary" type="submit" { "Save profile" }
512+ }
513+ }
514+ }
515+ section class="listing" {
516+ h2 { "Account" }
517+ div class="card" {
518+ table class="list" {
519+ tr { th { "Username" } td { (user.username) } }
520+ tr { th { "Email" } td { (user.email) } }
521+ tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }
522+ }
523+ }
524+ }
525+ }
414526 }
415527
416528 /// Theme-switch query.
crates/web/src/repo.rs +108 −7
@@ -155,13 +155,19 @@ pub async fn user_page(
155155
156156 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
157157 let body = html! {
158- h1 { (owner.username) }
158+ (profile_header(&owner))
159159 @if !groups.is_empty() {
160- section {
160+ section class="listing" {
161161 h2 { "Groups" }
162- ul class="repo-list" {
162+ ul class="repo-list card" {
163163 @for group in &groups {
164- li { a href={ "/" (owner.username) "/" (group.path) } { (group.path) } }
164+ li {
165+ a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
166+ span class="repo-row-name" {
167+ (icon(Icon::Folder)) span { (group.path) }
168+ }
169+ }
170+ }
165171 }
166172 }
167173 }
@@ -445,7 +451,9 @@ fn render_blob(
445451 div class="blob-header" {
446452 span class="muted" { (blob.size) " bytes" }
447453 span { a class="btn" href=(raw_url) { "Raw" }
448- button class="btn" type="button" data-clipboard=(raw_url) { "Copy path" } }
454+ button class="btn" type="button" data-clipboard=(raw_url) {
455+ (icon(Icon::Copy)) span { "Copy path" }
456+ } }
449457 }
450458 @if is_image(filename) {
451459 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
@@ -1038,7 +1046,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
10381046 }
10391047 span class="repo-row-meta muted" {
10401048 (if e.private { "private" } else { "public" })
1041- " · default: " (e.default_branch)
1049+ " · " (icon(Icon::GitBranch)) " " (e.default_branch)
10421050 }
10431051 }
10441052 }
@@ -1055,6 +1063,97 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
10551063 repo_card("Repositories", "No repositories.", &entries)
10561064 }
10571065
1066+/// The profile header: avatar, name, `User · username`, pronouns, bio, and any
1067+/// location and social links.
1068+fn profile_header(owner: &User) -> Markup {
1069+ let name = owner.display_name.as_deref().unwrap_or(&owner.username);
1070+ html! {
1071+ header class="profile-head" {
1072+ img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
1073+ div class="profile-meta" {
1074+ h1 { (name) }
1075+ p class="profile-sub muted" {
1076+ (icon(Icon::User)) span { "User · " (owner.username) }
1077+ @if let Some(pronouns) = pronouns(owner) {
1078+ span class="profile-pronouns" { (pronouns) }
1079+ }
1080+ }
1081+ @if let Some(bio) = non_empty(owner.bio.as_ref()) {
1082+ p class="profile-bio" { (bio) }
1083+ }
1084+ @let links = profile_links(owner);
1085+ @if !links.is_empty() {
1086+ ul class="profile-links muted" {
1087+ @for link in &links { (link) }
1088+ }
1089+ }
1090+ }
1091+ }
1092+ }
1093+}
1094+
1095+/// The trimmed pronouns, if set.
1096+fn pronouns(owner: &User) -> Option<&str> {
1097+ non_empty(owner.pronouns.as_ref())
1098+}
1099+
1100+/// A field's trimmed value, or `None` when empty.
1101+fn non_empty(field: Option<&String>) -> Option<&str> {
1102+ field.map(|s| s.trim()).filter(|s| !s.is_empty())
1103+}
1104+
1105+/// The profile's location and social links, each an icon-prefixed row.
1106+fn profile_links(owner: &User) -> Vec<Markup> {
1107+ let mut out = Vec::new();
1108+ if let Some(location) = non_empty(owner.location.as_ref()) {
1109+ out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
1110+ }
1111+ if let Some(site) = non_empty(owner.website.as_ref()) {
1112+ let href = if site.starts_with("http://") || site.starts_with("https://") {
1113+ site.to_string()
1114+ } else {
1115+ format!("https://{site}")
1116+ };
1117+ let shown = site
1118+ .trim_start_matches("https://")
1119+ .trim_start_matches("http://");
1120+ out.push(html! {
1121+ li { a href=(href) rel="nofollow noopener" { (icon(Icon::Globe)) span { (shown) } } }
1122+ });
1123+ }
1124+ if let Some(handle) = non_empty(owner.social_github.as_ref()) {
1125+ let handle = handle.trim_start_matches('@');
1126+ out.push(html! {
1127+ li {
1128+ a href=(format!("https://github.com/{handle}")) rel="nofollow noopener" {
1129+ (icon(Icon::Github)) span { (handle) }
1130+ }
1131+ }
1132+ });
1133+ }
1134+ if let Some(handle) = non_empty(owner.social_mastodon.as_ref()) {
1135+ out.push(html! { li { (mastodon_link(handle)) } });
1136+ }
1137+ out
1138+}
1139+
1140+/// Render a Mastodon `@user@instance` handle as a link when well-formed, else as
1141+/// plain text.
1142+fn mastodon_link(handle: &str) -> Markup {
1143+ let trimmed = handle.trim_start_matches('@');
1144+ if let Some((user, instance)) = trimmed.split_once('@')
1145+ && !user.is_empty()
1146+ && !instance.is_empty()
1147+ {
1148+ return html! {
1149+ a href=(format!("https://{instance}/@{user}")) rel="nofollow noopener me" {
1150+ (icon(Icon::Mastodon)) span { "@" (user) "@" (instance) }
1151+ }
1152+ };
1153+ }
1154+ html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
1155+}
1156+
10581157 /// The repo sub-header: slug, tabs, and clone URLs.
10591158 fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
10601159 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
@@ -1085,7 +1184,9 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
10851184 div class="clone-row" {
10861185 input type="text" readonly value=(clone_https(state, ctx))
10871186 aria-label="HTTPS clone URL";
1088- button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) { "Copy" }
1187+ button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1188+ (icon(Icon::Copy)) span { "Copy" }
1189+ }
10891190 }
10901191 }
10911192 }