forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.1 KiB
1//! Page: home. Landing page for visitors; repo dashboard for signed-in users.
2//!
3//! The dashboard is a greeting, the contribution calendar, then two columns: the user's
4//! repositories and the unread inbox rows that need them.
5
6use std::time::Duration;
7
8use forge_core::{Notification, Repo, Visibility};
9use time::OffsetDateTime;
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::page;
13use topcoat::view::{View, component, view};
14
15use crate::auth::{current_user, percent_encode};
16use crate::i18n::misc::{self as m, K as MK};
17use crate::i18n::orgs::{self as g, K};
18use crate::i18n::{self, Lang};
19use crate::pages::issue_parts::stamp_unix;
20use crate::pages::repo_settings::{notice_banner_for, take_notice};
21use crate::state::AppState;
22use crate::views::heatmap::{self, Calendar, contribution_calendar};
23use crate::views::{display_name, now_unix, relative_time, shell};
24
25/// How far back the calendar reads contributions: 53 weeks plus the days of the current week.
26const CALENDAR_DAYS: u64 = 371;
27
28/// Unread inbox rows in the "Needs your attention" column.
29const ATTENTION_ROWS: u32 = 5;
30
31/// What the signed-in dashboard shows, loaded once per page view.
32struct Dashboard {
33 name: String,
34 hour: u8,
35 repos: Vec<Repo>,
36 calendar: Calendar,
37 attention: Vec<AttentionRow>,
38}
39
40/// One unread inbox row, with its strings ready for the view.
41struct AttentionRow {
42 href: String,
43 reference: String,
44 title: String,
45 kind: &'static str,
46 relative: String,
47}
48
49#[page("/")]
50async 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/// Reads the dashboard data for a signed-in user.
77async 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]
110async 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]
126async 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/// The row for an unread inbox entry on the dashboard.
194fn 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(&note.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(&note.updated_at), now),
207 }
208}
209
210/// CSS class for a visibility badge.
211pub fn badge_class(visibility: Visibility) -> &'static str {
212 match visibility {
213 Visibility::Public => "badge public",
214 Visibility::Private => "badge private",
215 }
216}