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