fabrica

hanna/fabrica

feat(web): arbitrary profile links; fix markdown-preview header seam

4292e09 · hanna committed on 2026-07-25

Replace the fixed website/GitHub/Mastodon profile fields with up to five
free-form links (migration 0003 adds a newline-separated `links` column;
the old columns are left unused). Brand icons are inferred from each link's
host (GitHub, Mastodon, else a globe), and the settings page offers five URL
slots. Also fix the markdown blob preview so it continues the blob-header
box instead of rendering as a detached card with a seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +136 −82UnifiedSplit
assets/base.css +16 −0
@@ -296,6 +296,13 @@ select:focus {
296.card form button[type="submit"] {296.card form button[type="submit"] {
297 margin-top: 1.1rem;297 margin-top: 1.1rem;
298}298}
299.field-hint {
300 margin: 0.25rem 0 0.5rem;
301 font-size: 0.85em;
302}
303.link-slot {
304 margin-bottom: 0.5rem;
305}
299306
300/* Centered auth card (sign in, invite activation). */307/* Centered auth card (sign in, invite activation). */
301.auth-wrap {308.auth-wrap {
@@ -1106,6 +1113,15 @@ pre.code {
1106 color: var(--fb-accent);1113 color: var(--fb-accent);
1107}1114}
11081115
1116/* Rendered markdown preview continues the blob-header box (no seam). */
1117.blob-preview {
1118 padding: 1.25rem 1.5rem;
1119 background: var(--fb-bg-raised);
1120 border: 1px solid var(--fb-border);
1121 border-top: none;
1122 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
1123}
1124
1109table.blob {1125table.blob {
1110 width: 100%;1126 width: 100%;
1111 border-collapse: collapse;1127 border-collapse: collapse;
crates/model/src/entity.rs +3 −6
@@ -69,14 +69,11 @@ pub struct User {
69 pub pronouns: Option<String>,69 pub pronouns: Option<String>,
70 /// Optional free-text bio shown on the profile.70 /// Optional free-text bio shown on the profile.
71 pub bio: Option<String>,71 pub bio: Option<String>,
72 /// Optional personal website URL.
73 pub website: Option<String>,
74 /// Optional location string.72 /// Optional location string.
75 pub location: Option<String>,73 pub location: Option<String>,
76 /// Optional `GitHub` handle (username only, no `@`).74 /// Up to five arbitrary profile links (their brand icons are inferred from
77 pub social_github: Option<String>,75 /// the URL host).
78 /// Optional Mastodon handle (e.g. `@user@instance`).76 pub links: Vec<String>,
79 pub social_mastodon: Option<String>,
80 /// Content type of the uploaded avatar, or `None` to use a generated77 /// Content type of the uploaded avatar, or `None` to use a generated
81 /// identicon. The bytes live on disk, not in this row.78 /// identicon. The bytes live on disk, not in this row.
82 pub avatar_mime: Option<String>,79 pub avatar_mime: Option<String>,
crates/store/src/tests.rs +6 −4
@@ -103,10 +103,11 @@ async fn update_profile_and_avatar_round_trip() {
103 display_name: Some("Professor".to_string()),103 display_name: Some("Professor".to_string()),
104 pronouns: Some("they/them".to_string()),104 pronouns: Some("they/them".to_string()),
105 bio: Some("Hello there.".to_string()),105 bio: Some("Hello there.".to_string()),
106 website: Some("https://example.com".to_string()),
107 location: Some("Everywhere".to_string()),106 location: Some("Everywhere".to_string()),
108 social_github: Some("prof".to_string()),107 links: vec![
109 social_mastodon: Some("@prof@example.social".to_string()),108 "https://example.com".to_string(),
109 "https://github.com/prof".to_string(),
110 ],
110 },111 },
111 )112 )
112 .await113 .await
@@ -116,7 +117,8 @@ async fn update_profile_and_avatar_round_trip() {
116 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();117 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
117 assert_eq!(got.pronouns.as_deref(), Some("they/them"));118 assert_eq!(got.pronouns.as_deref(), Some("they/them"));
118 assert_eq!(got.bio.as_deref(), Some("Hello there."));119 assert_eq!(got.bio.as_deref(), Some("Hello there."));
119 assert_eq!(got.social_mastodon.as_deref(), Some("@prof@example.social"));120 assert_eq!(got.links.len(), 2, "two links stored");
121 assert_eq!(got.links[1], "https://github.com/prof");
120 assert!(got.avatar_mime.is_none(), "no avatar yet");122 assert!(got.avatar_mime.is_none(), "no avatar yet");
121123
122 // Setting then clearing the avatar content type round-trips.124 // Setting then clearing the avatar content type round-trips.
crates/store/src/users.rs +34 −18
@@ -13,7 +13,7 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
13/// The columns of `users` in a fixed order, shared by every `SELECT` so the13/// The columns of `users` in a fixed order, shared by every `SELECT` so the
14/// row-mapping in [`Store::map_user`] can rely on names.14/// row-mapping in [`Store::map_user`] can rely on names.
15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \
16 website, location, social_github, social_mastodon, avatar_mime, password_hash, \16 location, links, avatar_mime, password_hash, \
17 must_change_password, is_admin, disabled_at, created_at, updated_at";17 must_change_password, is_admin, disabled_at, created_at, updated_at";
1818
19/// The editable profile fields, set together by the settings page. Each `None`19/// The editable profile fields, set together by the settings page. Each `None`
@@ -26,14 +26,36 @@ pub struct ProfileUpdate {
26 pub pronouns: Option<String>,26 pub pronouns: Option<String>,
27 /// Free-text bio.27 /// Free-text bio.
28 pub bio: Option<String>,28 pub bio: Option<String>,
29 /// Website URL.
30 pub website: Option<String>,
31 /// Location.29 /// Location.
32 pub location: Option<String>,30 pub location: Option<String>,
33 /// `GitHub` handle.31 /// Up to five arbitrary profile links.
34 pub social_github: Option<String>,32 pub links: Vec<String>,
35 /// Mastodon handle.33}
36 pub social_mastodon: Option<String>,34
35/// Encode profile links for storage: trimmed, non-empty, capped at five, joined
36/// by newlines, or `None` when the list is empty.
37fn encode_links(links: &[String]) -> Option<String> {
38 let joined = links
39 .iter()
40 .map(|s| s.trim())
41 .filter(|s| !s.is_empty())
42 .take(5)
43 .collect::<Vec<_>>()
44 .join("\n");
45 (!joined.is_empty()).then_some(joined)
46}
47
48/// Decode the stored newline-separated links column into a bounded list.
49fn decode_links(raw: Option<&str>) -> Vec<String> {
50 raw.map(|s| {
51 s.lines()
52 .map(str::trim)
53 .filter(|l| !l.is_empty())
54 .take(5)
55 .map(str::to_string)
56 .collect()
57 })
58 .unwrap_or_default()
37}59}
3860
39/// Fields needed to create a user. The server fills in the id, timestamps, and61/// Fields needed to create a user. The server fills in the id, timestamps, and
@@ -75,10 +97,8 @@ impl Store {
75 display_name: new.display_name,97 display_name: new.display_name,
76 pronouns: None,98 pronouns: None,
77 bio: None,99 bio: None,
78 website: None,
79 location: None,100 location: None,
80 social_github: None,101 links: Vec::new(),
81 social_mastodon: None,
82 avatar_mime: None,102 avatar_mime: None,
83 password_hash: new.password_hash,103 password_hash: new.password_hash,
84 must_change_password: new.must_change_password,104 must_change_password: new.must_change_password,
@@ -260,16 +280,14 @@ impl Store {
260 user_id: &str,280 user_id: &str,
261 profile: ProfileUpdate,281 profile: ProfileUpdate,
262 ) -> Result<bool, StoreError> {282 ) -> Result<bool, StoreError> {
263 let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, website = $4, \283 let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, \
264 location = $5, social_github = $6, social_mastodon = $7, updated_at = $8 WHERE id = $9";284 location = $4, links = $5, updated_at = $6 WHERE id = $7";
265 let updated = sqlx::query(sql)285 let updated = sqlx::query(sql)
266 .bind(profile.display_name)286 .bind(profile.display_name)
267 .bind(profile.pronouns)287 .bind(profile.pronouns)
268 .bind(profile.bio)288 .bind(profile.bio)
269 .bind(profile.website)
270 .bind(profile.location)289 .bind(profile.location)
271 .bind(profile.social_github)290 .bind(encode_links(&profile.links))
272 .bind(profile.social_mastodon)
273 .bind(now_ms())291 .bind(now_ms())
274 .bind(user_id)292 .bind(user_id)
275 .execute(&self.pool)293 .execute(&self.pool)
@@ -311,10 +329,8 @@ impl Store {
311 display_name: row.try_get("display_name")?,329 display_name: row.try_get("display_name")?,
312 pronouns: row.try_get("pronouns")?,330 pronouns: row.try_get("pronouns")?,
313 bio: row.try_get("bio")?,331 bio: row.try_get("bio")?,
314 website: row.try_get("website")?,
315 location: row.try_get("location")?,332 location: row.try_get("location")?,
316 social_github: row.try_get("social_github")?,333 links: decode_links(row.try_get::<Option<String>, _>("links")?.as_deref()),
317 social_mastodon: row.try_get("social_mastodon")?,
318 avatar_mime: row.try_get("avatar_mime")?,334 avatar_mime: row.try_get("avatar_mime")?,
319 password_hash: row.try_get("password_hash")?,335 password_hash: row.try_get("password_hash")?,
320 must_change_password: self.get_bool(row, "must_change_password")?,336 must_change_password: self.get_bool(row, "must_change_password")?,
crates/web/src/pages.rs +33 −19
@@ -529,23 +529,29 @@ pub struct SettingsForm {
529 /// Free-text bio.529 /// Free-text bio.
530 #[serde(default)]530 #[serde(default)]
531 bio: String,531 bio: String,
532 /// Website URL.
533 #[serde(default)]
534 website: String,
535 /// Location.532 /// Location.
536 #[serde(default)]533 #[serde(default)]
537 location: String,534 location: String,
538 /// `GitHub` handle.535 /// Up to five arbitrary profile links (`serde_urlencoded` has no sequence
536 /// support, so each slot is its own field).
537 #[serde(default)]
538 link1: String,
539 #[serde(default)]
540 link2: String,
539 #[serde(default)]541 #[serde(default)]
540 social_github: String,542 link3: String,
541 /// Mastodon handle.
542 #[serde(default)]543 #[serde(default)]
543 social_mastodon: String,544 link4: String,
545 #[serde(default)]
546 link5: String,
544 /// CSRF token (`_csrf`).547 /// CSRF token (`_csrf`).
545 #[serde(rename = "_csrf")]548 #[serde(rename = "_csrf")]
546 csrf: String,549 csrf: String,
547}550}
548551
552/// The number of profile-link slots offered on the settings page.
553const LINK_SLOTS: usize = 5;
554
549/// `POST /settings` — persist the viewer's profile fields.555/// `POST /settings` — persist the viewer's profile fields.
550pub async fn settings_submit(556pub async fn settings_submit(
551 State(state): State<AppState>,557 State(state): State<AppState>,
@@ -557,14 +563,24 @@ pub async fn settings_submit(
557 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {563 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
558 return AppError::Forbidden.into_response();564 return AppError::Forbidden.into_response();
559 }565 }
566 let links: Vec<String> = [
567 &form.link1,
568 &form.link2,
569 &form.link3,
570 &form.link4,
571 &form.link5,
572 ]
573 .iter()
574 .map(|s| s.trim().to_string())
575 .filter(|s| !s.is_empty())
576 .take(LINK_SLOTS)
577 .collect();
560 let update = store::ProfileUpdate {578 let update = store::ProfileUpdate {
561 display_name: norm(&form.display_name),579 display_name: norm(&form.display_name),
562 pronouns: norm(&form.pronouns),580 pronouns: norm(&form.pronouns),
563 bio: norm(&form.bio),581 bio: norm(&form.bio),
564 website: norm(&form.website),
565 location: norm(&form.location),582 location: norm(&form.location),
566 social_github: norm(&form.social_github).map(|s| s.trim_start_matches('@').to_string()),583 links,
567 social_mastodon: norm(&form.social_mastodon),
568 };584 };
569 match state.store.update_profile(&user.id, update).await {585 match state.store.update_profile(&user.id, update).await {
570 Ok(_) => Redirect::to("/settings").into_response(),586 Ok(_) => Redirect::to("/settings").into_response(),
@@ -613,15 +629,13 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
613 textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }629 textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }
614 label for="location" { "Location" }630 label for="location" { "Location" }
615 input type="text" id="location" name="location" value=(val(&user.location));631 input type="text" id="location" name="location" value=(val(&user.location));
616 label for="website" { "Website" }632 label for="link1" { "Links" }
617 input type="text" id="website" name="website"633 p class="muted field-hint" { "Up to five links (website, GitHub, Mastodon, …); icons are chosen from the address." }
618 value=(val(&user.website)) placeholder="https://example.com";634 @for i in 0..LINK_SLOTS {
619 label for="social_github" { "GitHub" }635 input type="url" id=(format!("link{}", i + 1)) name=(format!("link{}", i + 1))
620 input type="text" id="social_github" name="social_github"636 class="link-slot" value=(user.links.get(i).map_or("", String::as_str))
621 value=(val(&user.social_github)) placeholder="username";637 placeholder="https://example.com";
622 label for="social_mastodon" { "Mastodon" }638 }
623 input type="text" id="social_mastodon" name="social_mastodon"
624 value=(val(&user.social_mastodon)) placeholder="@user@instance";
625 button class="btn btn-primary" type="submit" { "Save profile" }639 button class="btn btn-primary" type="submit" { "Save profile" }
626 }640 }
627 }641 }
crates/web/src/repo.rs +35 −35
@@ -531,7 +531,7 @@ fn render_blob(
531 p { a class="btn" href=(raw_url) { "Download" } }531 p { a class="btn" href=(raw_url) { "Download" } }
532 }532 }
533 } @else if show_preview {533 } @else if show_preview {
534 div class="card markdown" {534 div class="blob-preview markdown" {
535 (markdown::render(&String::from_utf8_lossy(&blob.content)))535 (markdown::render(&String::from_utf8_lossy(&blob.content)))
536 }536 }
537 } @else {537 } @else {
@@ -1250,56 +1250,56 @@ fn non_empty(field: Option<&String>) -> Option<&str> {
1250 field.map(|s| s.trim()).filter(|s| !s.is_empty())1250 field.map(|s| s.trim()).filter(|s| !s.is_empty())
1251}1251}
12521252
1253/// The profile's location and social links, each an icon-prefixed row.1253/// The profile's location and its arbitrary links, each an icon-prefixed row.
1254/// The brand icon is inferred from the link's host.
1254fn profile_links(owner: &User) -> Vec<Markup> {1255fn profile_links(owner: &User) -> Vec<Markup> {
1255 let mut out = Vec::new();1256 let mut out = Vec::new();
1256 if let Some(location) = non_empty(owner.location.as_ref()) {1257 if let Some(location) = non_empty(owner.location.as_ref()) {
1257 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });1258 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
1258 }1259 }
1259 if let Some(site) = non_empty(owner.website.as_ref()) {1260 for link in &owner.links {
1260 let href = if site.starts_with("http://") || site.starts_with("https://") {1261 let href = normalize_url(link);
1261 site.to_string()1262 let shown = href
1262 } else {
1263 format!("https://{site}")
1264 };
1265 let shown = site
1266 .trim_start_matches("https://")1263 .trim_start_matches("https://")
1267 .trim_start_matches("http://");1264 .trim_start_matches("http://")
1268 out.push(html! {1265 .trim_end_matches('/');
1269 li { a href=(href) rel="nofollow noopener" { (icon(Icon::Globe)) span { (shown) } } }
1270 });
1271 }
1272 if let Some(handle) = non_empty(owner.social_github.as_ref()) {
1273 let handle = handle.trim_start_matches('@');
1274 out.push(html! {1266 out.push(html! {
1275 li {1267 li {
1276 a href=(format!("https://github.com/{handle}")) rel="nofollow noopener" {1268 a href=(href) rel="nofollow noopener me" {
1277 (icon(Icon::Github)) span { (handle) }1269 (icon(link_icon(&href))) span { (shown) }
1278 }1270 }
1279 }1271 }
1280 });1272 });
1281 }1273 }
1282 if let Some(handle) = non_empty(owner.social_mastodon.as_ref()) {
1283 out.push(html! { li { (mastodon_link(handle)) } });
1284 }
1285 out1274 out
1286}1275}
12871276
1288/// Render a Mastodon `@user@instance` handle as a link when well-formed, else as1277/// Prefix a bare URL with `https://` when it has no scheme.
1289/// plain text.1278fn normalize_url(url: &str) -> String {
1290fn mastodon_link(handle: &str) -> Markup {1279 let url = url.trim();
1291 let trimmed = handle.trim_start_matches('@');1280 if url.starts_with("http://") || url.starts_with("https://") {
1292 if let Some((user, instance)) = trimmed.split_once('@')1281 url.to_string()
1293 && !user.is_empty()1282 } else {
1294 && !instance.is_empty()1283 format!("https://{url}")
1295 {1284 }
1296 return html! {1285}
1297 a href=(format!("https://{instance}/@{user}")) rel="nofollow noopener me" {1286
1298 (icon(Icon::Mastodon)) span { "@" (user) "@" (instance) }1287/// Choose a brand icon for a link from its host.
1299 }1288fn link_icon(url: &str) -> Icon {
1300 };1289 let host = url
1290 .trim_start_matches("https://")
1291 .trim_start_matches("http://")
1292 .split('/')
1293 .next()
1294 .unwrap_or("")
1295 .to_ascii_lowercase();
1296 if host == "github.com" || host.ends_with(".github.com") {
1297 Icon::Github
1298 } else if host.contains("mastodon") {
1299 Icon::Mastodon
1300 } else {
1301 Icon::Globe
1301 }1302 }
1302 html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
1303}1303}
13041304
1305/// The repo sub-header: slug, description, and a tab strip sharing its line with1305/// The repo sub-header: slug, description, and a tab strip sharing its line with
migrations/postgres/0003_user_links.sql +4 −0
@@ -0,0 +1,4 @@
1-- Replace the fixed website/social handle fields with a free list of up to five
2-- arbitrary profile links, stored newline-separated. The older social_* and
3-- website columns are left in place but are no longer read or written.
4ALTER TABLE users ADD COLUMN links TEXT;
migrations/sqlite/0003_user_links.sql +5 −0
@@ -0,0 +1,5 @@
1-- Replace the fixed website/social handle fields with a free list of up to five
2-- arbitrary profile links, stored newline-separated. The older social_* and
3-- website columns are left in place (SQLite cannot easily drop columns) but are
4-- no longer read or written.
5ALTER TABLE users ADD COLUMN links TEXT;