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 {
296296 .card form button[type="submit"] {
297297 margin-top: 1.1rem;
298298 }
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
300307 /* Centered auth card (sign in, invite activation). */
301308 .auth-wrap {
@@ -1106,6 +1113,15 @@ pre.code {
11061113 color: var(--fb-accent);
11071114 }
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+
11091125 table.blob {
11101126 width: 100%;
11111127 border-collapse: collapse;
crates/model/src/entity.rs +3 −6
@@ -69,14 +69,11 @@ pub struct User {
6969 pub pronouns: Option<String>,
7070 /// Optional free-text bio shown on the profile.
7171 pub bio: Option<String>,
72- /// Optional personal website URL.
73- pub website: Option<String>,
7472 /// Optional location string.
7573 pub location: Option<String>,
76- /// Optional `GitHub` handle (username only, no `@`).
77- pub social_github: Option<String>,
78- /// Optional Mastodon handle (e.g. `@user@instance`).
79- pub social_mastodon: Option<String>,
74+ /// Up to five arbitrary profile links (their brand icons are inferred from
75+ /// the URL host).
76+ pub links: Vec<String>,
8077 /// Content type of the uploaded avatar, or `None` to use a generated
8178 /// identicon. The bytes live on disk, not in this row.
8279 pub avatar_mime: Option<String>,
crates/store/src/tests.rs +6 −4
@@ -103,10 +103,11 @@ async fn update_profile_and_avatar_round_trip() {
103103 display_name: Some("Professor".to_string()),
104104 pronouns: Some("they/them".to_string()),
105105 bio: Some("Hello there.".to_string()),
106- website: Some("https://example.com".to_string()),
107106 location: Some("Everywhere".to_string()),
108- social_github: Some("prof".to_string()),
109- social_mastodon: Some("@prof@example.social".to_string()),
107+ links: vec![
108+ "https://example.com".to_string(),
109+ "https://github.com/prof".to_string(),
110+ ],
110111 },
111112 )
112113 .await
@@ -116,7 +117,8 @@ async fn update_profile_and_avatar_round_trip() {
116117 let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
117118 assert_eq!(got.pronouns.as_deref(), Some("they/them"));
118119 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");
120122 assert!(got.avatar_mime.is_none(), "no avatar yet");
121123
122124 // 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};
1313 /// The columns of `users` in a fixed order, shared by every `SELECT` so the
1414 /// row-mapping in [`Store::map_user`] can rely on names.
1515 pub(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, \
1717 must_change_password, is_admin, disabled_at, created_at, updated_at";
1818
1919 /// The editable profile fields, set together by the settings page. Each `None`
@@ -26,14 +26,36 @@ pub struct ProfileUpdate {
2626 pub pronouns: Option<String>,
2727 /// Free-text bio.
2828 pub bio: Option<String>,
29- /// Website URL.
30- pub website: Option<String>,
3129 /// Location.
3230 pub location: Option<String>,
33- /// `GitHub` handle.
34- pub social_github: Option<String>,
35- /// Mastodon handle.
36- pub social_mastodon: Option<String>,
31+ /// Up to five arbitrary profile links.
32+ pub links: Vec<String>,
33+}
34+
35+/// Encode profile links for storage: trimmed, non-empty, capped at five, joined
36+/// by newlines, or `None` when the list is empty.
37+fn 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.
49+fn 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()
3759 }
3860
3961 /// Fields needed to create a user. The server fills in the id, timestamps, and
@@ -75,10 +97,8 @@ impl Store {
7597 display_name: new.display_name,
7698 pronouns: None,
7799 bio: None,
78- website: None,
79100 location: None,
80- social_github: None,
81- social_mastodon: None,
101+ links: Vec::new(),
82102 avatar_mime: None,
83103 password_hash: new.password_hash,
84104 must_change_password: new.must_change_password,
@@ -260,16 +280,14 @@ impl Store {
260280 user_id: &str,
261281 profile: ProfileUpdate,
262282 ) -> Result<bool, StoreError> {
263- let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, website = $4, \
264- location = $5, social_github = $6, social_mastodon = $7, updated_at = $8 WHERE id = $9";
283+ let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, \
284+ location = $4, links = $5, updated_at = $6 WHERE id = $7";
265285 let updated = sqlx::query(sql)
266286 .bind(profile.display_name)
267287 .bind(profile.pronouns)
268288 .bind(profile.bio)
269- .bind(profile.website)
270289 .bind(profile.location)
271- .bind(profile.social_github)
272- .bind(profile.social_mastodon)
290+ .bind(encode_links(&profile.links))
273291 .bind(now_ms())
274292 .bind(user_id)
275293 .execute(&self.pool)
@@ -311,10 +329,8 @@ impl Store {
311329 display_name: row.try_get("display_name")?,
312330 pronouns: row.try_get("pronouns")?,
313331 bio: row.try_get("bio")?,
314- website: row.try_get("website")?,
315332 location: row.try_get("location")?,
316- social_github: row.try_get("social_github")?,
317- social_mastodon: row.try_get("social_mastodon")?,
333+ links: decode_links(row.try_get::<Option<String>, _>("links")?.as_deref()),
318334 avatar_mime: row.try_get("avatar_mime")?,
319335 password_hash: row.try_get("password_hash")?,
320336 must_change_password: self.get_bool(row, "must_change_password")?,
crates/web/src/pages.rs +33 −19
@@ -529,23 +529,29 @@ pub struct SettingsForm {
529529 /// Free-text bio.
530530 #[serde(default)]
531531 bio: String,
532- /// Website URL.
533- #[serde(default)]
534- website: String,
535532 /// Location.
536533 #[serde(default)]
537534 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,
539541 #[serde(default)]
540- social_github: String,
541- /// Mastodon handle.
542+ link3: String,
542543 #[serde(default)]
543- social_mastodon: String,
544+ link4: String,
545+ #[serde(default)]
546+ link5: String,
544547 /// CSRF token (`_csrf`).
545548 #[serde(rename = "_csrf")]
546549 csrf: String,
547550 }
548551
552+/// The number of profile-link slots offered on the settings page.
553+const LINK_SLOTS: usize = 5;
554+
549555 /// `POST /settings` — persist the viewer's profile fields.
550556 pub async fn settings_submit(
551557 State(state): State<AppState>,
@@ -557,14 +563,24 @@ pub async fn settings_submit(
557563 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
558564 return AppError::Forbidden.into_response();
559565 }
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();
560578 let update = store::ProfileUpdate {
561579 display_name: norm(&form.display_name),
562580 pronouns: norm(&form.pronouns),
563581 bio: norm(&form.bio),
564- website: norm(&form.website),
565582 location: norm(&form.location),
566- social_github: norm(&form.social_github).map(|s| s.trim_start_matches('@').to_string()),
567- social_mastodon: norm(&form.social_mastodon),
583+ links,
568584 };
569585 match state.store.update_profile(&user.id, update).await {
570586 Ok(_) => Redirect::to("/settings").into_response(),
@@ -613,15 +629,13 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
613629 textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }
614630 label for="location" { "Location" }
615631 input type="text" id="location" name="location" value=(val(&user.location));
616- label for="website" { "Website" }
617- input type="text" id="website" name="website"
618- value=(val(&user.website)) placeholder="https://example.com";
619- label for="social_github" { "GitHub" }
620- input type="text" id="social_github" name="social_github"
621- value=(val(&user.social_github)) placeholder="username";
622- label for="social_mastodon" { "Mastodon" }
623- input type="text" id="social_mastodon" name="social_mastodon"
624- value=(val(&user.social_mastodon)) placeholder="@user@instance";
632+ label for="link1" { "Links" }
633+ p class="muted field-hint" { "Up to five links (website, GitHub, Mastodon, …); icons are chosen from the address." }
634+ @for i in 0..LINK_SLOTS {
635+ input type="url" id=(format!("link{}", i + 1)) name=(format!("link{}", i + 1))
636+ class="link-slot" value=(user.links.get(i).map_or("", String::as_str))
637+ placeholder="https://example.com";
638+ }
625639 button class="btn btn-primary" type="submit" { "Save profile" }
626640 }
627641 }
crates/web/src/repo.rs +35 −35
@@ -531,7 +531,7 @@ fn render_blob(
531531 p { a class="btn" href=(raw_url) { "Download" } }
532532 }
533533 } @else if show_preview {
534- div class="card markdown" {
534+ div class="blob-preview markdown" {
535535 (markdown::render(&String::from_utf8_lossy(&blob.content)))
536536 }
537537 } @else {
@@ -1250,56 +1250,56 @@ fn non_empty(field: Option<&String>) -> Option<&str> {
12501250 field.map(|s| s.trim()).filter(|s| !s.is_empty())
12511251 }
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.
12541255 fn profile_links(owner: &User) -> Vec<Markup> {
12551256 let mut out = Vec::new();
12561257 if let Some(location) = non_empty(owner.location.as_ref()) {
12571258 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
12581259 }
1259- if let Some(site) = non_empty(owner.website.as_ref()) {
1260- let href = if site.starts_with("http://") || site.starts_with("https://") {
1261- site.to_string()
1262- } else {
1263- format!("https://{site}")
1264- };
1265- let shown = site
1260+ for link in &owner.links {
1261+ let href = normalize_url(link);
1262+ let shown = href
12661263 .trim_start_matches("https://")
1267- .trim_start_matches("http://");
1268- out.push(html! {
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('@');
1264+ .trim_start_matches("http://")
1265+ .trim_end_matches('/');
12741266 out.push(html! {
12751267 li {
1276- a href=(format!("https://github.com/{handle}")) rel="nofollow noopener" {
1277- (icon(Icon::Github)) span { (handle) }
1268+ a href=(href) rel="nofollow noopener me" {
1269+ (icon(link_icon(&href))) span { (shown) }
12781270 }
12791271 }
12801272 });
12811273 }
1282- if let Some(handle) = non_empty(owner.social_mastodon.as_ref()) {
1283- out.push(html! { li { (mastodon_link(handle)) } });
1284- }
12851274 out
12861275 }
12871276
1288-/// Render a Mastodon `@user@instance` handle as a link when well-formed, else as
1289-/// plain text.
1290-fn mastodon_link(handle: &str) -> Markup {
1291- let trimmed = handle.trim_start_matches('@');
1292- if let Some((user, instance)) = trimmed.split_once('@')
1293- && !user.is_empty()
1294- && !instance.is_empty()
1295- {
1296- return html! {
1297- a href=(format!("https://{instance}/@{user}")) rel="nofollow noopener me" {
1298- (icon(Icon::Mastodon)) span { "@" (user) "@" (instance) }
1299- }
1300- };
1277+/// Prefix a bare URL with `https://` when it has no scheme.
1278+fn normalize_url(url: &str) -> String {
1279+ let url = url.trim();
1280+ if url.starts_with("http://") || url.starts_with("https://") {
1281+ url.to_string()
1282+ } else {
1283+ format!("https://{url}")
1284+ }
1285+}
1286+
1287+/// Choose a brand icon for a link from its host.
1288+fn link_icon(url: &str) -> Icon {
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
13011302 }
1302- html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
13031303 }
13041304
13051305 /// 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.
4+ALTER 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.
5+ALTER TABLE users ADD COLUMN links TEXT;