Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +124 −11
| @@ -359,6 +359,9 @@ select:focus { | |||
| 359 | } | 359 | } |
| 360 | .badge-private { | 360 | .badge-private { |
| 361 | color: var(--fb-fg-muted); | 361 | color: var(--fb-fg-muted); |
| 362 | /* An explicit muted border: Carbon's --fb-border equals --fb-bg-inset, so the | ||
| 363 | default badge border vanishes against a hovered inset row. */ | ||
| 364 | border-color: var(--fb-fg-muted); | ||
| 362 | } | 365 | } |
| 363 | 366 | ||
| 364 | /* Inline search row: input grows, button sits to its right with margin. */ | 367 | /* Inline search row: input grows, button sits to its right with margin. */ |
| @@ -461,6 +464,103 @@ select:focus { | |||
| 461 | font-size: 0.85em; | 464 | font-size: 0.85em; |
| 462 | } | 465 | } |
| 463 | 466 | ||
| 467 | /* ---- Dashboard (activity + repo sidebar) ---- */ | ||
| 468 | .dashboard { | ||
| 469 | display: flex; | ||
| 470 | gap: 1.5rem; | ||
| 471 | align-items: flex-start; | ||
| 472 | } | ||
| 473 | .dashboard-main { | ||
| 474 | flex: 1; | ||
| 475 | min-width: 0; | ||
| 476 | } | ||
| 477 | .dashboard-side { | ||
| 478 | flex: none; | ||
| 479 | width: 20rem; | ||
| 480 | } | ||
| 481 | .dashboard-side .search-row { | ||
| 482 | margin-top: 0; | ||
| 483 | } | ||
| 484 | |||
| 485 | /* Contribution heatmap. */ | ||
| 486 | .heatmap-wrap { | ||
| 487 | overflow-x: auto; | ||
| 488 | padding-bottom: 0.25rem; | ||
| 489 | } | ||
| 490 | .heatmap { | ||
| 491 | display: block; | ||
| 492 | } | ||
| 493 | .hm-label { | ||
| 494 | fill: var(--fb-fg-muted); | ||
| 495 | font-size: 9px; | ||
| 496 | font-family: var(--fb-font-sans); | ||
| 497 | } | ||
| 498 | .hm-cell { | ||
| 499 | fill: var(--fb-bg-inset); | ||
| 500 | } | ||
| 501 | .hm-cell[data-level="1"] { | ||
| 502 | fill: color-mix(in srgb, var(--fb-accent) 30%, var(--fb-bg-inset)); | ||
| 503 | } | ||
| 504 | .hm-cell[data-level="2"] { | ||
| 505 | fill: color-mix(in srgb, var(--fb-accent) 55%, var(--fb-bg-inset)); | ||
| 506 | } | ||
| 507 | .hm-cell[data-level="3"] { | ||
| 508 | fill: color-mix(in srgb, var(--fb-accent) 78%, var(--fb-bg-inset)); | ||
| 509 | } | ||
| 510 | .hm-cell[data-level="4"] { | ||
| 511 | fill: var(--fb-accent); | ||
| 512 | } | ||
| 513 | .heatmap-caption { | ||
| 514 | margin: 0.25rem 0 1.5rem; | ||
| 515 | font-size: 0.85em; | ||
| 516 | } | ||
| 517 | |||
| 518 | /* Recent-activity feed. */ | ||
| 519 | .activity-feed { | ||
| 520 | list-style: none; | ||
| 521 | margin: 0; | ||
| 522 | padding: 0; | ||
| 523 | } | ||
| 524 | .activity-item { | ||
| 525 | padding: 0.75rem 1rem; | ||
| 526 | border-bottom: 1px solid var(--fb-border); | ||
| 527 | } | ||
| 528 | .activity-item:last-child { | ||
| 529 | border-bottom: none; | ||
| 530 | } | ||
| 531 | .activity-head { | ||
| 532 | display: flex; | ||
| 533 | align-items: center; | ||
| 534 | gap: 0.4rem; | ||
| 535 | flex-wrap: wrap; | ||
| 536 | font-size: 0.9em; | ||
| 537 | } | ||
| 538 | .activity-time { | ||
| 539 | margin-left: auto; | ||
| 540 | } | ||
| 541 | .activity-commit { | ||
| 542 | display: flex; | ||
| 543 | gap: 0.6rem; | ||
| 544 | margin-top: 0.35rem; | ||
| 545 | align-items: baseline; | ||
| 546 | color: var(--fb-fg); | ||
| 547 | } | ||
| 548 | .activity-commit:hover { | ||
| 549 | color: var(--fb-accent); | ||
| 550 | text-decoration: none; | ||
| 551 | } | ||
| 552 | .commit-sha { | ||
| 553 | flex: none; | ||
| 554 | color: var(--fb-fg-muted); | ||
| 555 | font-size: 0.85em; | ||
| 556 | } | ||
| 557 | .commit-summary { | ||
| 558 | min-width: 0; | ||
| 559 | overflow: hidden; | ||
| 560 | text-overflow: ellipsis; | ||
| 561 | white-space: nowrap; | ||
| 562 | } | ||
| 563 | |||
| 464 | /* ---- Avatars & profile header ---- */ | 564 | /* ---- Avatars & profile header ---- */ |
| 465 | .avatar { | 565 | .avatar { |
| 466 | border-radius: 6px; | 566 | border-radius: 6px; |
| @@ -546,6 +646,18 @@ select:focus { | |||
| 546 | display: inline-flex; | 646 | display: inline-flex; |
| 547 | align-items: center; | 647 | align-items: center; |
| 548 | gap: 0.5rem; | 648 | gap: 0.5rem; |
| 649 | min-width: 0; | ||
| 650 | } | ||
| 651 | .profile-links li .icon { | ||
| 652 | flex: none; | ||
| 653 | color: var(--fb-fg-muted); | ||
| 654 | } | ||
| 655 | .profile-links a { | ||
| 656 | color: var(--fb-accent); | ||
| 657 | min-width: 0; | ||
| 658 | overflow: hidden; | ||
| 659 | text-overflow: ellipsis; | ||
| 660 | white-space: nowrap; | ||
| 549 | } | 661 | } |
| 550 | .profile-joined { | 662 | .profile-joined { |
| 551 | margin: 0; | 663 | margin: 0; |
| @@ -718,34 +830,33 @@ pre.code { | |||
| 718 | .repo-slug a { | 830 | .repo-slug a { |
| 719 | color: var(--fb-accent); | 831 | color: var(--fb-accent); |
| 720 | } | 832 | } |
| 721 | /* Horizontal tab strips (repo sub-nav, explore sub-nav). */ | 833 | /* Tab strips (repo sub-nav, explore sub-nav): active-pill style. */ |
| 722 | .repo-tabs, | 834 | .repo-tabs, |
| 723 | .subnav { | 835 | .subnav { |
| 724 | display: flex; | 836 | display: flex; |
| 725 | gap: 0.25rem; | 837 | gap: 0.5rem; |
| 726 | flex-wrap: wrap; | 838 | flex-wrap: wrap; |
| 727 | margin: 0.75rem 0; | 839 | margin: 0.75rem 0 1.25rem; |
| 728 | border-bottom: 1px solid var(--fb-border); | ||
| 729 | } | 840 | } |
| 730 | .repo-tabs a, | 841 | .repo-tabs a, |
| 731 | .subnav a { | 842 | .subnav a { |
| 732 | display: inline-flex; | 843 | display: inline-flex; |
| 733 | align-items: center; | 844 | align-items: center; |
| 734 | gap: 0.4rem; | 845 | gap: 0.4rem; |
| 735 | padding: 0.5rem 0.85rem; | 846 | padding: 0.4rem 0.9rem; |
| 847 | border-radius: 999px; | ||
| 736 | color: var(--fb-fg-muted); | 848 | color: var(--fb-fg-muted); |
| 737 | border-bottom: 2px solid transparent; | ||
| 738 | margin-bottom: -1px; | ||
| 739 | } | 849 | } |
| 740 | .repo-tabs a:hover, | 850 | .repo-tabs a:hover, |
| 741 | .subnav a:hover { | 851 | .subnav a:hover { |
| 742 | color: var(--fb-fg); | 852 | color: var(--fb-fg); |
| 853 | background: var(--fb-bg-inset); | ||
| 743 | text-decoration: none; | 854 | text-decoration: none; |
| 744 | } | 855 | } |
| 745 | .repo-tabs a[aria-current="page"], | 856 | .repo-tabs a[aria-current="page"], |
| 746 | .subnav a[aria-current="page"] { | 857 | .subnav a[aria-current="page"] { |
| 747 | color: var(--fb-fg); | 858 | color: var(--fb-fg); |
| 748 | border-bottom-color: var(--fb-accent); | 859 | background: var(--fb-bg-inset); |
| 749 | font-weight: 600; | 860 | font-weight: 600; |
| 750 | } | 861 | } |
| 751 | .subnav { | 862 | .subnav { |
| @@ -1018,11 +1129,13 @@ td.diff-empty { | |||
| 1018 | display: block; | 1129 | display: block; |
| 1019 | overflow-x: auto; | 1130 | overflow-x: auto; |
| 1020 | } | 1131 | } |
| 1021 | /* Stack the profile columns on narrow screens. */ | 1132 | /* Stack the profile and dashboard columns on narrow screens. */ |
| 1022 | .profile-layout { | 1133 | .profile-layout, |
| 1134 | .dashboard { | ||
| 1023 | flex-direction: column; | 1135 | flex-direction: column; |
| 1024 | } | 1136 | } |
| 1025 | .profile-sidebar { | 1137 | .profile-sidebar, |
| 1138 | .dashboard-side { | ||
| 1026 | width: 100%; | 1139 | width: 100%; |
| 1027 | } | 1140 | } |
| 1028 | } | 1141 | } |
crates/web/src/activity.rs +288 −0
| @@ -0,0 +1,288 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! The dashboard's contribution activity: a 53-week commit heatmap and a recent | ||
| 6 | //! commit feed, computed across the viewer's own repositories. | ||
| 7 | //! | ||
| 8 | //! Commits are attributed to the viewer by author email (case-insensitive) over | ||
| 9 | //! the trailing 53 weeks. Scanning is bounded per repo so a large history never | ||
| 10 | //! makes the dashboard slow. | ||
| 11 | |||
| 12 | use std::cmp::Reverse; | ||
| 13 | use std::fmt::Write as _; | ||
| 14 | |||
| 15 | use maud::{Markup, PreEscaped, html}; | ||
| 16 | use model::User; | ||
| 17 | use time::{Duration, OffsetDateTime}; | ||
| 18 | |||
| 19 | use crate::AppState; | ||
| 20 | use crate::error::AppResult; | ||
| 21 | use crate::icons::{Icon, icon}; | ||
| 22 | use crate::repo::git_read; | ||
| 23 | |||
| 24 | /// Weeks shown across the heatmap (a year plus the current partial week). | ||
| 25 | const WEEKS: usize = 53; | ||
| 26 | /// Commits scanned per repo on its default branch — bounds dashboard cost. | ||
| 27 | const SCAN_PER_REPO: usize = 400; | ||
| 28 | /// Recent commits shown in the activity feed. | ||
| 29 | const FEED_LIMIT: usize = 20; | ||
| 30 | |||
| 31 | /// One commit in the activity feed. | ||
| 32 | pub struct FeedItem { | ||
| 33 | /// The repo's owner username. | ||
| 34 | pub owner: String, | ||
| 35 | /// The repo path. | ||
| 36 | pub repo: String, | ||
| 37 | /// The branch the commit is on (the default branch). | ||
| 38 | pub branch: String, | ||
| 39 | /// The abbreviated commit id. | ||
| 40 | pub short: String, | ||
| 41 | /// The full commit id, for the link. | ||
| 42 | pub oid: String, | ||
| 43 | /// The commit summary line. | ||
| 44 | pub summary: String, | ||
| 45 | /// Author time, epoch milliseconds. | ||
| 46 | pub time_ms: i64, | ||
| 47 | } | ||
| 48 | |||
| 49 | /// The computed activity for a user. | ||
| 50 | pub struct Activity { | ||
| 51 | /// Per-cell commit counts, indexed `week * 7 + weekday` (weekday 0 = Monday). | ||
| 52 | counts: Vec<u32>, | ||
| 53 | /// Total commits in the window. | ||
| 54 | total: u32, | ||
| 55 | /// The Monday of the first (leftmost) column. | ||
| 56 | start: time::Date, | ||
| 57 | /// The most recent commits, newest first. | ||
| 58 | feed: Vec<FeedItem>, | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Compute the viewer's activity across their own repositories. | ||
| 62 | pub async fn compute(state: &AppState, user: &User, now_ms: i64) -> AppResult<Activity> { | ||
| 63 | let repos = state.store.repos_by_owner(&user.id).await?; | ||
| 64 | let email = user.email.to_lowercase(); | ||
| 65 | |||
| 66 | // The grid ends today and starts on the Monday 53 weeks back. | ||
| 67 | let today = OffsetDateTime::from_unix_timestamp(now_ms.div_euclid(1000)) | ||
| 68 | .unwrap_or(OffsetDateTime::UNIX_EPOCH) | ||
| 69 | .date(); | ||
| 70 | let this_monday = today - Duration::days(i64::from(today.weekday().number_days_from_monday())); | ||
| 71 | let start = this_monday - Duration::days(i64::try_from((WEEKS - 1) * 7).unwrap_or(0)); | ||
| 72 | |||
| 73 | let mut counts = vec![0u32; WEEKS * 7]; | ||
| 74 | let mut feed: Vec<FeedItem> = Vec::new(); | ||
| 75 | |||
| 76 | for repo in &repos { | ||
| 77 | let rev_name = repo.default_branch.clone(); | ||
| 78 | let commits = git_read(state, &repo.id, move |r| { | ||
| 79 | let rev = r.resolve_ref(&rev_name)?; | ||
| 80 | r.commits(&rev, None, git::Page::first(SCAN_PER_REPO)) | ||
| 81 | }) | ||
| 82 | .await; | ||
| 83 | let Ok(commits) = commits else { continue }; | ||
| 84 | |||
| 85 | for c in commits { | ||
| 86 | if !c.author.email.eq_ignore_ascii_case(&email) { | ||
| 87 | continue; | ||
| 88 | } | ||
| 89 | // Bucket into the heatmap when inside the window. | ||
| 90 | if let Ok(dt) = OffsetDateTime::from_unix_timestamp(c.author.time_ms.div_euclid(1000)) { | ||
| 91 | let offset = (dt.date() - start).whole_days(); | ||
| 92 | let total_days = i64::try_from(WEEKS * 7).unwrap_or(0); | ||
| 93 | if (0..total_days).contains(&offset) | ||
| 94 | && let Ok(idx) = usize::try_from(offset) | ||
| 95 | { | ||
| 96 | counts[idx] += 1; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | feed.push(FeedItem { | ||
| 100 | owner: user.username.clone(), | ||
| 101 | repo: repo.path.clone(), | ||
| 102 | branch: repo.default_branch.clone(), | ||
| 103 | short: c.oid.short().to_string(), | ||
| 104 | oid: c.oid.to_string(), | ||
| 105 | summary: c.summary.clone(), | ||
| 106 | time_ms: c.author.time_ms, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | let total = counts.iter().sum(); | ||
| 112 | feed.sort_by_key(|i| Reverse(i.time_ms)); | ||
| 113 | feed.truncate(FEED_LIMIT); | ||
| 114 | |||
| 115 | Ok(Activity { | ||
| 116 | counts, | ||
| 117 | total, | ||
| 118 | start, | ||
| 119 | feed, | ||
| 120 | }) | ||
| 121 | } | ||
| 122 | |||
| 123 | impl Activity { | ||
| 124 | /// The intensity level (0–4) for a commit count, scaled to the busiest day. | ||
| 125 | fn level(&self, count: u32) -> u8 { | ||
| 126 | if count == 0 { | ||
| 127 | return 0; | ||
| 128 | } | ||
| 129 | let max = self.counts.iter().copied().max().unwrap_or(1).max(1); | ||
| 130 | let step = count.saturating_sub(1).saturating_mul(4) / max; | ||
| 131 | 1 + u8::try_from(step.min(3)).unwrap_or(3) | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Render the contribution heatmap as an inline SVG with month and weekday | ||
| 135 | /// labels. | ||
| 136 | pub fn heatmap(&self) -> Markup { | ||
| 137 | // Geometry, in SVG user units. | ||
| 138 | let cell = 11u32; | ||
| 139 | let gap = 3u32; | ||
| 140 | let step = cell + gap; | ||
| 141 | let left = 30u32; // weekday-label gutter | ||
| 142 | let top = 18u32; // month-label strip | ||
| 143 | let width = left + u32::try_from(WEEKS).unwrap_or(53) * step; | ||
| 144 | let height = top + 7 * step; | ||
| 145 | |||
| 146 | let month_name = |m: time::Month| match m { | ||
| 147 | time::Month::January => "Jan", | ||
| 148 | time::Month::February => "Feb", | ||
| 149 | time::Month::March => "Mar", | ||
| 150 | time::Month::April => "Apr", | ||
| 151 | time::Month::May => "May", | ||
| 152 | time::Month::June => "Jun", | ||
| 153 | time::Month::July => "Jul", | ||
| 154 | time::Month::August => "Aug", | ||
| 155 | time::Month::September => "Sep", | ||
| 156 | time::Month::October => "Oct", | ||
| 157 | time::Month::November => "Nov", | ||
| 158 | time::Month::December => "Dec", | ||
| 159 | }; | ||
| 160 | |||
| 161 | let mut body = String::new(); | ||
| 162 | // Month labels: at the first column whose Monday introduces a new month. | ||
| 163 | let mut prev_month: Option<time::Month> = None; | ||
| 164 | for week in 0..WEEKS { | ||
| 165 | let wk = u32::try_from(week).unwrap_or(0); | ||
| 166 | let col_date = self.start + Duration::days(i64::from(wk) * 7); | ||
| 167 | let m = col_date.month(); | ||
| 168 | if prev_month != Some(m) { | ||
| 169 | prev_month = Some(m); | ||
| 170 | let x = left + wk * step; | ||
| 171 | let _ = write!( | ||
| 172 | body, | ||
| 173 | r#"<text x="{x}" y="12" class="hm-label">{}</text>"#, | ||
| 174 | month_name(m) | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | } | ||
| 178 | // Weekday labels (Mon/Wed/Fri). | ||
| 179 | for (row, label) in [(0u32, "Mon"), (2, "Wed"), (4, "Fri")] { | ||
| 180 | let y = top + row * step + cell; | ||
| 181 | let _ = write!( | ||
| 182 | body, | ||
| 183 | r#"<text x="0" y="{y}" class="hm-label">{label}</text>"# | ||
| 184 | ); | ||
| 185 | } | ||
| 186 | // Cells. | ||
| 187 | for week in 0..WEEKS { | ||
| 188 | let wk = u32::try_from(week).unwrap_or(0); | ||
| 189 | for day in 0..7u32 { | ||
| 190 | let idx = week * 7 + usize::try_from(day).unwrap_or(0); | ||
| 191 | let count = self.counts[idx]; | ||
| 192 | let x = left + wk * step; | ||
| 193 | let y = top + day * step; | ||
| 194 | let lvl = self.level(count); | ||
| 195 | let date = self.start + Duration::days(i64::try_from(idx).unwrap_or(0)); | ||
| 196 | let _ = write!( | ||
| 197 | body, | ||
| 198 | r#"<rect x="{x}" y="{y}" width="{cell}" height="{cell}" rx="2" class="hm-cell" data-level="{lvl}"><title>{count} on {}-{:02}-{:02}</title></rect>"#, | ||
| 199 | date.year(), | ||
| 200 | u8::from(date.month()), | ||
| 201 | date.day(), | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | html! { | ||
| 207 | div class="heatmap-wrap" { | ||
| 208 | svg class="heatmap" viewBox=(format!("0 0 {width} {height}")) | ||
| 209 | width=(width) height=(height) role="img" | ||
| 210 | aria-label=(format!("{} contributions in the last year", self.total)) { | ||
| 211 | (PreEscaped(body)) | ||
| 212 | } | ||
| 213 | } | ||
| 214 | p class="muted heatmap-caption" { | ||
| 215 | (self.total) " contributions in the last year" | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | /// Render the recent-activity commit feed. | ||
| 221 | pub fn feed_section(&self, now_ms: i64) -> Markup { | ||
| 222 | html! { | ||
| 223 | section class="listing" { | ||
| 224 | h2 { "Recent activity" } | ||
| 225 | @if self.feed.is_empty() { | ||
| 226 | div class="card" { p class="muted" { "No recent activity." } } | ||
| 227 | } @else { | ||
| 228 | ul class="activity-feed card" { | ||
| 229 | @for item in &self.feed { | ||
| 230 | @let base = format!("/{}/{}", item.owner, item.repo); | ||
| 231 | li class="activity-item" { | ||
| 232 | div class="activity-head muted" { | ||
| 233 | (icon(Icon::GitBranch)) | ||
| 234 | " pushed to " | ||
| 235 | span class="badge" { (item.branch) } | ||
| 236 | " at " | ||
| 237 | a href=(base) { (item.owner) "/" (item.repo) } | ||
| 238 | span class="activity-time" { (fmt_relative(item.time_ms, now_ms)) } | ||
| 239 | } | ||
| 240 | a class="activity-commit" href=(format!("{base}/-/commit/{}", item.oid)) { | ||
| 241 | span class="mono commit-sha" { (item.short) } | ||
| 242 | span class="commit-summary" { (item.summary) } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | } | ||
| 246 | } | ||
| 247 | } | ||
| 248 | } | ||
| 249 | } | ||
| 250 | } | ||
| 251 | } | ||
| 252 | |||
| 253 | /// Format an epoch-millisecond timestamp relative to `now_ms`, e.g. `3 hours | ||
| 254 | /// ago`, falling back to an absolute date past a month. | ||
| 255 | fn fmt_relative(ms: i64, now_ms: i64) -> String { | ||
| 256 | let secs = (now_ms - ms).div_euclid(1000).max(0); | ||
| 257 | let mins = secs / 60; | ||
| 258 | let hours = mins / 60; | ||
| 259 | let days = hours / 24; | ||
| 260 | if secs < 60 { | ||
| 261 | "just now".to_string() | ||
| 262 | } else if mins < 60 { | ||
| 263 | plural(mins, "minute") | ||
| 264 | } else if hours < 24 { | ||
| 265 | plural(hours, "hour") | ||
| 266 | } else if days < 30 { | ||
| 267 | plural(days, "day") | ||
| 268 | } else { | ||
| 269 | match OffsetDateTime::from_unix_timestamp(ms.div_euclid(1000)) { | ||
| 270 | Ok(dt) => format!( | ||
| 271 | "on {}-{:02}-{:02}", | ||
| 272 | dt.year(), | ||
| 273 | u8::from(dt.month()), | ||
| 274 | dt.day() | ||
| 275 | ), | ||
| 276 | Err(_) => "a while ago".to_string(), | ||
| 277 | } | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | /// `N unit(s) ago`, pluralizing the unit. | ||
| 282 | fn plural(n: i64, unit: &str) -> String { | ||
| 283 | if n == 1 { | ||
| 284 | format!("1 {unit} ago") | ||
| 285 | } else { | ||
| 286 | format!("{n} {unit}s ago") | ||
| 287 | } | ||
| 288 | } | ||
crates/web/src/lib.rs +1 −0
| @@ -10,6 +10,7 @@ | |||
| 10 | //! [`serve`](mod@serve_assets) (assets/health). Every interactive element works | 10 | //! [`serve`](mod@serve_assets) (assets/health). Every interactive element works |
| 11 | //! without JavaScript; htmx only enhances. | 11 | //! without JavaScript; htmx only enhances. |
| 12 | 12 | ||
| 13 | mod activity; | ||
| 13 | mod assets; | 14 | mod assets; |
| 14 | mod avatar; | 15 | mod avatar; |
| 15 | mod diff; | 16 | mod diff; |
crates/web/src/pages.rs +25 −5
| @@ -65,10 +65,11 @@ pub async fn home( | |||
| 65 | MaybeUser(user): MaybeUser, | 65 | MaybeUser(user): MaybeUser, |
| 66 | jar: CookieJar, | 66 | jar: CookieJar, |
| 67 | uri: Uri, | 67 | uri: Uri, |
| 68 | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, | ||
| 68 | ) -> AppResult<Response> { | 69 | ) -> AppResult<Response> { |
| 69 | let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string()); | 70 | let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string()); |
| 70 | if let Some(u) = user { | 71 | if let Some(u) = user { |
| 71 | let body = dashboard_body(&state, &u).await?; | 72 | let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default()).await?; |
| 72 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) | 73 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) |
| 73 | } else { | 74 | } else { |
| 74 | let body = explore_repos_body(&state, "").await?; | 75 | let body = explore_repos_body(&state, "").await?; |
| @@ -211,16 +212,35 @@ fn user_card(users: &[User]) -> Markup { | |||
| 211 | } | 212 | } |
| 212 | } | 213 | } |
| 213 | 214 | ||
| 214 | /// The dashboard: a greeting and the viewer's own repositories. | 215 | /// The dashboard: the viewer's contribution activity and recent commits, beside |
| 215 | async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> { | 216 | /// a filterable list of their repositories. |
| 217 | async fn dashboard_body(state: &AppState, user: &User, q: &str) -> AppResult<Markup> { | ||
| 218 | let now = now_ms(); | ||
| 219 | let activity = crate::activity::compute(state, user, now).await?; | ||
| 220 | |||
| 216 | let mine = state.store.repos_by_owner(&user.id).await?; | 221 | let mine = state.store.repos_by_owner(&user.id).await?; |
| 222 | let needle = q.trim().to_lowercase(); | ||
| 217 | let entries: Vec<RepoEntry> = mine | 223 | let entries: Vec<RepoEntry> = mine |
| 218 | .iter() | 224 | .iter() |
| 225 | .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle)) | ||
| 219 | .map(|r| RepoEntry::owned(&user.username, r)) | 226 | .map(|r| RepoEntry::owned(&user.username, r)) |
| 220 | .collect(); | 227 | .collect(); |
| 228 | |||
| 221 | Ok(html! { | 229 | Ok(html! { |
| 222 | h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) } | 230 | div class="dashboard" { |
| 223 | (repo_card("Your repositories", "You have no repositories yet.", &entries)) | 231 | div class="dashboard-main" { |
| 232 | (activity.heatmap()) | ||
| 233 | (activity.feed_section(now)) | ||
| 234 | } | ||
| 235 | aside class="dashboard-side" { | ||
| 236 | form class="search-row" method="get" { | ||
| 237 | input type="search" name="q" value=(q) | ||
| 238 | placeholder="Search repositories…" aria-label="Search repositories"; | ||
| 239 | button class="btn btn-primary" type="submit" { "Search" } | ||
| 240 | } | ||
| 241 | (repo_card("Repositories", "You have no repositories yet.", &entries)) | ||
| 242 | } | ||
| 243 | } | ||
| 224 | }) | 244 | }) |
| 225 | } | 245 | } |
| 226 | 246 | ||