fabrica

hanna/fabrica

feat(web): bookmark button, navbar icon, and profile tabs

844ac86 · hanna committed on 2026-07-26

- A Bookmark toggle in the repo header (signed-in viewers) and a
  bookmarks icon in the navbar linking to your bookmarks tab.
- The profile page is now a left sidebar plus three tabs on the right —
  Repositories, Groups, and Bookmarks. The bookmarks tab lists the
  repositories you have starred, filtered to those the viewer can read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +190 −25UnifiedSplit
assets/base.css +12 −0
@@ -2038,13 +2038,23 @@ pre.code {
2038}2038}
2039/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */2039/* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
2040.repo-tabs,2040.repo-tabs,
2041.profile-tabs,
2041.subnav {2042.subnav {
2042 display: flex;2043 display: flex;
2043 gap: 0.5rem;2044 gap: 0.5rem;
2044 flex-wrap: wrap;2045 flex-wrap: wrap;
2045 margin: 0.75rem 0 1.25rem;2046 margin: 0.75rem 0 1.25rem;
2046}2047}
2048.profile-tabs {
2049 margin-top: 0;
2050}
2051/* Active toggle button (e.g. a bookmarked repo). */
2052.btn-active {
2053 color: var(--fb-accent);
2054 border-color: var(--fb-accent);
2055}
2047.repo-tabs a,2056.repo-tabs a,
2057.profile-tabs a,
2048.subnav a {2058.subnav a {
2049 display: inline-flex;2059 display: inline-flex;
2050 align-items: center;2060 align-items: center;
@@ -2054,12 +2064,14 @@ pre.code {
2054 color: var(--fb-fg-muted);2064 color: var(--fb-fg-muted);
2055}2065}
2056.repo-tabs a:hover,2066.repo-tabs a:hover,
2067.profile-tabs a:hover,
2057.subnav a:hover {2068.subnav a:hover {
2058 color: var(--fb-fg);2069 color: var(--fb-fg);
2059 background: var(--fb-bg-inset);2070 background: var(--fb-bg-inset);
2060 text-decoration: none;2071 text-decoration: none;
2061}2072}
2062.repo-tabs a[aria-current="page"],2073.repo-tabs a[aria-current="page"],
2074.profile-tabs a[aria-current="page"],
2063.subnav a[aria-current="page"] {2075.subnav a[aria-current="page"] {
2064 color: var(--fb-fg);2076 color: var(--fb-fg);
2065 background: var(--fb-bg-inset);2077 background: var(--fb-bg-inset);
crates/web/src/icons.rs +3 −0
@@ -41,6 +41,7 @@ pub enum Icon {
41 Download,41 Download,
42 Mirror,42 Mirror,
43 GitFork,43 GitFork,
44 Bookmark,
44 Github,45 Github,
45 Mastodon,46 Mastodon,
46}47}
@@ -128,6 +129,8 @@ impl Icon {
128 Icon::GitFork => {129 Icon::GitFork => {
129 r#"<circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/>"#130 r#"<circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"/><path d="M12 12v3"/>"#
130 }131 }
132 // Lucide "bookmark".
133 Icon::Bookmark => r#"<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>"#,
131 Icon::Github => {134 Icon::Github => {
132 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"/>"#135 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"/>"#
133 }136 }
crates/web/src/layout.rs +3 −1
@@ -74,8 +74,10 @@ fn topbar(chrome: &Chrome) -> Markup {
74 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"74 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
75 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }75 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
76 span class="slug" { a href="/" { (chrome.instance_name) } }76 span class="slug" { a href="/" { (chrome.instance_name) } }
77 @if chrome.user.is_some() {77 @if let Some(user) = &chrome.user {
78 (create_menu(chrome))78 (create_menu(chrome))
79 a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" }
80 aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) }
79 }81 }
80 form class="topbar-search" method="get" action="/search" role="search" {82 form class="topbar-search" method="get" action="/search" role="search" {
81 input type="search" name="q" placeholder="Search…" aria-label="Search";83 input type="search" name="q" placeholder="Search…" aria-label="Search";
crates/web/src/lib.rs +1 −0
@@ -319,6 +319,7 @@ pub fn build_router(state: AppState) -> Router {
319 "/repo-fork/{id}",319 "/repo-fork/{id}",
320 get(repo::fork_confirm).post(repo::fork_create),320 get(repo::fork_confirm).post(repo::fork_create),
321 )321 )
322 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
322 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.323 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
323 .route("/issue-new/{repo_id}", post(issues::create))324 .route("/issue-new/{repo_id}", post(issues::create))
324 .route("/issue/{id}/comment", post(issues::comment))325 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/repo.rs +171 −24
@@ -78,6 +78,8 @@ pub(crate) struct RepoCtx {
78 pub(crate) access: auth::Access,78 pub(crate) access: auth::Access,
79 /// The signed-in viewer's id, if any (gates the Fork action).79 /// The signed-in viewer's id, if any (gates the Fork action).
80 pub(crate) viewer_id: Option<String>,80 pub(crate) viewer_id: Option<String>,
81 /// Whether the viewer has bookmarked this repo.
82 pub(crate) bookmarked: bool,
81 /// The parent repo's `(owner_username, path)` when this repo is a fork.83 /// The parent repo's `(owner_username, path)` when this repo is a fork.
82 pub(crate) fork_parent: Option<(String, String)>,84 pub(crate) fork_parent: Option<(String, String)>,
83}85}
@@ -153,11 +155,20 @@ pub(crate) async fn resolve(
153 Some(pid) => resolve_fork_parent(state, pid).await,155 Some(pid) => resolve_fork_parent(state, pid).await,
154 None => None,156 None => None,
155 };157 };
158 let bookmarked = match viewer {
159 Some(u) => state
160 .store
161 .is_bookmarked(&u.id, &repo.id)
162 .await
163 .unwrap_or(false),
164 None => false,
165 };
156 Ok(Some(RepoCtx {166 Ok(Some(RepoCtx {
157 owner,167 owner,
158 repo,168 repo,
159 access,169 access,
160 viewer_id: viewer.map(|u| u.id.clone()),170 viewer_id: viewer.map(|u| u.id.clone()),
171 bookmarked,
161 fork_parent,172 fork_parent,
162 }))173 }))
163}174}
@@ -180,6 +191,9 @@ pub struct ListQuery {
180 /// Case-insensitive substring filter over repository paths.191 /// Case-insensitive substring filter over repository paths.
181 #[serde(default)]192 #[serde(default)]
182 pub q: Option<String>,193 pub q: Option<String>,
194 /// Active profile tab: `repos` (default), `groups`, or `bookmarks`.
195 #[serde(default)]
196 pub tab: Option<String>,
183}197}
184198
185/// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).199/// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).
@@ -303,37 +317,44 @@ pub async fn user_page(
303 let Some(owner) = state.store.user_by_username(&owner_name).await? else {317 let Some(owner) = state.store.user_by_username(&owner_name).await? else {
304 return Err(AppError::NotFound);318 return Err(AppError::NotFound);
305 };319 };
306 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;320 let tab = match lq.tab.as_deref() {
307 let groups = state.store.groups_by_owner(&owner.id).await?;321 Some("groups") => ProfileTab::Groups,
308322 Some("bookmarks") => ProfileTab::Bookmarks,
323 _ => ProfileTab::Repos,
324 };
309 let q = lq.q.unwrap_or_default();325 let q = lq.q.unwrap_or_default();
310 let needle = q.trim().to_lowercase();326 let needle = q.trim().to_lowercase();
311 // The profile lists only top-level groups and repos; grouped ones live in327 let base = format!("/{}", owner.username);
312 // their group. A search, however, spans everything so nested items are
313 // findable.
314 let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect();
315 let entries: Vec<RepoEntry> = repos
316 .iter()
317 .filter(|r| {
318 let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle);
319 matches && (!needle.is_empty() || !r.path.contains('/'))
320 })
321 .map(|r| RepoEntry::owned(&owner.username, r))
322 .collect();
323328
324 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());329 let content = match tab {
325 let body = html! {330 ProfileTab::Repos => {
326 div class="profile-layout" {331 let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
327 aside class="profile-sidebar" { (profile_sidebar(&owner)) }332 // Top-level repos only; a search spans nested ones so they are findable.
328 div class="profile-main" {333 let entries: Vec<RepoEntry> = repos
334 .iter()
335 .filter(|r| {
336 let matches = needle.is_empty() || r.path.to_lowercase().contains(&needle);
337 matches && (!needle.is_empty() || !r.path.contains('/'))
338 })
339 .map(|r| RepoEntry::owned(&owner.username, r))
340 .collect();
341 html! {
329 form class="search-row" method="get" {342 form class="search-row" method="get" {
330 input type="search" name="q" value=(q)343 input type="search" name="q" value=(q)
331 placeholder="Search repositories…" aria-label="Search repositories";344 placeholder="Search repositories…" aria-label="Search repositories";
332 button class="btn btn-primary" type="submit" { "Search" }345 button class="btn btn-primary" type="submit" { "Search" }
333 }346 }
334 @if !top_groups.is_empty() {347 (repo_card("", "No repositories.", &entries))
335 section class="listing" {348 }
336 h2 { "Groups" }349 }
350 ProfileTab::Groups => {
351 let groups = state.store.groups_by_owner(&owner.id).await?;
352 let top_groups: Vec<_> = groups.iter().filter(|g| !g.path.contains('/')).collect();
353 html! {
354 section class="listing" {
355 @if top_groups.is_empty() {
356 div class="card" { p class="muted" { "No groups." } }
357 } @else {
337 ul class="repo-list card" {358 ul class="repo-list card" {
338 @for group in &top_groups {359 @for group in &top_groups {
339 li {360 li {
@@ -347,13 +368,109 @@ pub async fn user_page(
347 }368 }
348 }369 }
349 }370 }
350 (repo_card("Repositories", "No repositories.", &entries))371 }
372 }
373 ProfileTab::Bookmarks => {
374 let bookmarked = state.store.bookmarked_repos(&owner.id).await?;
375 let visible = filter_readable(&state, bookmarked, viewer.as_ref()).await?;
376 let entries: Vec<RepoEntry> = visible
377 .iter()
378 .map(|r| {
379 // Resolve each repo's own owner for the cross-owner label.
380 RepoEntry::global(&r.owner_id, r)
381 })
382 .collect();
383 // Replace the owner-id label with the username where we can.
384 let entries = resolve_bookmark_labels(&state, &visible, entries).await;
385 html! { (repo_card("", "No bookmarks yet.", &entries)) }
386 }
387 };
388
389 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
390 let ptab = |t: ProfileTab, key: &str, label: &str| {
391 html! {
392 a href=(format!("{base}?tab={key}")) aria-current=[(tab == t).then_some("page")] { (label) }
393 }
394 };
395 let body = html! {
396 div class="profile-layout" {
397 aside class="profile-sidebar" { (profile_sidebar(&owner)) }
398 div class="profile-main" {
399 nav class="tabs profile-tabs" {
400 (ptab(ProfileTab::Repos, "repos", "Repositories"))
401 (ptab(ProfileTab::Groups, "groups", "Groups"))
402 (ptab(ProfileTab::Bookmarks, "bookmarks", "Bookmarks"))
403 }
404 (content)
351 }405 }
352 }406 }
353 };407 };
354 Ok((jar, page(&chrome, &owner.username, body)).into_response())408 Ok((jar, page(&chrome, &owner.username, body)).into_response())
355}409}
356410
411/// The three tabs on a user's profile.
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413enum ProfileTab {
414 Repos,
415 Groups,
416 Bookmarks,
417}
418
419/// Filter an arbitrary repo list to those `viewer` may read (for bookmarks).
420async fn filter_readable(
421 state: &AppState,
422 repos: Vec<Repo>,
423 viewer: Option<&User>,
424) -> AppResult<Vec<Repo>> {
425 let viewer_id = viewer.map(|v| auth::Viewer {
426 id: v.id.clone(),
427 is_admin: v.is_admin,
428 });
429 let mut out = Vec::new();
430 for repo in repos {
431 let collab = match viewer {
432 Some(v) => state
433 .store
434 .collaborator_permission(&repo.id, &v.id)
435 .await?
436 .and_then(|p| auth::Permission::parse(&p)),
437 None => None,
438 };
439 let access = auth::access(viewer_id.as_ref(), &repo, collab, state.allow_anonymous());
440 if access >= auth::Access::Read {
441 out.push(repo);
442 }
443 }
444 Ok(out)
445}
446
447/// Replace each bookmark entry's owner-id label/href with the real username.
448async fn resolve_bookmark_labels(
449 state: &AppState,
450 repos: &[Repo],
451 mut entries: Vec<RepoEntry>,
452) -> Vec<RepoEntry> {
453 let mut names: std::collections::HashMap<String, String> = std::collections::HashMap::new();
454 for (repo, entry) in repos.iter().zip(entries.iter_mut()) {
455 let owner = if let Some(n) = names.get(&repo.owner_id) {
456 n.clone()
457 } else {
458 let n = state
459 .store
460 .user_by_id(&repo.owner_id)
461 .await
462 .ok()
463 .flatten()
464 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
465 names.insert(repo.owner_id.clone(), n.clone());
466 n
467 };
468 entry.href = format!("/{owner}/{}", repo.path);
469 entry.label = format!("{owner}/{}", repo.path);
470 }
471 entries
472}
473
357/// `GET /{owner}/{*rest}` — dispatch a repo path and view.474/// `GET /{owner}/{*rest}` — dispatch a repo path and view.
358pub async fn dispatch(475pub async fn dispatch(
359 State(state): State<AppState>,476 State(state): State<AppState>,
@@ -1618,6 +1735,31 @@ async fn require_readable_repo(
1618 Ok(repo)1735 Ok(repo)
1619}1736}
16201737
1738/// `GET /repo-bookmark/{id}` — toggle the viewer's bookmark of a readable repo,
1739/// then return to it. (A GET toggle, matching the fork link; bookmarks are a
1740/// trivial, reversible per-user flag.)
1741pub async fn bookmark_toggle(
1742 State(state): State<AppState>,
1743 MaybeUser(viewer): MaybeUser,
1744 Path(id): Path<String>,
1745) -> AppResult<Response> {
1746 let Some(user) = viewer.clone() else {
1747 return Ok(Redirect::to("/login").into_response());
1748 };
1749 let repo = require_readable_repo(&state, viewer.as_ref(), &id).await?;
1750 if state.store.is_bookmarked(&user.id, &repo.id).await? {
1751 state.store.remove_bookmark(&user.id, &repo.id).await?;
1752 } else {
1753 state.store.add_bookmark(&user.id, &repo.id).await?;
1754 }
1755 let owner = state
1756 .store
1757 .user_by_id(&repo.owner_id)
1758 .await?
1759 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
1760 Ok(Redirect::to(&format!("/{owner}/{}", repo.path)).into_response())
1761}
1762
1621/// `GET /repo-fork/{id}` — confirm forking a readable repo to your account.1763/// `GET /repo-fork/{id}` — confirm forking a readable repo to your account.
1622pub async fn fork_confirm(1764pub async fn fork_confirm(
1623 State(state): State<AppState>,1765 State(state): State<AppState>,
@@ -2857,6 +2999,11 @@ pub(crate) fn repo_header(
2857 }2999 }
2858 div class="repo-actions" {3000 div class="repo-actions" {
2859 @if ctx.viewer_id.is_some() {3001 @if ctx.viewer_id.is_some() {
3002 a class=(if ctx.bookmarked { "btn btn-active" } else { "btn" })
3003 href=(format!("/repo-bookmark/{}", ctx.repo.id)) {
3004 (icon(Icon::Bookmark))
3005 span { (if ctx.bookmarked { "Bookmarked" } else { "Bookmark" }) }
3006 }
2860 a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) {3007 a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) {
2861 (icon(Icon::GitFork)) span { "Fork" }3008 (icon(Icon::GitFork)) span { "Fork" }
2862 }3009 }