Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +124 −11
| @@ -359,6 +359,9 @@ select:focus { | |||
| 359 | 359 | } | |
| 360 | 360 | .badge-private { | |
| 361 | 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 | 367 | /* Inline search row: input grows, button sits to its right with margin. */ | |
| @@ -461,6 +464,103 @@ select:focus { | |||
| 461 | 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 | 564 | /* ---- Avatars & profile header ---- */ | |
| 465 | 565 | .avatar { | |
| 466 | 566 | border-radius: 6px; | |
| @@ -546,6 +646,18 @@ select:focus { | |||
| 546 | 646 | display: inline-flex; | |
| 547 | 647 | align-items: center; | |
| 548 | 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 | 662 | .profile-joined { | |
| 551 | 663 | margin: 0; | |
| @@ -718,34 +830,33 @@ pre.code { | |||
| 718 | 830 | .repo-slug a { | |
| 719 | 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 | 834 | .repo-tabs, | |
| 723 | 835 | .subnav { | |
| 724 | 836 | display: flex; | |
| 725 | - | gap: 0.25rem; | |
| 837 | + | gap: 0.5rem; | |
| 726 | 838 | flex-wrap: wrap; | |
| 727 | - | margin: 0.75rem 0; | |
| 728 | - | border-bottom: 1px solid var(--fb-border); | |
| 839 | + | margin: 0.75rem 0 1.25rem; | |
| 729 | 840 | } | |
| 730 | 841 | .repo-tabs a, | |
| 731 | 842 | .subnav a { | |
| 732 | 843 | display: inline-flex; | |
| 733 | 844 | align-items: center; | |
| 734 | 845 | gap: 0.4rem; | |
| 735 | - | padding: 0.5rem 0.85rem; | |
| 846 | + | padding: 0.4rem 0.9rem; | |
| 847 | + | border-radius: 999px; | |
| 736 | 848 | color: var(--fb-fg-muted); | |
| 737 | - | border-bottom: 2px solid transparent; | |
| 738 | - | margin-bottom: -1px; | |
| 739 | 849 | } | |
| 740 | 850 | .repo-tabs a:hover, | |
| 741 | 851 | .subnav a:hover { | |
| 742 | 852 | color: var(--fb-fg); | |
| 853 | + | background: var(--fb-bg-inset); | |
| 743 | 854 | text-decoration: none; | |
| 744 | 855 | } | |
| 745 | 856 | .repo-tabs a[aria-current="page"], | |
| 746 | 857 | .subnav a[aria-current="page"] { | |
| 747 | 858 | color: var(--fb-fg); | |
| 748 | - | border-bottom-color: var(--fb-accent); | |
| 859 | + | background: var(--fb-bg-inset); | |
| 749 | 860 | font-weight: 600; | |
| 750 | 861 | } | |
| 751 | 862 | .subnav { | |
| @@ -1018,11 +1129,13 @@ td.diff-empty { | |||
| 1018 | 1129 | display: block; | |
| 1019 | 1130 | overflow-x: auto; | |
| 1020 | 1131 | } | |
| 1021 | - | /* Stack the profile columns on narrow screens. */ | |
| 1022 | - | .profile-layout { | |
| 1132 | + | /* Stack the profile and dashboard columns on narrow screens. */ | |
| 1133 | + | .profile-layout, | |
| 1134 | + | .dashboard { | |
| 1023 | 1135 | flex-direction: column; | |
| 1024 | 1136 | } | |
| 1025 | - | .profile-sidebar { | |
| 1137 | + | .profile-sidebar, | |
| 1138 | + | .dashboard-side { | |
| 1026 | 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 | 10 | //! [`serve`](mod@serve_assets) (assets/health). Every interactive element works | |
| 11 | 11 | //! without JavaScript; htmx only enhances. | |
| 12 | 12 | ||
| 13 | + | mod activity; | |
| 13 | 14 | mod assets; | |
| 14 | 15 | mod avatar; | |
| 15 | 16 | mod diff; | |
crates/web/src/pages.rs +25 −5
| @@ -65,10 +65,11 @@ pub async fn home( | |||
| 65 | 65 | MaybeUser(user): MaybeUser, | |
| 66 | 66 | jar: CookieJar, | |
| 67 | 67 | uri: Uri, | |
| 68 | + | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, | |
| 68 | 69 | ) -> AppResult<Response> { | |
| 69 | 70 | let (jar, chrome) = build_chrome(&state, jar, user.clone(), uri.path().to_string()); | |
| 70 | 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 | 73 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) | |
| 73 | 74 | } else { | |
| 74 | 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 | - | async fn dashboard_body(state: &AppState, user: &User) -> AppResult<Markup> { | |
| 215 | + | /// The dashboard: the viewer's contribution activity and recent commits, beside | |
| 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 | 221 | let mine = state.store.repos_by_owner(&user.id).await?; | |
| 222 | + | let needle = q.trim().to_lowercase(); | |
| 217 | 223 | let entries: Vec<RepoEntry> = mine | |
| 218 | 224 | .iter() | |
| 225 | + | .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle)) | |
| 219 | 226 | .map(|r| RepoEntry::owned(&user.username, r)) | |
| 220 | 227 | .collect(); | |
| 228 | + | ||
| 221 | 229 | Ok(html! { | |
| 222 | - | h1 { "Welcome, " (user.display_name.clone().unwrap_or_else(|| user.username.clone())) } | |
| 223 | - | (repo_card("Your repositories", "You have no repositories yet.", &entries)) | |
| 230 | + | div class="dashboard" { | |
| 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 | ||