steid

@jamesgill /

12.8 KBCode·Blame·Raw
1//! The profile page — `/{handle}`.
2//!
3//! Public. This is the first page that renders for anonymous visitors, so everything it
4//! shows comes from [`PublicProfile`](crate::application::PublicProfile), which has no
5//! private fields to leak.
6
7use topcoat::{
8 Result,
9 context::Cx,
10 router::{
11 error::{RouterErrorExt, not_found},
12 page, path_param,
13 },
14 view::{component, view},
15};
16
17use crate::{
18 application::{PublicProfile, RepoSummary, list_repos, view_profile},
19 components::{
20 badge::{BadgeVariant, badge},
21 button::{ButtonSize, ButtonVariant, button_variants},
22 },
23 domain::OrgName,
24};
25
26use super::{
27 browse::ago,
28 context::{current_actor, memberships, orgs, repos, server_error},
29 layout::narrow,
30};
31
32/// `{handle}` from the path. The struct name snake-cased is the parameter name.
33///
34/// Declared as `str` so the raw segment arrives unparsed — validation is `OrgName`'s
35/// job, and a handle that fails it is a page that does not exist rather than a bad
36/// request.
37#[path_param]
38struct Handle(str);
39
40/// Resolves `{handle}` from the path, or 404.
41///
42/// A malformed handle 404s rather than erroring: `/Not A Handle` is a page that does
43/// not exist, not a bad request.
44///
45/// Shared with `/api`, which resolves the same segment without wanting a profile —
46/// listing repositories under a handle needs the handle and nothing else.
47pub(super) fn handle_param(cx: &Cx) -> Result<OrgName> {
48 Ok(OrgName::new(path_param::<Handle>(cx)).map_err(|_| not_found())?)
49}
50
51/// Resolves `{handle}` from the path into a profile, or 404.
52pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
53 let handle = handle_param(cx)?;
54 let actor = current_actor(cx).await?;
55
56 Ok(view_profile(&handle, &actor, &orgs(cx), &memberships(cx))
57 .await
58 .map_err(server_error)?
59 .ok_or_not_found()?)
60}
61
62/// How many repositories the overview previews before deferring to the index.
63///
64/// Small on purpose. The overview is a shopfront, not an inventory — the tab is where
65/// completeness lives.
66const PREVIEW: usize = 5;
67
68/// Which tab is lit.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(super) enum Tab {
71 Overview,
72 Repositories,
73}
74
75/// The identity block and the tab bar, shared by every page that is *about a handle*
76/// rather than about a thing inside one.
77///
78/// Flat navigation rather than stacked sections: an empty section stops being a visible
79/// failure on the front page and becomes a destination that happens to be empty. See
80/// `plans/ui.md`.
81/// The parameter is `identity`, not `profile`: the `#[page] async fn profile` below puts
82/// a unit struct of that name in module scope, which shadows a parameter sharing it.
83#[component]
84pub(super) async fn profile_chrome(
85 identity: &PublicProfile,
86 active: Tab,
87 repo_count: usize,
88) -> Result {
89 let handle = identity.handle.as_str();
90 let overview = format!("/{handle}");
91 let repositories = format!("/{handle}/repos");
92 let settings = format!("/{handle}/settings");
93 let count = repo_count.to_string();
94
95 view! {
96 <header>
97 <h1 class="text-xl font-semibold tracking-tight">(&identity.label)</h1>
98 <p class="mt-0.5 font-mono text-[13px] text-muted-foreground">"@" (handle)</p>
99 ({
100 match &identity.bio {
101 Some(bio) => view! {
102 <p class="mt-3.5 max-w-lg text-sm leading-relaxed text-muted-foreground">(bio)</p>
103 },
104 None => view! {},
105 }
106 }?)
107 </header>
108
109 <nav class="mt-8 flex items-center gap-6 border-b border-border text-[13px]">
110 tab_link(
111 href: overview.as_str(),
112 label: "Overview",
113 count: "",
114 active: active == Tab::Overview,
115 )
116 tab_link(
117 href: repositories.as_str(),
118 label: "Repositories",
119 count: count.as_str(),
120 active: active == Tab::Repositories,
121 )
122
123 if identity.viewer_is_owner {
124 <a
125 href=(settings.as_str())
126 class="ml-auto -mb-px border-b-2 border-transparent pb-2.5 text-muted-foreground hover:text-foreground"
127 >"Settings"</a>
128 }
129 </nav>
130 }
131}
132
133/// One tab. A plain link, so every tab is a real URL and none of this needs JavaScript.
134#[component]
135async fn tab_link(href: &str, label: &str, count: &str, active: bool) -> Result {
136 view! {
137 <a
138 href=(href)
139 class=(if active {
140 "-mb-px border-b-2 border-foreground pb-2.5 font-medium text-foreground"
141 } else {
142 "-mb-px border-b-2 border-transparent pb-2.5 text-muted-foreground hover:text-foreground"
143 })
144 >
145 (label)
146 if !count.is_empty() {
147 <span class="ml-1.5 text-xs text-muted-foreground/60">(count)</span>
148 }
149 </a>
150 }
151}
152
153/// The pinned repository, given weight by everything except size.
154///
155/// Eyebrow, a heavier weight, a roomier description and a rule beneath it —
156/// `plans/ui.md` records why scale is deliberately not one of the devices.
157#[component]
158async fn lead(handle: &str, repo: &RepoSummary) -> Result {
159 view! {
160 <section class="mt-8 border-b border-border/60 pb-8">
161 <p class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
162 "Currently building"
163 </p>
164 <div class="mt-2 flex items-baseline gap-2">
165 <a
166 href=(format!("/{handle}/repos/{}", repo.name))
167 class="text-sm font-semibold hover:underline"
168 >(repo.name.as_str())</a>
169 if !repo.visibility.is_public() {
170 badge(variant: BadgeVariant::Outline, "Private")
171 }
172 </div>
173 ({
174 match &repo.description {
175 Some(description) => view! {
176 <p class="mt-2 max-w-lg text-sm leading-relaxed text-muted-foreground">
177 (description)
178 </p>
179 },
180 None => view! {},
181 }
182 }?)
183 <p class="mt-2.5 font-mono text-[11px] text-muted-foreground/80">
184 "updated " (ago(repo.updated_at))
185 </p>
186 </section>
187 }
188}
189
190/// A flat list of repositories. Rows and hairlines, never boxes.
191#[component]
192pub(super) async fn repo_rows(handle: &str, repos: &[RepoSummary]) -> Result {
193 view! {
194 <div class="mt-1">
195 for repo in repos {
196 <a
197 href=(format!("/{handle}/repos/{}", repo.name))
198 class="group -mx-2.5 flex items-baseline justify-between gap-4 rounded px-2.5 py-2.5 hover:bg-surface"
199 >
200 <div>
201 <div class="flex items-baseline gap-2">
202 <span class="text-sm font-medium group-hover:underline">
203 (repo.name.as_str())
204 </span>
205 if !repo.visibility.is_public() {
206 badge(variant: BadgeVariant::Outline, "Private")
207 }
208 </div>
209 ({
210 match &repo.description {
211 Some(description) => view! {
212 <p class="mt-0.5 text-[13px] leading-snug text-muted-foreground">
213 (description)
214 </p>
215 },
216 None => view! {},
217 }
218 }?)
219 </div>
220 <span class="shrink-0 font-mono text-[11px] text-muted-foreground">
221 (ago(repo.updated_at))
222 </span>
223 </a>
224 }
225 </div>
226 }
227}
228
229#[page("/{handle}")]
230async fn profile(cx: &Cx) -> Result {
231 let profile = profile_for(cx).await?;
232
233 // The handle resolved above, so `None` here is impossible; an empty list is the
234 // right answer either way rather than a 500.
235 let listed = list_repos(
236 &profile.handle,
237 &current_actor(cx).await?,
238 &orgs(cx),
239 &memberships(cx),
240 &repos(cx),
241 )
242 .await
243 .map_err(server_error)?
244 .unwrap_or_default();
245
246 // The lead comes out of the listing rather than a second query, so the two can
247 // never disagree about which repository is pinned.
248 let lead_repo = listed.iter().find(|repo| repo.pinned).cloned();
249 let rest: Vec<RepoSummary> = listed
250 .iter()
251 .filter(|repo| !repo.pinned)
252 .take(PREVIEW)
253 .cloned()
254 .collect();
255 let more = listed
256 .len()
257 .saturating_sub(rest.len() + usize::from(lead_repo.is_some()));
258 let handle = profile.handle.to_string();
259
260 view! {
261 narrow(
262 profile_chrome(identity: &profile, active: Tab::Overview, repo_count: listed.len())
263
264 ({
265 match &lead_repo {
266 Some(repo) => view! { lead(handle: handle.as_str(), repo: repo) },
267 None => view! {},
268 }
269 }?)
270
271 if !rest.is_empty() {
272 <section class="mt-8">
273 <div class="flex items-baseline justify-between">
274 <h2 class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
275 "Repositories"
276 </h2>
277 if more > 0 {
278 <a
279 href=(format!("/{handle}/repos"))
280 class="text-xs text-muted-foreground hover:text-foreground"
281 >"All " (listed.len().to_string()) " \u{2192}"</a>
282 }
283 </div>
284 repo_rows(handle: handle.as_str(), repos: &rest)
285 </section>
286 }
287
288 // Nothing to show. A visitor gets silence rather than an empty box advertising
289 // incompleteness; the owner gets the way to fix it.
290 if listed.is_empty() {
291 if profile.viewer_is_owner {
292 <div class="mt-8">
293 <p class="text-sm text-muted-foreground">"No repositories yet."</p>
294 <p class="mt-3">
295 <a
296 href=(format!("/{handle}/repos/new"))
297 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
298 >"New repository"</a>
299 </p>
300 </div>
301 }
302 }
303 )
304 }
305}
306
307/// Every repository under a handle that the viewer may see.
308///
309/// The tab's destination, and where completeness lives — the overview deliberately
310/// previews only [`PREVIEW`] of them.
311#[page("/{handle}/repos")]
312async fn repos_index(cx: &Cx) -> Result {
313 // Not `profile`: the `#[page] async fn profile` above is a unit struct in this
314 // module, so `let profile = …` parses as a unit-struct pattern rather than a new
315 // binding. The error names neither the page nor the shadowing.
316 let identity = profile_for(cx).await?;
317
318 let listed = list_repos(
319 &identity.handle,
320 &current_actor(cx).await?,
321 &orgs(cx),
322 &memberships(cx),
323 &repos(cx),
324 )
325 .await
326 .map_err(server_error)?
327 .unwrap_or_default();
328
329 let handle = identity.handle.to_string();
330 let new_repo = format!("/{handle}/repos/new");
331
332 view! {
333 narrow(
334 profile_chrome(identity: &identity, active: Tab::Repositories, repo_count: listed.len())
335
336 if listed.is_empty() {
337 <p class="mt-8 text-sm text-muted-foreground">"No repositories yet."</p>
338 if identity.viewer_is_owner {
339 <p class="mt-3">
340 <a
341 href=(new_repo.as_str())
342 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
343 >"New repository"</a>
344 </p>
345 }
346 } else {
347 <div class="mt-6">
348 if identity.viewer_is_owner {
349 <p class="mb-2 flex justify-end">
350 <a
351 href=(new_repo.as_str())
352 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
353 >"New repository"</a>
354 </p>
355 }
356 repo_rows(handle: handle.as_str(), repos: &listed)
357 </div>
358 }
359 )
360 }
361}