fabrica

hanna/fabrica

feat(web): split dashboard and explore, restyle repo listings

1d75fb4 · hanna committed on 2026-07-25

Give `/` a signed-in dashboard (your repositories) and anonymous visitors
the public Explore landing with no in-content sign-in button; add `/explore`
for the public listing at any time. Replace the plain repo lists with a
titled card of bordered rows — bold name, package icon, and a muted
`visibility · default: branch` subtitle — matching the requested reference
design, and reuse it for user and group pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +164 −70UnifiedSplit
assets/base.css +42 −0
@@ -339,6 +339,9 @@ select:focus {
339339 }
340340
341341 /* ---- Repo/list rows ---- */
342+.listing {
343+ margin-bottom: 1.5rem;
344+}
342345 .repo-list {
343346 list-style: none;
344347 margin: 0;
@@ -353,6 +356,45 @@ select:focus {
353356 font-size: 1.05em;
354357 }
355358
359+/* Listing card: bordered rows inside a single card. */
360+.repo-list.card {
361+ padding: 0;
362+ overflow: hidden;
363+}
364+.repo-list.card li {
365+ padding: 0;
366+ border-bottom: 1px solid var(--fb-border);
367+}
368+.repo-list.card li:last-child {
369+ border-bottom: none;
370+}
371+.repo-row {
372+ display: flex;
373+ flex-direction: column;
374+ gap: 0.15rem;
375+ padding: 0.75rem 1rem;
376+ color: var(--fb-fg);
377+}
378+.repo-row:hover {
379+ background: var(--fb-bg-inset);
380+ text-decoration: none;
381+}
382+.repo-row-name {
383+ display: inline-flex;
384+ align-items: center;
385+ gap: 0.45rem;
386+ font-weight: 600;
387+}
388+.repo-row-name .icon {
389+ color: var(--fb-fg-muted);
390+}
391+.repo-row-desc {
392+ font-size: 0.9em;
393+}
394+.repo-row-meta {
395+ font-size: 0.82em;
396+}
397+
356398 /* ---- Tables ---- */
357399 table.list {
358400 width: 100%;
crates/web/src/lib.rs +1 −0
@@ -119,6 +119,7 @@ pub fn build_router(state: AppState) -> Router {
119119 Router::new()
120120 .nest_service("/api/v1", api)
121121 .route("/", get(pages::home))
122+ .route("/explore", get(pages::explore))
122123 .route("/login", get(pages::login_form).post(pages::login_submit))
123124 .route("/logout", post(pages::logout))
124125 .route(
crates/web/src/pages.rs +47 −53
@@ -20,6 +20,7 @@ use crate::AppState;
2020 use crate::assets::Scheme;
2121 use crate::error::{AppError, AppResult};
2222 use crate::layout::{Chrome, page};
23+use crate::repo::{RepoEntry, repo_card};
2324 use crate::session::{
2425 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,
2526 };
@@ -56,7 +57,8 @@ pub(crate) fn build_chrome(
5657 (jar, chrome)
5758 }
5859
59-/// `GET /` — your repositories and the public ones.
60+/// `GET /` — the signed-in viewer's dashboard, or the public Explore landing for
61+/// anonymous visitors.
6062 pub async fn home(
6163 State(state): State<AppState>,
6264 MaybeUser(user): MaybeUser,
@@ -64,19 +66,45 @@ pub async fn home(
6466 uri: Uri,
6567 ) -> AppResult<Response> {
6668 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());
67- let body = home_body(&state, user.as_ref()).await?;
68- Ok((jar, page(&chrome, "Home", body)).into_response())
69+ if let Some(u) = user {
70+ let body = dashboard_body(&state, &u).await?;
71+ Ok((jar, page(&chrome, "Dashboard", body)).into_response())
72+ } else {
73+ let body = explore_body(&state).await?;
74+ Ok((jar, page(&chrome, "Explore", body)).into_response())
75+ }
6976 }
7077
71-/// Render the home content: the viewer's own repos, plus public repos.
72-async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> {
73- let mine = match user {
74- Some(u) => state.store.repos_by_owner(&u.id).await?,
75- None => Vec::new(),
76- };
77- // Public repos, with owner names resolved for their links.
78+/// `GET /explore` — every public repository, for anyone.
79+pub async fn explore(
80+ State(state): State<AppState>,
81+ MaybeUser(user): MaybeUser,
82+ jar: CookieJar,
83+ uri: Uri,
84+) -> AppResult<Response> {
85+ let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
86+ let body = explore_body(&state).await?;
87+ Ok((jar, page(&chrome, "Explore", body)).into_response())
88+}
89+
90+/// The dashboard: a greeting and the viewer's own repositories.
91+async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> {
92+ let mine = state.store.repos_by_owner(&user.id).await?;
93+ let entries: Vec<RepoEntry> = mine
94+ .iter()
95+ .map(|r| RepoEntry::owned(&user.username, r))
96+ .collect();
97+ Ok(html! {
98+ h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) }
99+ (repo_card("Your repositories", "You have no repositories yet.", &entries))
100+ })
101+}
102+
103+/// The Explore listing: every public repository, labelled `owner/path`.
104+async fn explore_body(state: &AppState) -> AppResult<Markup> {
78105 let all = state.store.list_repos().await?;
79106 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();
107+ // Resolve owner names once for the cross-owner labels/links.
80108 let mut owners: HashMap<String, String> = HashMap::new();
81109 for repo in &public {
82110 if !owners.contains_key(&repo.owner_id) {
@@ -88,50 +116,16 @@ async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> {
88116 owners.insert(repo.owner_id.clone(), name);
89117 }
90118 }
91-
119+ let entries: Vec<RepoEntry> = public
120+ .iter()
121+ .map(|r| {
122+ let owner = owners.get(&r.owner_id).map_or("", String::as_str);
123+ RepoEntry::global(owner, r)
124+ })
125+ .collect();
92126 Ok(html! {
93- @if let Some(u) = user {
94- h1 { "Welcome, " (u.display_name.clone().unwrap_or_else(|| u.username.clone())) }
95- section {
96- h2 { "Your repositories" }
97- @if mine.is_empty() {
98- p class="muted" { "You have no repositories yet." }
99- } @else {
100- ul class="repo-list" {
101- @for repo in &mine {
102- li {
103- div class="name" {
104- a href={ "/" (u.username) "/" (repo.path) } { (repo.path) }
105- @if repo.is_private { " " span class="badge badge-private" { "private" } }
106- }
107- @if let Some(desc) = &repo.description { p class="muted" { (desc) } }
108- }
109- }
110- }
111- }
112- }
113- } @else {
114- h1 { "Welcome to " (state.config.instance.name) }
115- p { a class="btn btn-primary" href="/login" { "Sign in" } }
116- }
117- section {
118- h2 { "Public repositories" }
119- @if public.is_empty() {
120- p class="muted" { "No public repositories." }
121- } @else {
122- ul class="repo-list" {
123- @for repo in &public {
124- @let owner = owners.get(&repo.owner_id).cloned().unwrap_or_default();
125- li {
126- div class="name" {
127- a href={ "/" (owner) "/" (repo.path) } { (owner) "/" (repo.path) }
128- }
129- @if let Some(desc) = &repo.description { p class="muted" { (desc) } }
130- }
131- }
132- }
133- }
134- }
127+ h1 { "Explore" }
128+ (repo_card("Public repositories", "No public repositories.", &entries))
135129 })
136130 }
137131
crates/web/src/repo.rs +74 −17
@@ -166,10 +166,7 @@ pub async fn user_page(
166166 }
167167 }
168168 }
169- section {
170- h2 { "Repositories" }
171- (repo_list(&owner.username, &repos))
172- }
169+ (owned_repo_card(&owner.username, &repos))
173170 };
174171 Ok((jar, page(&chrome, &owner.username, body)).into_response())
175172 }
@@ -937,7 +934,7 @@ async fn group_listing(
937934 }
938935 }
939936 }
940- section { h2 { "Repositories" } (repo_list(&owner.username, &repos)) }
937+ (owned_repo_card(&owner.username, &repos))
941938 };
942939 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
943940 let title = format!("{}/{}", owner.username, group.path);
@@ -977,20 +974,74 @@ async fn visible_repos(
977974 Ok(out)
978975 }
979976
980-/// Render a list of repositories as links.
981-fn repo_list(owner: &str, repos: &[Repo]) -> Markup {
977+/// One row in a repository listing card.
978+pub(crate) struct RepoEntry {
979+ /// Link target.
980+ pub(crate) href: String,
981+ /// Display label (a bare repo path, or `owner/path` in cross-owner listings).
982+ pub(crate) label: String,
983+ /// Whether the repo is private (drives the badge and visibility subtitle).
984+ pub(crate) private: bool,
985+ /// The default branch, shown in the subtitle.
986+ pub(crate) default_branch: String,
987+ /// Optional one-line description, shown under the name when present.
988+ pub(crate) description: Option<String>,
989+}
990+
991+impl RepoEntry {
992+ /// Build an entry for a repo shown on its owner's own page (bare-path label).
993+ pub(crate) fn owned(owner: &str, repo: &Repo) -> Self {
994+ Self {
995+ href: format!("/{owner}/{}", repo.path),
996+ label: repo.path.clone(),
997+ private: repo.is_private,
998+ default_branch: repo.default_branch.clone(),
999+ description: repo.description.clone(),
1000+ }
1001+ }
1002+
1003+ /// Build an entry for a cross-owner listing (e.g. Explore), labelled
1004+ /// `owner/path`.
1005+ pub(crate) fn global(owner: &str, repo: &Repo) -> Self {
1006+ Self {
1007+ href: format!("/{owner}/{}", repo.path),
1008+ label: format!("{owner}/{}", repo.path),
1009+ private: repo.is_private,
1010+ default_branch: repo.default_branch.clone(),
1011+ description: repo.description.clone(),
1012+ }
1013+ }
1014+}
1015+
1016+/// Render a titled card of repository rows in the reference listing style: a
1017+/// card headed by `title`, one bordered row per repo with a bold name and a
1018+/// muted `visibility · default: branch` subtitle.
1019+pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Markup {
9821020 html! {
983- @if repos.is_empty() {
984- p class="muted" { "No repositories." }
985- } @else {
986- ul class="repo-list" {
987- @for repo in repos {
988- li {
989- div class="name" {
990- a href={ "/" (owner) "/" (repo.path) } { (repo.path) }
991- @if repo.is_private { " " span class="badge badge-private" { "private" } }
1021+ section class="listing" {
1022+ h2 { (title) }
1023+ @if entries.is_empty() {
1024+ div class="card" { p class="muted" { (empty) } }
1025+ } @else {
1026+ ul class="repo-list card" {
1027+ @for e in entries {
1028+ li {
1029+ a class="repo-row" href=(e.href) {
1030+ span class="repo-row-name" {
1031+ (icon(Icon::Box)) span { (e.label) }
1032+ @if e.private {
1033+ span class="badge badge-private" { "private" }
1034+ }
1035+ }
1036+ @if let Some(desc) = &e.description {
1037+ span class="repo-row-desc muted" { (desc) }
1038+ }
1039+ span class="repo-row-meta muted" {
1040+ (if e.private { "private" } else { "public" })
1041+ " · default: " (e.default_branch)
1042+ }
1043+ }
9921044 }
993- @if let Some(desc) = &repo.description { p class="muted" { (desc) } }
9941045 }
9951046 }
9961047 }
@@ -998,6 +1049,12 @@ fn repo_list(owner: &str, repos: &[Repo]) -> Markup {
9981049 }
9991050 }
10001051
1052+/// Render an owner's repositories as a listing card.
1053+fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
1054+ let entries: Vec<RepoEntry> = repos.iter().map(|r| RepoEntry::owned(owner, r)).collect();
1055+ repo_card("Repositories", "No repositories.", &entries)
1056+}
1057+
10011058 /// The repo sub-header: slug, tabs, and clone URLs.
10021059 fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
10031060 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);