steid

@jamesgill /

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::view,
15};
16
17use crate::{
18 application::{PublicProfile, list_repos, view_profile},
19 components::button::{ButtonSize, ButtonVariant, button_variants},
20 domain::OrgName,
21};
22
23use super::{
24 context::{current_actor, memberships, orgs, repos, server_error},
25 repo::repo_list,
26};
27
28/// `{handle}` from the path. The struct name snake-cased is the parameter name.
29///
30/// Declared as `str` so the raw segment arrives unparsed — validation is `OrgName`'s
31/// job, and a handle that fails it is a page that does not exist rather than a bad
32/// request.
33#[path_param]
34struct Handle(str);
35
36/// Resolves `{handle}` from the path into a profile, or 404.
37///
38/// A malformed handle 404s rather than erroring: `/Not A Handle` is a page that does
39/// not exist, not a bad request.
40pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
41 let raw = path_param::<Handle>(cx);
42 let handle = OrgName::new(raw).map_err(|_| not_found())?;
43 let actor = current_actor(cx).await?;
44
45 Ok(view_profile(&handle, &actor, &orgs(cx), &memberships(cx))
46 .await
47 .map_err(server_error)?
48 .ok_or_not_found()?)
49}
50
51#[page("/{handle}")]
52async fn profile(cx: &Cx) -> Result {
53 let profile = profile_for(cx).await?;
54
55 // The handle resolved above, so `None` here is impossible; an empty list is the
56 // right answer either way rather than a 500.
57 let listed = list_repos(
58 &profile.handle,
59 &current_actor(cx).await?,
60 &orgs(cx),
61 &memberships(cx),
62 &repos(cx),
63 )
64 .await
65 .map_err(server_error)?
66 .unwrap_or_default();
67
68 view! {
69 <header class="mb-10">
70 <h1 class="text-2xl font-semibold tracking-tight">(&profile.label)</h1>
71 <p class="mt-1 font-mono text-sm text-muted-foreground">
72 "@" (profile.handle.as_str())
73 </p>
74 ({
75 match &profile.bio {
76 Some(bio) => view! { <p class="mt-3 text-sm leading-relaxed">(bio)</p> },
77 None => view! {},
78 }
79 }?)
80 ({
81 if profile.viewer_is_owner {
82 view! {
83 <p class="mt-4">
84 <a
85 href=(format!("/{}/settings", profile.handle))
86 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
87 >"Edit profile"</a>
88 </p>
89 }
90 } else {
91 view! {}
92 }
93 }?)
94 </header>
95
96 <section class="mt-8">
97 <div class="flex items-center justify-between">
98 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
99 "Repositories"
100 </h2>
101 if profile.viewer_is_owner {
102 <a
103 href=(format!("/{}/repos/new", profile.handle))
104 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
105 >"New repository"</a>
106 }
107 </div>
108 repo_list(handle: profile.handle.as_str(), repos: &listed)
109 </section>
110
111 <section class="mt-8">
112 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
113 "Writing"
114 </h2>
115 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
116 "Nothing here yet."
117 </p>
118 </section>
119
120 <section class="mt-8">
121 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
122 "Projects"
123 </h2>
124 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
125 "Nothing here yet."
126 </p>
127 </section>
128 }
129}