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 | /* ---- 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 | } |
| 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 | /* ---- Tables ---- */ | 398 | /* ---- Tables ---- */ |
| 357 | table.list { | 399 | table.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; | |||
| 20 | use crate::assets::Scheme; | 20 | use crate::assets::Scheme; |
| 21 | use crate::error::{AppError, AppResult}; | 21 | use crate::error::{AppError, AppResult}; |
| 22 | use crate::layout::{Chrome, page}; | 22 | use crate::layout::{Chrome, page}; |
| 23 | use crate::repo::{RepoEntry, repo_card}; | ||
| 23 | use crate::session::{ | 24 | use 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 | } |
| 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 | pub async fn home( | 62 | pub 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 | } |
| 70 | 77 | ||
| 71 | /// Render the home content: the viewer's own repos, plus public repos. | 78 | /// `GET /explore` — every public repository, for anyone. |
| 72 | async fn home_body(state: &AppState, user: Option<&User>) -> AppResult<Markup> { | 79 | pub 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. | ||
| 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 | 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 | } |
| 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 | 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 | } |
| 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 { | 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 | } |
| 979 | 976 | ||
| 980 | /// Render a list of repositories as links. | 977 | /// One row in a repository listing card. |
| 981 | fn repo_list(owner: &str, repos: &[Repo]) -> Markup { | 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 | 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 | } |
| 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 | /// The repo sub-header: slug, tabs, and clone URLs. | 1058 | /// The repo sub-header: slug, tabs, and clone URLs. |
| 1002 | fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | 1059 | fn 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); |