fabrica

hanna/fabrica

feat(web): Forgejo-style explore with repositories and users tabs

128d4be · hanna committed on 2026-07-25

Rebuild /explore with a centered sub-nav (Repositories / Users), a search
box, and the listing below. Add /explore/users listing the user directory
(avatar, name, join date) and server-side search filtering on both tabs; the
anonymous landing reuses the repositories tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +158 −23UnifiedSplit
assets/base.css +29 −0
@@ -432,6 +432,35 @@ select:focus {
432432 font-size: 0.82em;
433433 }
434434
435+/* User directory rows (Explore → Users). */
436+.user-row {
437+ display: flex;
438+ align-items: center;
439+ gap: 0.75rem;
440+ padding: 0.75rem 1rem;
441+ color: var(--fb-fg);
442+}
443+.user-row:hover {
444+ background: var(--fb-bg-inset);
445+ text-decoration: none;
446+}
447+.user-row .avatar {
448+ width: 2.5rem;
449+ height: 2.5rem;
450+ flex: none;
451+}
452+.user-row-main {
453+ display: flex;
454+ flex-direction: column;
455+ min-width: 0;
456+}
457+.user-row-name {
458+ font-weight: 600;
459+}
460+.user-row-meta {
461+ font-size: 0.85em;
462+}
463+
435464 /* ---- Avatars & profile header ---- */
436465 .avatar {
437466 border-radius: 6px;
crates/web/src/lib.rs +1 −0
@@ -121,6 +121,7 @@ pub fn build_router(state: AppState) -> Router {
121121 .nest_service("/api/v1", api)
122122 .route("/", get(pages::home))
123123 .route("/explore", get(pages::explore))
124+ .route("/explore/users", get(pages::explore_users))
124125 .route("/login", get(pages::login_form).post(pages::login_submit))
125126 .route("/logout", post(pages::logout))
126127 .route(
crates/web/src/pages.rs +114 −20
@@ -21,7 +21,7 @@ use crate::assets::Scheme;
2121 use crate::error::{AppError, AppResult};
2222 use crate::icons::{Icon, icon};
2323 use crate::layout::{Chrome, page};
24-use crate::repo::{RepoEntry, repo_card};
24+use crate::repo::{self, RepoEntry, repo_card};
2525 use crate::session::{
2626 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,
2727 };
@@ -71,7 +71,7 @@ pub async fn home(
7171 let body = dashboard_body(&state, &u).await?;
7272 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
7373 } else {
74- let body = explore_body(&state).await?;
74+ let body = explore_repos_body(&state, "").await?;
7575 Ok((jar, page(&chrome, "Explore", body)).into_response())
7676 }
7777 }
@@ -82,30 +82,46 @@ pub async fn explore(
8282 MaybeUser(user): MaybeUser,
8383 jar: CookieJar,
8484 uri: Uri,
85+ axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
8586 ) -> AppResult<Response> {
8687 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
87- let body = explore_body(&state).await?;
88+ let body = explore_repos_body(&state, &lq.q.unwrap_or_default()).await?;
8889 Ok((jar, page(&chrome, "Explore", body)).into_response())
8990 }
9091
91-/// The dashboard: a greeting and the viewer's own repositories.
92-async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {
93- let mine = state.store.repos_by_owner(&user.id).await?;
94- let entries: Vec<RepoEntry> = mine
95- .iter()
96- .map(|r| RepoEntry::owned(&user.username, r))
97- .collect();
98- Ok(html! {
99- h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }
100- (repo_card("Your repositories", "You have no repositories yet.", &entries))
101- })
92+/// `GET /explore/users` — every user, for anyone.
93+pub async fn explore_users(
94+ State(state): State<AppState>,
95+ MaybeUser(user): MaybeUser,
96+ jar: CookieJar,
97+ uri: Uri,
98+ axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
99+) -> AppResult<Response> {
100+ let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
101+ let body = explore_users_body(&state, &lq.q.unwrap_or_default()).await?;
102+ Ok((jar, page(&chrome, "Explore", body)).into_response())
103+}
104+
105+/// The Explore sub-navigation (Repositories / Users tabs).
106+fn explore_subnav(active: &str) -> Markup {
107+ html! {
108+ nav class="subnav" aria-label="Explore" {
109+ a href="/explore" aria-current=[(active == "repos").then_some("page")] {
110+ (icon(Icon::Box)) span { "Repositories" }
111+ }
112+ a href="/explore/users" aria-current=[(active == "users").then_some("page")] {
113+ (icon(Icon::User)) span { "Users" }
114+ }
115+ }
116+ }
102117 }
103118
104-/// The Explore listing: every public repository, labelled `owner/path`.
105-async fn explore_body(state: &AppState) -> AppResult<Markup> {
119+/// The Explore repositories tab: sub-nav, a search box, and the public repos.
120+async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
121+ let needle = q.trim().to_lowercase();
106122 let all = state.store.list_repos().await?;
107123 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();
108- // Resolve owner names once for the cross-owner labels/links.
124+ // Resolve owner names once for the cross-owner labels/links and search.
109125 let mut owners: HashMap<String, String> = HashMap::new();
110126 for repo in &public {
111127 if !owners.contains_key(&repo.owner_id) {
@@ -119,17 +135,95 @@ async fn explore_body(state: &AppState) -> AppResult<Markup> {
119135 }
120136 let entries: Vec<RepoEntry> = public
121137 .iter()
122- .map(|r| {
138+ .filter_map(|r| {
123139 let owner = owners.get(&r.owner_id).map_or("", String::as_str);
124- RepoEntry::global(owner, r)
140+ let label = format!("{owner}/{}", r.path);
141+ (needle.is_empty() || label.to_lowercase().contains(&needle))
142+ .then(|| RepoEntry::global(owner, r))
125143 })
126144 .collect();
127145 Ok(html! {
128- h1 { "Explore" }
146+ (explore_subnav("repos"))
147+ form class="search-row" method="get" action="/explore" {
148+ input type="search" name="q" value=(q) placeholder="Search repositories…"
149+ aria-label="Search repositories";
150+ button class="btn btn-primary" type="submit" { "Search" }
151+ }
129152 (repo_card("Public repositories", "No public repositories.", &entries))
130153 })
131154 }
132155
156+/// The Explore users tab: sub-nav, a search box, and the user directory.
157+async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
158+ let needle = q.trim().to_lowercase();
159+ let users: Vec<User> = state
160+ .store
161+ .list_users()
162+ .await?
163+ .into_iter()
164+ .filter(|u| u.disabled_at.is_none())
165+ .filter(|u| {
166+ needle.is_empty()
167+ || u.username.to_lowercase().contains(&needle)
168+ || u.display_name
169+ .as_deref()
170+ .is_some_and(|d| d.to_lowercase().contains(&needle))
171+ })
172+ .collect();
173+ Ok(html! {
174+ (explore_subnav("users"))
175+ form class="search-row" method="get" action="/explore/users" {
176+ input type="search" name="q" value=(q) placeholder="Search users…"
177+ aria-label="Search users";
178+ button class="btn btn-primary" type="submit" { "Search" }
179+ }
180+ (user_card(&users))
181+ })
182+}
183+
184+/// Render a directory of users as a listing card (avatar, name, join date).
185+fn user_card(users: &[User]) -> Markup {
186+ html! {
187+ section class="listing" {
188+ h2 { "Users" }
189+ @if users.is_empty() {
190+ div class="card" { p class="muted" { "No users." } }
191+ } @else {
192+ ul class="repo-list card" {
193+ @for u in users {
194+ li {
195+ a class="user-row" href={ "/" (u.username) } {
196+ img class="avatar" src={ "/avatar/" (u.username) } alt="";
197+ span class="user-row-main" {
198+ span class="user-row-name" {
199+ (u.display_name.as_deref().unwrap_or(&u.username))
200+ }
201+ span class="user-row-meta muted" {
202+ "@" (u.username) " · Joined on " (repo::fmt_joined(u.created_at))
203+ }
204+ }
205+ }
206+ }
207+ }
208+ }
209+ }
210+ }
211+ }
212+}
213+
214+/// The dashboard: a greeting and the viewer's own repositories.
215+async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {
216+ let mine = state.store.repos_by_owner(&user.id).await?;
217+ let entries: Vec<RepoEntry> = mine
218+ .iter()
219+ .map(|r| RepoEntry::owned(&user.username, r))
220+ .collect();
221+ Ok(html! {
222+ h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }
223+ (repo_card("Your repositories", "You have no repositories yet.", &entries))
224+ })
225+}
226+
133227 /// Sign-in form fields.
134228 #[derive(Debug, Deserialize)]
135229 pub struct LoginForm {
crates/web/src/repo.rs +1 −1
@@ -1364,7 +1364,7 @@ fn is_image(filename: &str) -> bool {
13641364
13651365 /// Format an epoch-millisecond timestamp as a human join date, e.g.
13661366 /// `Jul 11, 2026`.
1367-fn fmt_joined(ms: i64) -> String {
1367+pub(crate) fn fmt_joined(ms: i64) -> String {
13681368 let secs = ms.div_euclid(1000);
13691369 match time::OffsetDateTime::from_unix_timestamp(secs) {
13701370 Ok(dt) => {
crates/web/src/tests.rs +13 −2
@@ -316,8 +316,19 @@ async fn explore_lists_public_repos_for_anonymous() {
316316 let html = body_string(res).await;
317317 assert!(html.contains("Public repositories"), "explore heading");
318318 assert!(html.contains("ada/open"), "public repo listed");
319- // The anonymous landing must not offer an in-content sign-in button.
320- assert!(!html.contains("btn-primary"), "no content sign-in button");
319+ // The Explore sub-nav offers the repositories and users tabs.
320+ assert!(html.contains("/explore/users"), "users tab present");
321+}
322+
323+#[tokio::test]
324+async fn explore_users_lists_the_directory() {
325+ let (_state, app) = app().await;
326+ let res = app.oneshot(get("/explore/users")).await.unwrap();
327+ assert_eq!(res.status(), StatusCode::OK);
328+ let html = body_string(res).await;
329+ assert!(html.contains("Users"), "users heading");
330+ assert!(html.contains("Ada"), "user display name listed");
331+ assert!(html.contains("/avatar/ada"), "user avatar shown");
321332 }
322333
323334 #[tokio::test]