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 {
339}339}
340340
341/* ---- Repo/list rows ---- */341/* ---- Repo/list rows ---- */
342.listing {
343 margin-bottom: 1.5rem;
344}
342.repo-list {345.repo-list {
343 list-style: none;346 list-style: none;
344 margin: 0;347 margin: 0;
@@ -353,6 +356,45 @@ select:focus {
353 font-size: 1.05em;356 font-size: 1.05em;
354}357}
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
356/* ---- Tables ---- */398/* ---- Tables ---- */
357table.list {399table.list {
358 width: 100%;400 width: 100%;
crates/web/src/lib.rs +1 −0
@@ -119,6 +119,7 @@ pub fn build_router(state: AppState) -> Router {
119 Router::new()119 Router::new()
120 .nest_service("/api/v1", api)120 .nest_service("/api/v1", api)
121 .route("/", get(pages::home))121 .route("/", get(pages::home))
122 .route("/explore", get(pages::explore))
122 .route("/login", get(pages::login_form).post(pages::login_submit))123 .route("/login", get(pages::login_form).post(pages::login_submit))
123 .route("/logout", post(pages::logout))124 .route("/logout", post(pages::logout))
124 .route(125 .route(
crates/web/src/pages.rs +47 −53
@@ -20,6 +20,7 @@ use crate::AppState;
20use crate::assets::Scheme;20use crate::assets::Scheme;
21use crate::error::{AppError, AppResult};21use crate::error::{AppError, AppResult};
22use crate::layout::{Chrome, page};22use crate::layout::{Chrome, page};
23use crate::repo::{RepoEntry, repo_card};
23use crate::session::{24use crate::session::{
24 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,25 MaybeUser, RequireUser, THEME_COOKIE, clear_cookie, ensure_csrf, session_cookie, verify_csrf,
25};26};
@@ -56,7 +57,8 @@ pub(crate) fn build_chrome(
56 (jar, chrome)57 (jar, chrome)
57}58}
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.
60pub async fn home(62pub async fn home(
61 State(state): State<AppState>,63 State(state): State<AppState>,
62 MaybeUser(user): MaybeUser,64 MaybeUser(user): MaybeUser,
@@ -64,19 +66,45 @@ pub async fn home(
64 uri: Uri,66 uri: Uri,
65) -> AppResult<Response> {67) -> AppResult<Response> {
66 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());68 let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string());
67 let body = home_body(&state, user.as_ref()).await?;69 if let Some(u) = user {
68 Ok((jar, page(&chrome, "Home", body)).into_response())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 }
69}76}
7077
71/// Render the home content: the viewer's own repos, plus public repos.78/// `GET /explore` — every public repository, for anyone.
72async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> {79pub async fn explore(
73 let mine = match user {80 State(state): State<AppState>,
74 Some(u) => state.store.repos_by_owner(&u.id).await?,81 MaybeUser(user): MaybeUser,
75 None => Vec::new(),82 jar: CookieJar,
76 };83 uri: Uri,
77 // Public repos, with owner names resolved for their links.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.
91async 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`.
104async fn explore_body(state: &AppState) -> AppResult<Markup> {
78 let all = state.store.list_repos().await?;105 let all = state.store.list_repos().await?;
79 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();106 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();
107 // Resolve owner names once for the cross-owner labels/links.
80 let mut owners: HashMap<String, String> = HashMap::new();108 let mut owners: HashMap<String, String> = HashMap::new();
81 for repo in &public {109 for repo in &public {
82 if !owners.contains_key(&repo.owner_id) {110 if !owners.contains_key(&repo.owner_id) {
@@ -88,50 +116,16 @@ async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> {
88 owners.insert(repo.owner_id.clone(), name);116 owners.insert(repo.owner_id.clone(), name);
89 }117 }
90 }118 }
91119 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();
92 Ok(html! {126 Ok(html! {
93 @if let Some(u) = user {127 h1 { "Explore" }
94 h1 { "Welcome, " (u.display_name.clone().unwrap_or_else(|| u.username.clone())) }128 (repo_card("Public repositories", "No public repositories.", &entries))
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 }
135 })129 })
136}130}
137131
crates/web/src/repo.rs +74 −17
@@ -166,10 +166,7 @@ pub async fn user_page(
166 }166 }
167 }167 }
168 }168 }
169 section {169 (owned_repo_card(&owner.username, &repos))
170 h2 { "Repositories" }
171 (repo_list(&owner.username, &repos))
172 }
173 };170 };
174 Ok((jar, page(&chrome, &owner.username, body)).into_response())171 Ok((jar, page(&chrome, &owner.username, body)).into_response())
175}172}
@@ -937,7 +934,7 @@ async fn group_listing(
937 }934 }
938 }935 }
939 }936 }
940 section { h2 { "Repositories" } (repo_list(&owner.username, &repos)) }937 (owned_repo_card(&owner.username, &repos))
941 };938 };
942 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());939 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
943 let title = format!("{}/{}", owner.username, group.path);940 let title = format!("{}/{}", owner.username, group.path);
@@ -977,20 +974,74 @@ async fn visible_repos(
977 Ok(out)974 Ok(out)
978}975}
979976
980/// Render a list of repositories as links.977/// One row in a repository listing card.
981fn repo_list(owner: &str, repos: &[Repo]) -> Markup {978pub(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
991impl 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.
1019pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Markup {
982 html! {1020 html! {
983 @if repos.is_empty() {1021 section class="listing" {
984 p class="muted" { "No repositories." }1022 h2 { (title) }
985 } @else {1023 @if entries.is_empty() {
986 ul class="repo-list" {1024 div class="card" { p class="muted" { (empty) } }
987 @for repo in repos {1025 } @else {
988 li {1026 ul class="repo-list card" {
989 div class="name" {1027 @for e in entries {
990 a href={ "/" (owner) "/" (repo.path) } { (repo.path) }1028 li {
991 @if repo.is_private { " " span class="badge badge-private" { "private" } }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 }
992 }1044 }
993 @if let Some(desc) = &repo.description { p class="muted" { (desc) } }
994 }1045 }
995 }1046 }
996 }1047 }
@@ -998,6 +1049,12 @@ fn repo_list(owner: &str, repos: &[Repo]) -> Markup {
998 }1049 }
999}1050}
10001051
1052/// Render an owner's repositories as a listing card.
1053fn 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
1001/// The repo sub-header: slug, tabs, and clone URLs.1058/// The repo sub-header: slug, tabs, and clone URLs.
1002fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {1059fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1003 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1060 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);