@jpgilldev / steid

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