Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +42 −0
| @@ -339,6 +339,9 @@ select:focus { | |||
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | /* ---- Repo/list rows ---- */ | |
| 342 | + | .listing { | |
| 343 | + | margin-bottom: 1.5rem; | |
| 344 | + | } | |
| 342 | 345 | .repo-list { | |
| 343 | 346 | list-style: none; | |
| 344 | 347 | margin: 0; | |
| @@ -353,6 +356,45 @@ select:focus { | |||
| 353 | 356 | font-size: 1.05em; | |
| 354 | 357 | } | |
| 355 | 358 | ||
| 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 | 398 | /* ---- Tables ---- */ | |
| 357 | 399 | table.list { | |
| 358 | 400 | width: 100%; | |
crates/web/src/lib.rs +1 −0
| @@ -119,6 +119,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 119 | 119 | Router::new() | |
| 120 | 120 | .nest_service("/api/v1", api) | |
| 121 | 121 | .route("/", get(pages::home)) | |
| 122 | + | .route("/explore", get(pages::explore)) | |
| 122 | 123 | .route("/login", get(pages::login_form).post(pages::login_submit)) | |
| 123 | 124 | .route("/logout", post(pages::logout)) | |
| 124 | 125 | .route( | |
crates/web/src/pages.rs +47 −53
| @@ -20,6 +20,7 @@ use crate::AppState; | |||
| 20 | 20 | use crate::assets::Scheme; | |
| 21 | 21 | use crate::error::{AppError, AppResult}; | |
| 22 | 22 | use crate::layout::{Chrome, page}; | |
| 23 | + | use crate::repo::{RepoEntry, repo_card}; | |
| 23 | 24 | use crate::session::{ | |
| 24 | 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 | 57 | (jar, chrome) | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 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. | |
| 60 | 62 | pub async fn home( | |
| 61 | 63 | State(state): State<AppState>, | |
| 62 | 64 | MaybeUser(user): MaybeUser, | |
| @@ -64,19 +66,45 @@ pub async fn home( | |||
| 64 | 66 | uri: Uri, | |
| 65 | 67 | ) -> AppResult<Response> { | |
| 66 | 68 | 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 | + | } | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 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> { | |
| 78 | 105 | let all = state.store.list_repos().await?; | |
| 79 | 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 | 108 | let mut owners: HashMap<String, String> = HashMap::new(); | |
| 81 | 109 | for repo in &public { | |
| 82 | 110 | if !owners.contains_key(&repo.owner_id) { | |
| @@ -88,50 +116,16 @@ async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> { | |||
| 88 | 116 | owners.insert(repo.owner_id.clone(), name); | |
| 89 | 117 | } | |
| 90 | 118 | } | |
| 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(); | |
| 92 | 126 | 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)) | |
| 135 | 129 | }) | |
| 136 | 130 | } | |
| 137 | 131 | ||
crates/web/src/repo.rs +74 −17
| @@ -166,10 +166,7 @@ pub async fn user_page( | |||
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | 168 | } | |
| 169 | - | section { | |
| 170 | - | h2 { "Repositories" } | |
| 171 | - | (repo_list(&owner.username, &repos)) | |
| 172 | - | } | |
| 169 | + | (owned_repo_card(&owner.username, &repos)) | |
| 173 | 170 | }; | |
| 174 | 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 | 939 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 943 | 940 | let title = format!("{}/{}", owner.username, group.path); | |
| @@ -977,20 +974,74 @@ async fn visible_repos( | |||
| 977 | 974 | Ok(out) | |
| 978 | 975 | } | |
| 979 | 976 | ||
| 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 { | |
| 982 | 1020 | 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 | + | } | |
| 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 | } | |
| 1000 | 1051 | ||
| 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 | + | ||
| 1001 | 1058 | /// The repo sub-header: slug, tabs, and clone URLs. | |
| 1002 | 1059 | fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | |
| 1003 | 1060 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |