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 {
20382038 }
20392039 /* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */
20402040 .repo-tabs,
2041+.profile-tabs,
20412042 .subnav {
20422043 display: flex;
20432044 gap: 0.5rem;
20442045 flex-wrap: wrap;
20452046 margin: 0.75rem 0 1.25rem;
20462047 }
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+}
20472056 .repo-tabs a,
2057+.profile-tabs a,
20482058 .subnav a {
20492059 display: inline-flex;
20502060 align-items: center;
@@ -2054,12 +2064,14 @@ pre.code {
20542064 color: var(--fb-fg-muted);
20552065 }
20562066 .repo-tabs a:hover,
2067+.profile-tabs a:hover,
20572068 .subnav a:hover {
20582069 color: var(--fb-fg);
20592070 background: var(--fb-bg-inset);
20602071 text-decoration: none;
20612072 }
20622073 .repo-tabs a[aria-current="page"],
2074+.profile-tabs a[aria-current="page"],
20632075 .subnav a[aria-current="page"] {
20642076 color: var(--fb-fg);
20652077 background: var(--fb-bg-inset);
crates/web/src/icons.rs +3 −0
@@ -41,6 +41,7 @@ pub enum Icon {
4141 Download,
4242 Mirror,
4343 GitFork,
44+ Bookmark,
4445 Github,
4546 Mastodon,
4647 }
@@ -128,6 +129,8 @@ impl Icon {
128129 Icon::GitFork => {
129130 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"/>"#
130131 }
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"/>"#,
131134 Icon::Github => {
132135 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"/>"#
133136 }
crates/web/src/layout.rs +3 −1
@@ -74,8 +74,10 @@ fn topbar(chrome: &Chrome) -> Markup {
7474 button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu"
7575 aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) }
7676 span class="slug" { a href="/" { (chrome.instance_name) } }
77- @if chrome.user.is_some() {
77+ @if let Some(user) = &chrome.user {
7878 (create_menu(chrome))
79+ a class="icon-btn" href={ "/" (user.username) "?tab=bookmarks" }
80+ aria-label="Bookmarks" title="Bookmarks" { (icon(Icon::Bookmark)) }
7981 }
8082 form class="topbar-search" method="get" action="/search" role="search" {
8183 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 {
319319 "/repo-fork/{id}",
320320 get(repo::fork_confirm).post(repo::fork_create),
321321 )
322+ .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
322323 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
323324 .route("/issue-new/{repo_id}", post(issues::create))
324325 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/repo.rs +171 −24
@@ -78,6 +78,8 @@ pub(crate) struct RepoCtx {
7878 pub(crate) access: auth::Access,
7979 /// The signed-in viewer's id, if any (gates the Fork action).
8080 pub(crate) viewer_id: Option<String>,
81+ /// Whether the viewer has bookmarked this repo.
82+ pub(crate) bookmarked: bool,
8183 /// The parent repo's `(owner_username, path)` when this repo is a fork.
8284 pub(crate) fork_parent: Option<(String, String)>,
8385 }
@@ -153,11 +155,20 @@ pub(crate) async fn resolve(
153155 Some(pid) => resolve_fork_parent(state, pid).await,
154156 None => None,
155157 };
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+ };
156166 Ok(Some(RepoCtx {
157167 owner,
158168 repo,
159169 access,
160170 viewer_id: viewer.map(|u| u.id.clone()),
171+ bookmarked,
161172 fork_parent,
162173 }))
163174 }
@@ -180,6 +191,9 @@ pub struct ListQuery {
180191 /// Case-insensitive substring filter over repository paths.
181192 #[serde(default)]
182193 pub q: Option<String>,
194+ /// Active profile tab: `repos` (default), `groups`, or `bookmarks`.
195+ #[serde(default)]
196+ pub tab: Option<String>,
183197 }
184198
185199 /// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).
@@ -303,37 +317,44 @@ pub async fn user_page(
303317 let Some(owner) = state.store.user_by_username(&owner_name).await? else {
304318 return Err(AppError::NotFound);
305319 };
306- let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
307- let groups = state.store.groups_by_owner(&owner.id).await?;
308-
320+ let tab = match lq.tab.as_deref() {
321+ Some("groups") => ProfileTab::Groups,
322+ Some("bookmarks") => ProfileTab::Bookmarks,
323+ _ => ProfileTab::Repos,
324+ };
309325 let q = lq.q.unwrap_or_default();
310326 let needle = q.trim().to_lowercase();
311- // The profile lists only top-level groups and repos; grouped ones live in
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();
327+ let base = format!("/{}", owner.username);
323328
324- let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
325- let body = html! {
326- div class="profile-layout" {
327- aside class="profile-sidebar" { (profile_sidebar(&owner)) }
328- div class="profile-main" {
329+ let content = match tab {
330+ ProfileTab::Repos => {
331+ let repos = visible_repos(&state, &owner, viewer.as_ref()).await?;
332+ // Top-level repos only; a search spans nested ones so they are findable.
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! {
329342 form class="search-row" method="get" {
330343 input type="search" name="q" value=(q)
331344 placeholder="Search repositories…" aria-label="Search repositories";
332345 button class="btn btn-primary" type="submit" { "Search" }
333346 }
334- @if !top_groups.is_empty() {
335- section class="listing" {
336- h2 { "Groups" }
347+ (repo_card("", "No repositories.", &entries))
348+ }
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 {
337358 ul class="repo-list card" {
338359 @for group in &top_groups {
339360 li {
@@ -347,13 +368,109 @@ pub async fn user_page(
347368 }
348369 }
349370 }
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)
351405 }
352406 }
353407 };
354408 Ok((jar, page(&chrome, &owner.username, body)).into_response())
355409 }
356410
411+/// The three tabs on a user's profile.
412+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413+enum ProfileTab {
414+ Repos,
415+ Groups,
416+ Bookmarks,
417+}
418+
419+/// Filter an arbitrary repo list to those `viewer` may read (for bookmarks).
420+async 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.
448+async 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+
357474 /// `GET /{owner}/{*rest}` — dispatch a repo path and view.
358475 pub async fn dispatch(
359476 State(state): State<AppState>,
@@ -1618,6 +1735,31 @@ async fn require_readable_repo(
16181735 Ok(repo)
16191736 }
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.)
1741+pub 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+
16211763 /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account.
16221764 pub async fn fork_confirm(
16231765 State(state): State<AppState>,
@@ -2857,6 +2999,11 @@ pub(crate) fn repo_header(
28572999 }
28583000 div class="repo-actions" {
28593001 @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+ }
28603007 a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) {
28613008 (icon(Icon::GitFork)) span { "Fork" }
28623009 }