| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use forge_core::{Notification, Repo, Visibility}; |
| 9 | use time::OffsetDateTime; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::page; |
| 13 | use topcoat::view::{View, component, view}; |
| 14 | |
| 15 | use crate::auth::{current_user, percent_encode}; |
| 16 | use crate::i18n::misc::{self as m, K as MK}; |
| 17 | use crate::i18n::orgs::{self as g, K}; |
| 18 | use crate::i18n::{self, Lang}; |
| 19 | use crate::pages::issue_parts::stamp_unix; |
| 20 | use crate::pages::repo_settings::{notice_banner_for, take_notice}; |
| 21 | use crate::state::AppState; |
| 22 | use crate::views::heatmap::{self, Calendar, contribution_calendar}; |
| 23 | use crate::views::{display_name, now_unix, relative_time, shell}; |
| 24 | |
| 25 | |
| 26 | const CALENDAR_DAYS: u64 = 371; |
| 27 | |
| 28 | |
| 29 | const ATTENTION_ROWS: u32 = 5; |
| 30 | |
| 31 | |
| 32 | struct Dashboard { |
| 33 | name: String, |
| 34 | hour: u8, |
| 35 | repos: Vec<Repo>, |
| 36 | calendar: Calendar, |
| 37 | attention: Vec<AttentionRow>, |
| 38 | } |
| 39 | |
| 40 | |
| 41 | struct AttentionRow { |
| 42 | href: String, |
| 43 | reference: String, |
| 44 | title: String, |
| 45 | kind: &'static str, |
| 46 | relative: String, |
| 47 | } |
| 48 | |
| 49 | #[page("/")] |
| 50 | async fn home(cx: &Cx) -> Result<impl View> { |
| 51 | let state = app_context::<AppState>(cx); |
| 52 | let viewer = current_user(cx).await?; |
| 53 | let lang = i18n::lang(cx, viewer.as_ref().map(|(u, _)| u)); |
| 54 | let dash = match &viewer { |
| 55 | Some((user, _)) => Some(load_dashboard(state, user, lang).await?), |
| 56 | None => None, |
| 57 | }; |
| 58 | let has_users = viewer.is_none() && state.store.user_count().await? > 0; |
| 59 | let title = if viewer.is_some() { |
| 60 | g::t(lang, K::Dashboard) |
| 61 | } else { |
| 62 | g::t(lang, K::Home) |
| 63 | }; |
| 64 | let notice = take_notice(cx); |
| 65 | Ok(view! { |
| 66 | shell(title: title, viewer: viewer.as_ref(), |
| 67 | notice_banner_for(lang: lang, title: notice) |
| 68 | if let Some(dash) = dash { |
| 69 | dashboard(dash: dash, lang: lang) |
| 70 | } else { |
| 71 | landing(has_users: has_users, lang: lang) |
| 72 | }) |
| 73 | }) |
| 74 | } |
| 75 | |
| 76 | |
| 77 | async fn load_dashboard( |
| 78 | state: &AppState, |
| 79 | user: &forge_core::User, |
| 80 | lang: Lang, |
| 81 | ) -> Result<Dashboard> { |
| 82 | let repos = state.store.list_repos_by_owner(&user.id).await?; |
| 83 | let now = OffsetDateTime::now_utc(); |
| 84 | let today = ( |
| 85 | i64::from(now.year()), |
| 86 | i64::from(u8::from(now.month())), |
| 87 | i64::from(now.day()), |
| 88 | ); |
| 89 | let since = forge_core::time::stamp_ago(Duration::from_secs(CALENDAR_DAYS * 86_400)); |
| 90 | let counts = state.store.contribution_counts(&user.id, &since).await?; |
| 91 | let calendar = heatmap::build(&counts, today); |
| 92 | let now_secs = now_unix(); |
| 93 | let attention = state |
| 94 | .store |
| 95 | .list_notifications(&user.id, true, None, ATTENTION_ROWS) |
| 96 | .await? |
| 97 | .iter() |
| 98 | .map(|note| attention_row(lang, note, now_secs)) |
| 99 | .collect(); |
| 100 | Ok(Dashboard { |
| 101 | name: display_name(lang, &user.username), |
| 102 | hour: now.hour(), |
| 103 | repos, |
| 104 | calendar, |
| 105 | attention, |
| 106 | }) |
| 107 | } |
| 108 | |
| 109 | #[component] |
| 110 | async fn landing(has_users: bool, lang: Lang) -> Result<impl View> { |
| 111 | Ok(view! { |
| 112 | <section class="hero"> |
| 113 | <h1>"forge"</h1> |
| 114 | <p class="muted">(g::t(lang, K::LandingTagline))</p> |
| 115 | <div class="actions"> |
| 116 | <a class="button primary" href="/login">(g::t(lang, K::LogIn))</a> |
| 117 | if !has_users { |
| 118 | <a class="button" href="/setup">(g::t(lang, K::SetUpForge))</a> |
| 119 | } |
| 120 | </div> |
| 121 | </section> |
| 122 | }) |
| 123 | } |
| 124 | |
| 125 | #[component] |
| 126 | async fn dashboard(dash: Dashboard, lang: Lang) -> Result<impl View> { |
| 127 | let empty = dash.repos.is_empty(); |
| 128 | let count = m::repositories_count(lang, dash.repos.len() as u64); |
| 129 | let greeting = m::greeting(lang, dash.hour, &dash.name); |
| 130 | let attention_empty = dash.attention.is_empty(); |
| 131 | Ok(view! { |
| 132 | <h1 class="greet">(greeting)</h1> |
| 133 | contribution_calendar(calendar: dash.calendar, lang: lang) |
| 134 | <div class="dash-cols"> |
| 135 | <section class="dash-repos" aria-labelledby="repos-h"> |
| 136 | <div class="sec-head"> |
| 137 | <h2 id="repos-h">(g::t(lang, K::YourRepositories))</h2> |
| 138 | <span class="small">(count)</span> |
| 139 | <a class="button primary" href="/new">(g::t(lang, K::NewRepository))</a> |
| 140 | </div> |
| 141 | if empty { |
| 142 | <p class="muted">(g::t(lang, K::NoReposYet))</p> |
| 143 | } else { |
| 144 | <div class="table-wrap"> |
| 145 | <table class="table"> |
| 146 | <thead> |
| 147 | <tr> |
| 148 | <th scope="col">(g::t(lang, K::Name))</th> |
| 149 | <th scope="col" class="dash-desc">(g::t(lang, K::Description))</th> |
| 150 | </tr> |
| 151 | </thead> |
| 152 | <tbody> |
| 153 | for repo in dash.repos.into_iter() { |
| 154 | <tr> |
| 155 | <td> |
| 156 | <a class="repo-name" href=(format!("/{}/{}", repo.owner_name, repo.name))> |
| 157 | (format!("{}/{}", repo.owner_name, repo.name)) |
| 158 | </a> |
| 159 | if repo.visibility == Visibility::Private { |
| 160 | <span class=(badge_class(repo.visibility))>(i18n::visibility_label(lang, repo.visibility))</span> |
| 161 | } |
| 162 | </td> |
| 163 | <td class="muted dash-desc">(repo.description)</td> |
| 164 | </tr> |
| 165 | } |
| 166 | </tbody> |
| 167 | </table> |
| 168 | </div> |
| 169 | } |
| 170 | </section> |
| 171 | <section class="dash-attention" aria-labelledby="attn-h"> |
| 172 | <div class="sec-head"> |
| 173 | <h2 id="attn-h">(m::t(lang, MK::NeedsAttention))</h2> |
| 174 | </div> |
| 175 | if attention_empty { |
| 176 | <p class="muted">(m::t(lang, MK::NothingNeedsYou))</p> |
| 177 | } else { |
| 178 | <ul class="attn"> |
| 179 | for item in dash.attention.into_iter() { |
| 180 | <li> |
| 181 | <a href=(item.href.as_str())><span class="mono">(item.reference.as_str())</span> (item.title.as_str())</a> |
| 182 | <span class="sub">(item.kind) " · " (item.relative.as_str())</span> |
| 183 | </li> |
| 184 | } |
| 185 | </ul> |
| 186 | } |
| 187 | <p class="small"><a href="/notifications">(m::t(lang, MK::ViewAllNotifications))</a></p> |
| 188 | </section> |
| 189 | </div> |
| 190 | }) |
| 191 | } |
| 192 | |
| 193 | |
| 194 | fn attention_row(lang: Lang, note: &Notification, now: i64) -> AttentionRow { |
| 195 | let kind = match (note.is_pull, note.issue_state.as_str()) { |
| 196 | (false, "open") => m::t(lang, MK::KindOpenIssue), |
| 197 | (false, _) => m::t(lang, MK::KindClosedIssue), |
| 198 | (true, "open") => m::t(lang, MK::KindOpenPull), |
| 199 | (true, _) => m::t(lang, MK::KindClosedPull), |
| 200 | }; |
| 201 | AttentionRow { |
| 202 | href: format!("/notifications/{}/open", percent_encode(¬e.id)), |
| 203 | reference: format!("{}/{} #{}", note.repo_owner, note.repo_name, note.number), |
| 204 | title: note.title.clone(), |
| 205 | kind, |
| 206 | relative: relative_time(lang, stamp_unix(¬e.updated_at), now), |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | |
| 211 | pub fn badge_class(visibility: Visibility) -> &'static str { |
| 212 | match visibility { |
| 213 | Visibility::Public => "badge public", |
| 214 | Visibility::Private => "badge private", |
| 215 | } |
| 216 | } |