Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +16 −0
| @@ -296,6 +296,13 @@ select:focus { | |||
| 296 | 296 | .card form button[type="submit"] { | |
| 297 | 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 | + | } | |
| 299 | 306 | ||
| 300 | 307 | /* Centered auth card (sign in, invite activation). */ | |
| 301 | 308 | .auth-wrap { | |
| @@ -1106,6 +1113,15 @@ pre.code { | |||
| 1106 | 1113 | color: var(--fb-accent); | |
| 1107 | 1114 | } | |
| 1108 | 1115 | ||
| 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 | + | ||
| 1109 | 1125 | table.blob { | |
| 1110 | 1126 | width: 100%; | |
| 1111 | 1127 | border-collapse: collapse; | |
crates/model/src/entity.rs +3 −6
| @@ -69,14 +69,11 @@ pub struct User { | |||
| 69 | 69 | pub pronouns: Option<String>, | |
| 70 | 70 | /// Optional free-text bio shown on the profile. | |
| 71 | 71 | pub bio: Option<String>, | |
| 72 | - | /// Optional personal website URL. | |
| 73 | - | pub website: Option<String>, | |
| 74 | 72 | /// Optional location string. | |
| 75 | 73 | 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>, | |
| 80 | 77 | /// Content type of the uploaded avatar, or `None` to use a generated | |
| 81 | 78 | /// identicon. The bytes live on disk, not in this row. | |
| 82 | 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 | 103 | display_name: Some("Professor".to_string()), | |
| 104 | 104 | pronouns: Some("they/them".to_string()), | |
| 105 | 105 | bio: Some("Hello there.".to_string()), | |
| 106 | - | website: Some("https://example.com".to_string()), | |
| 107 | 106 | 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 | + | ], | |
| 110 | 111 | }, | |
| 111 | 112 | ) | |
| 112 | 113 | .await | |
| @@ -116,7 +117,8 @@ async fn update_profile_and_avatar_round_trip() { | |||
| 116 | 117 | let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap(); | |
| 117 | 118 | assert_eq!(got.pronouns.as_deref(), Some("they/them")); | |
| 118 | 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 | 122 | assert!(got.avatar_mime.is_none(), "no avatar yet"); | |
| 121 | 123 | ||
| 122 | 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 | 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | |
| 14 | 14 | /// row-mapping in [`Store::map_user`] can rely on names. | |
| 15 | 15 | 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, \ | |
| 17 | 17 | must_change_password, is_admin, disabled_at, created_at, updated_at"; | |
| 18 | 18 | ||
| 19 | 19 | /// The editable profile fields, set together by the settings page. Each `None` | |
| @@ -26,14 +26,36 @@ pub struct ProfileUpdate { | |||
| 26 | 26 | pub pronouns: Option<String>, | |
| 27 | 27 | /// Free-text bio. | |
| 28 | 28 | pub bio: Option<String>, | |
| 29 | - | /// Website URL. | |
| 30 | - | pub website: Option<String>, | |
| 31 | 29 | /// Location. | |
| 32 | 30 | 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() | |
| 37 | 59 | } | |
| 38 | 60 | ||
| 39 | 61 | /// Fields needed to create a user. The server fills in the id, timestamps, and | |
| @@ -75,10 +97,8 @@ impl Store { | |||
| 75 | 97 | display_name: new.display_name, | |
| 76 | 98 | pronouns: None, | |
| 77 | 99 | bio: None, | |
| 78 | - | website: None, | |
| 79 | 100 | location: None, | |
| 80 | - | social_github: None, | |
| 81 | - | social_mastodon: None, | |
| 101 | + | links: Vec::new(), | |
| 82 | 102 | avatar_mime: None, | |
| 83 | 103 | password_hash: new.password_hash, | |
| 84 | 104 | must_change_password: new.must_change_password, | |
| @@ -260,16 +280,14 @@ impl Store { | |||
| 260 | 280 | user_id: &str, | |
| 261 | 281 | profile: ProfileUpdate, | |
| 262 | 282 | ) -> 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"; | |
| 265 | 285 | let updated = sqlx::query(sql) | |
| 266 | 286 | .bind(profile.display_name) | |
| 267 | 287 | .bind(profile.pronouns) | |
| 268 | 288 | .bind(profile.bio) | |
| 269 | - | .bind(profile.website) | |
| 270 | 289 | .bind(profile.location) | |
| 271 | - | .bind(profile.social_github) | |
| 272 | - | .bind(profile.social_mastodon) | |
| 290 | + | .bind(encode_links(&profile.links)) | |
| 273 | 291 | .bind(now_ms()) | |
| 274 | 292 | .bind(user_id) | |
| 275 | 293 | .execute(&self.pool) | |
| @@ -311,10 +329,8 @@ impl Store { | |||
| 311 | 329 | display_name: row.try_get("display_name")?, | |
| 312 | 330 | pronouns: row.try_get("pronouns")?, | |
| 313 | 331 | bio: row.try_get("bio")?, | |
| 314 | - | website: row.try_get("website")?, | |
| 315 | 332 | 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()), | |
| 318 | 334 | avatar_mime: row.try_get("avatar_mime")?, | |
| 319 | 335 | password_hash: row.try_get("password_hash")?, | |
| 320 | 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 | 529 | /// Free-text bio. | |
| 530 | 530 | #[serde(default)] | |
| 531 | 531 | bio: String, | |
| 532 | - | /// Website URL. | |
| 533 | - | #[serde(default)] | |
| 534 | - | website: String, | |
| 535 | 532 | /// Location. | |
| 536 | 533 | #[serde(default)] | |
| 537 | 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 | 541 | #[serde(default)] | |
| 540 | - | social_github: String, | |
| 541 | - | /// Mastodon handle. | |
| 542 | + | link3: String, | |
| 542 | 543 | #[serde(default)] | |
| 543 | - | social_mastodon: String, | |
| 544 | + | link4: String, | |
| 545 | + | #[serde(default)] | |
| 546 | + | link5: String, | |
| 544 | 547 | /// CSRF token (`_csrf`). | |
| 545 | 548 | #[serde(rename = "_csrf")] | |
| 546 | 549 | csrf: String, | |
| 547 | 550 | } | |
| 548 | 551 | ||
| 552 | + | /// The number of profile-link slots offered on the settings page. | |
| 553 | + | const LINK_SLOTS: usize = 5; | |
| 554 | + | ||
| 549 | 555 | /// `POST /settings` — persist the viewer's profile fields. | |
| 550 | 556 | pub async fn settings_submit( | |
| 551 | 557 | State(state): State<AppState>, | |
| @@ -557,14 +563,24 @@ pub async fn settings_submit( | |||
| 557 | 563 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 558 | 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 | 578 | let update = store::ProfileUpdate { | |
| 561 | 579 | display_name: norm(&form.display_name), | |
| 562 | 580 | pronouns: norm(&form.pronouns), | |
| 563 | 581 | bio: norm(&form.bio), | |
| 564 | - | website: norm(&form.website), | |
| 565 | 582 | 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, | |
| 568 | 584 | }; | |
| 569 | 585 | match state.store.update_profile(&user.id, update).await { | |
| 570 | 586 | Ok(_) => Redirect::to("/settings").into_response(), | |
| @@ -613,15 +629,13 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | |||
| 613 | 629 | textarea id="bio" name="bio" rows="3" { (val(&user.bio)) } | |
| 614 | 630 | label for="location" { "Location" } | |
| 615 | 631 | 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 | + | } | |
| 625 | 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 | 531 | p { a class="btn" href=(raw_url) { "Download" } } | |
| 532 | 532 | } | |
| 533 | 533 | } @else if show_preview { | |
| 534 | - | div class="card markdown" { | |
| 534 | + | div class="blob-preview markdown" { | |
| 535 | 535 | (markdown::render(&String::from_utf8_lossy(&blob.content))) | |
| 536 | 536 | } | |
| 537 | 537 | } @else { | |
| @@ -1250,56 +1250,56 @@ fn non_empty(field: Option<&String>) -> Option<&str> { | |||
| 1250 | 1250 | field.map(|s| s.trim()).filter(|s| !s.is_empty()) | |
| 1251 | 1251 | } | |
| 1252 | 1252 | ||
| 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. | |
| 1254 | 1255 | fn profile_links(owner: &User) -> Vec<Markup> { | |
| 1255 | 1256 | let mut out = Vec::new(); | |
| 1256 | 1257 | if let Some(location) = non_empty(owner.location.as_ref()) { | |
| 1257 | 1258 | out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } }); | |
| 1258 | 1259 | } | |
| 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 | |
| 1266 | 1263 | .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('/'); | |
| 1274 | 1266 | out.push(html! { | |
| 1275 | 1267 | 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) } | |
| 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 | 1274 | out | |
| 1286 | 1275 | } | |
| 1287 | 1276 | ||
| 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 | |
| 1301 | 1302 | } | |
| 1302 | - | html! { span { (icon(Icon::Mastodon)) span { (handle) } } } | |
| 1303 | 1303 | } | |
| 1304 | 1304 | ||
| 1305 | 1305 | /// 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; | |