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 {
432 font-size: 0.82em;432 font-size: 0.82em;
433}433}
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
435/* ---- Avatars & profile header ---- */464/* ---- Avatars & profile header ---- */
436.avatar {465.avatar {
437 border-radius: 6px;466 border-radius: 6px;
crates/web/src/lib.rs +1 −0
@@ -121,6 +121,7 @@ pub fn build_router(state: AppState) -> Router {
121 .nest_service("/api/v1", api)121 .nest_service("/api/v1", api)
122 .route("/", get(pages::home))122 .route("/", get(pages::home))
123 .route("/explore", get(pages::explore))123 .route("/explore", get(pages::explore))
124 .route("/explore/users", get(pages::explore_users))
124 .route("/login", get(pages::login_form).post(pages::login_submit))125 .route("/login", get(pages::login_form).post(pages::login_submit))
125 .route("/logout", post(pages::logout))126 .route("/logout", post(pages::logout))
126 .route(127 .route(
crates/web/src/pages.rs +114 −20
@@ -21,7 +21,7 @@ use crate::assets::Scheme;
21use crate::error::{AppError, AppResult};21use crate::error::{AppError, AppResult};
22use crate::icons::{Icon, icon};22use crate::icons::{Icon, icon};
23use crate::layout::{Chrome, page};23use crate::layout::{Chrome, page};
24use crate::repo::{RepoEntry, repo_card};24use crate::repo::{self, RepoEntry, repo_card};
25use crate::session::{25use crate::session::{
26 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,26 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,
27};27};
@@ -71,7 +71,7 @@ pub async fn home(
71 let body = dashboard_body(&state, &u).await?;71 let body = dashboard_body(&state, &u).await?;
72 Ok((jar, page(&chrome, "Dashboard", body)).into_response())72 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
73 } else {73 } else {
74 let body = explore_body(&state).await?;74 let body = explore_repos_body(&state, "").await?;
75 Ok((jar, page(&chrome, "Explore", body)).into_response())75 Ok((jar, page(&chrome, "Explore", body)).into_response())
76 }76 }
77}77}
@@ -82,30 +82,46 @@ pub async fn explore(
82 MaybeUser(user): MaybeUser,82 MaybeUser(user): MaybeUser,
83 jar: CookieJar,83 jar: CookieJar,
84 uri: Uri,84 uri: Uri,
85 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
85) -> AppResult<Response> {86) -> AppResult<Response> {
86 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());87 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?;
88 Ok((jar, page(&chrome, "Explore", body)).into_response())89 Ok((jar, page(&chrome, "Explore", body)).into_response())
89}90}
9091
91/// The dashboard: a greeting and the viewer's own repositories.92/// `GET /explore/users` — every user, for anyone.
92async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {93pub async fn explore_users(
93 let mine = state.store.repos_by_owner(&user.id).await?;94 State(state): State<AppState>,
94 let entries: Vec<RepoEntry> = mine95 MaybeUser(user): MaybeUser,
95 .iter()96 jar: CookieJar,
96 .map(|r| RepoEntry::owned(&user.username, r))97 uri: Uri,
97 .collect();98 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
98 Ok(html! {99) -> AppResult<Response> {
99 h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }100 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
100 (repo_card("Your repositories", "You have no repositories yet.", &entries))101 let body = explore_users_body(&state, &lq.q.unwrap_or_default()).await?;
101 })102 Ok((jar, page(&chrome, "Explore", body)).into_response())
103}
104
105/// The Explore sub-navigation (Repositories / Users tabs).
106fn 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 }
102}117}
103118
104/// The Explore listing: every public repository, labelled `owner/path`.119/// The Explore repositories tab: sub-nav, a search box, and the public repos.
105async fn explore_body(state: &AppState) -> AppResult<Markup> {120async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
121 let needle = q.trim().to_lowercase();
106 let all = state.store.list_repos().await?;122 let all = state.store.list_repos().await?;
107 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();123 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.
109 let mut owners: HashMap<String, String> = HashMap::new();125 let mut owners: HashMap<String, String> = HashMap::new();
110 for repo in &public {126 for repo in &public {
111 if !owners.contains_key(&repo.owner_id) {127 if !owners.contains_key(&repo.owner_id) {
@@ -119,17 +135,95 @@ async fn explore_body(state: &AppState) -> AppResult<Markup> {
119 }135 }
120 let entries: Vec<RepoEntry> = public136 let entries: Vec<RepoEntry> = public
121 .iter()137 .iter()
122 .map(|r| {138 .filter_map(|r| {
123 let owner = owners.get(&r.owner_id).map_or("", String::as_str);139 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))
125 })143 })
126 .collect();144 .collect();
127 Ok(html! {145 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 }
129 (repo_card("Public repositories", "No public repositories.", &entries))152 (repo_card("Public repositories", "No public repositories.", &entries))
130 })153 })
131}154}
132155
156/// The Explore users tab: sub-nav, a search box, and the user directory.
157async 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).
185fn 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.
215async 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
133/// Sign-in form fields.227/// Sign-in form fields.
134#[derive(Debug, Deserialize)]228#[derive(Debug, Deserialize)]
135pub struct LoginForm {229pub struct LoginForm {
crates/web/src/repo.rs +1 −1
@@ -1364,7 +1364,7 @@ fn is_image(filename: &str) -> bool {
13641364
1365/// Format an epoch-millisecond timestamp as a human join date, e.g.1365/// Format an epoch-millisecond timestamp as a human join date, e.g.
1366/// `Jul 11, 2026`.1366/// `Jul 11, 2026`.
1367fn fmt_joined(ms: i64) -> String {1367pub(crate) fn fmt_joined(ms: i64) -> String {
1368 let secs = ms.div_euclid(1000);1368 let secs = ms.div_euclid(1000);
1369 match time::OffsetDateTime::from_unix_timestamp(secs) {1369 match time::OffsetDateTime::from_unix_timestamp(secs) {
1370 Ok(dt) => {1370 Ok(dt) => {
crates/web/src/tests.rs +13 −2
@@ -316,8 +316,19 @@ async fn explore_lists_public_repos_for_anonymous() {
316 let html = body_string(res).await;316 let html = body_string(res).await;
317 assert!(html.contains("Public repositories"), "explore heading");317 assert!(html.contains("Public repositories"), "explore heading");
318 assert!(html.contains("ada/open"), "public repo listed");318 assert!(html.contains("ada/open"), "public repo listed");
319 // The anonymous landing must not offer an in-content sign-in button.319 // The Explore sub-nav offers the repositories and users tabs.
320 assert!(!html.contains("btn-primary"), "no content sign-in button");320 assert!(html.contains("/explore/users"), "users tab present");
321}
322
323#[tokio::test]
324async 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");
321}332}
322333
323#[tokio::test]334#[tokio::test]