fabrica

hanna/fabrica

fix(web): avatar collaborator rows and tighter card top padding

52fd809 · hanna committed on 2026-07-26

Redesign collaborator rows as [avatar] [name] [role] with the remove button on
the right, sharing one `collaborators_card` helper between the repo and group
settings pages. Drop the doubled top padding on the first collaborator row and
on the first label of a card form so cards no longer open with an oversized gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
24 files changed · +77 −79UnifiedSplit
assets/base.css +31 −1
@@ -742,7 +742,8 @@ body.drawer-open .drawer-backdrop {
742 margin-bottom: 0;742 margin-bottom: 0;
743}743}
744744
745/* Collaborators: a bordered list inside the card, then an add row. */745/* Collaborators: a bordered list inside the card, then an add row. Each row is
746 [avatar] [name] [role] on the left, remove button on the right. */
746.collab-list {747.collab-list {
747 margin-bottom: 1rem;748 margin-bottom: 1rem;
748}749}
@@ -750,6 +751,28 @@ body.drawer-open .drawer-backdrop {
750 padding-left: 0;751 padding-left: 0;
751 padding-right: 0;752 padding-right: 0;
752}753}
754/* The first row abuts the card padding; drop its own top padding so the list
755 doesn't sit with a doubled gap at the top of the card. */
756.collab-list .entry-row:first-child {
757 padding-top: 0;
758}
759.collab-ident {
760 display: flex;
761 align-items: center;
762 gap: 0.6rem;
763 min-width: 0;
764}
765.collab-ident .entry-row-title {
766 display: inline;
767}
768.collab-perm {
769 font-size: 0.8em;
770 color: var(--fb-fg-muted);
771 border: 1px solid var(--fb-border);
772 border-radius: 999px;
773 padding: 0.05rem 0.55rem;
774 text-transform: capitalize;
775}
753.collab-list .entry-row-actions .btn {776.collab-list .entry-row-actions .btn {
754 padding: 0.25rem 0.6rem;777 padding: 0.25rem 0.6rem;
755 font-size: 0.85em;778 font-size: 0.85em;
@@ -1192,6 +1215,13 @@ label {
1192 margin: 0.75rem 0 0.25rem;1215 margin: 0.75rem 0 0.25rem;
1193 font-weight: 600;1216 font-weight: 600;
1194}1217}
1218/* The first label in a card form abuts the card padding — drop its top margin so
1219 the form doesn't sit with a doubled gap at the top. The CSRF hidden input is
1220 usually the real first child, so also match the label right after it. */
1221.card form > label:first-child,
1222.card form > input[type="hidden"]:first-child + label {
1223 margin-top: 0;
1224}
1195input[type="text"],1225input[type="text"],
1196input[type="password"],1226input[type="password"],
1197input[type="email"],1227input[type="email"],
crates/web/src/groups.rs +5 −49
@@ -287,7 +287,11 @@ pub async fn settings_view(
287 }287 }
288 }288 }
289 }289 }
290 (group_collaborators_section(gid, &chrome.csrf, &collaborators))290 section class="listing" {
291 h2 { "Collaborators" }
292 p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
293 (crate::repo::collaborators_card(&format!("/group-settings/{gid}"), &chrome.csrf, &collaborators))
294 }
291 section class="listing" {295 section class="listing" {
292 h2 { "Danger zone" }296 h2 { "Danger zone" }
293 div class="card danger-zone" {297 div class="card danger-zone" {
@@ -312,54 +316,6 @@ pub async fn settings_view(
312 result.unwrap_or_else(IntoResponse::into_response)316 result.unwrap_or_else(IntoResponse::into_response)
313}317}
314318
315/// The group collaborators section: current members with remove buttons, add form.
316fn group_collaborators_section(
317 id: &str,
318 csrf: &str,
319 collaborators: &[store::Collaborator],
320) -> Markup {
321 let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
322 html! {
323 section class="listing" {
324 h2 { "Collaborators" }
325 p class="muted field-hint" { "Group collaborators gain the same access to every repository within this group." }
326 div class="card" {
327 @if collaborators.is_empty() {
328 p class="muted" { "No collaborators yet." }
329 } @else {
330 ul class="entry-list collab-list" {
331 @for c in collaborators {
332 li class="entry-row" {
333 div class="entry-row-body" {
334 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
335 div class="entry-row-meta muted" { (c.permission) }
336 }
337 div class="entry-row-actions" {
338 form method="post" action=(format!("/group-settings/{id}/collaborators/remove")) {
339 input type="hidden" name="_csrf" value=(csrf);
340 input type="hidden" name="user_id" value=(c.user_id);
341 button class="btn btn-danger" type="submit" { "Remove" }
342 }
343 }
344 }
345 }
346 }
347 }
348 form class="collab-add" method="post" action=(format!("/group-settings/{id}/collaborators")) {
349 input type="hidden" name="_csrf" value=(csrf);
350 input type="text" name="username" placeholder="Username" aria-label="Username" required;
351 select name="permission" aria-label="Permission" {
352 (perm_option("read", "Read"))
353 (perm_option("write", "Write"))
354 (perm_option("admin", "Admin"))
355 }
356 button class="btn btn-primary inline-btn" type="submit" { "Add" }
357 }
358 }
359 }
360 }
361}
362
363/// The group general-settings form (rename + visibility).319/// The group general-settings form (rename + visibility).
364#[derive(Debug, Deserialize)]320#[derive(Debug, Deserialize)]
365pub struct GroupGeneralForm {321pub struct GroupGeneralForm {
crates/web/src/repo.rs +41 −29
@@ -1504,44 +1504,56 @@ fn fmt_duration(secs: i64) -> String {
1504/// The Collaborators section of the repo settings page: the current list with1504/// The Collaborators section of the repo settings page: the current list with
1505/// remove buttons, and the add form.1505/// remove buttons, and the add form.
1506fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {1506fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
1507 let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
1508 html! {1507 html! {
1509 section class="listing" {1508 section class="listing" {
1510 h2 { "Collaborators" }1509 h2 { "Collaborators" }
1511 div class="card" {1510 (collaborators_card(&format!("/repo-settings/{id}"), csrf, collaborators))
1512 @if collaborators.is_empty() {1511 }
1513 p class="muted" { "No collaborators yet." }1512 }
1514 } @else {1513}
1515 ul class="entry-list collab-list" {1514
1516 @for c in collaborators {1515/// The collaborators card — an avatar/name/role list with remove buttons, then
1517 li class="entry-row" {1516/// an add row. Shared by the repo and group settings pages; `base` is the action
1518 div class="entry-row-body" {1517/// URL prefix (`/repo-settings/{id}` or `/group-settings/{id}`).
1519 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }1518pub(crate) fn collaborators_card(
1520 div class="entry-row-meta muted" { (c.permission) }1519 base: &str,
1521 }1520 csrf: &str,
1522 div class="entry-row-actions" {1521 collaborators: &[store::Collaborator],
1523 form method="post"1522) -> Markup {
1524 action=(format!("/repo-settings/{id}/collaborators/remove")) {1523 let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
1525 input type="hidden" name="_csrf" value=(csrf);1524 html! {
1526 input type="hidden" name="user_id" value=(c.user_id);1525 div class="card" {
1527 button class="btn btn-danger" type="submit" { "Remove" }1526 @if collaborators.is_empty() {
1528 }1527 p class="muted" { "No collaborators yet." }
1528 } @else {
1529 ul class="entry-list collab-list" {
1530 @for c in collaborators {
1531 li class="entry-row" {
1532 div class="collab-ident" {
1533 img class="avatar avatar-sm" src=(format!("/avatar/{}", c.username)) alt="";
1534 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
1535 span class="collab-perm" { (c.permission) }
1536 }
1537 div class="entry-row-actions" {
1538 form method="post" action=(format!("{base}/collaborators/remove")) {
1539 input type="hidden" name="_csrf" value=(csrf);
1540 input type="hidden" name="user_id" value=(c.user_id);
1541 button class="btn btn-danger" type="submit" { "Remove" }
1529 }1542 }
1530 }1543 }
1531 }1544 }
1532 }1545 }
1533 }1546 }
1534 form class="collab-add" method="post"1547 }
1535 action=(format!("/repo-settings/{id}/collaborators")) {1548 form class="collab-add" method="post" action=(format!("{base}/collaborators")) {
1536 input type="hidden" name="_csrf" value=(csrf);1549 input type="hidden" name="_csrf" value=(csrf);
1537 input type="text" name="username" placeholder="Username" aria-label="Username" required;1550 input type="text" name="username" placeholder="Username" aria-label="Username" required;
1538 select name="permission" aria-label="Permission" {1551 select name="permission" aria-label="Permission" {
1539 (perm_option("read", "Read"))1552 (perm_option("read", "Read"))
1540 (perm_option("write", "Write"))1553 (perm_option("write", "Write"))
1541 (perm_option("admin", "Admin"))1554 (perm_option("admin", "Admin"))
1542 }
1543 button class="btn btn-primary inline-btn" type="submit" { "Add" }
1544 }1555 }
1556 button class="btn btn-primary inline-btn" type="submit" { "Add" }
1545 }1557 }
1546 }1558 }
1547 }1559 }
data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydt075vpteqnjzyms8v3vtm-1785061358405.git/info/exclude +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydta3a2b6gmfh0c4x7tx4b0-1785061363714.git/info/exclude +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/HEAD +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/config +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/description +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/README.sample +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/post-receive +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/hooks/pre-receive +0 −0

No textual changes.

data/fabrica/trash/01kydtah34meqbxcfatt6m90p3-1785061376374.git/info/exclude +0 −0

No textual changes.