steid

@jamesgill /

5.7 KBCode·Blame·Raw
c50d9adfeat: profile settings with flash feedback25d
1//! Profile settings — `/{handle}/settings`.
2//!
3//! Owner only. Authorization is the use case's decision, not this module's; the page
4//! only chooses how to render the answer.
5
6use serde::Deserialize;
7use topcoat::{
8 Result,
9 context::Cx,
10 router::{
11 content::Form,
12 error::{forbidden, redirect},
13 page, query_params,
14 },
15 view::{attributes, component, view},
16};
17
18use crate::{
19 application::{Error, update_profile},
20 components::{
21 button::button,
22 flash::{FlashKind, flash},
23 input::input,
24 label::label,
25 textarea::textarea,
26 },
27 domain::{DomainError, Organization},
28};
29
30use super::{
31 context::{current_actor, memberships, orgs, server_error},
32 profile::profile_for,
33};
34
35#[derive(Debug, Deserialize)]
36struct ProfileForm {
37 display_name: String,
38 bio: String,
39}
40
41/// Set after a successful save so the confirmation survives the redirect.
42///
43/// Post-redirect-get: reloading after a save must not resubmit it.
44#[query_params(error = bad_request)]
45struct Saved {
46 saved: Option<String>,
47}
48
49/// Blank input means "clear this field", which the domain treats as unset.
50fn optional(value: &str) -> Option<String> {
51 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
52}
53
54#[page("/{handle}/settings")]
55async fn settings_page(cx: &Cx) -> Result {
56 let profile = profile_for(cx).await?;
57
58 if !profile.viewer_is_owner {
59 return Err(forbidden().into());
60 }
61
62 let saved = query_params::<Saved>(cx)?.saved.is_some();
63
64 view! {
65 settings_form(
66 handle: profile.handle.as_str(),
67 // The stored display name, not the label: the label falls back to the
68 // handle, which would put text in a field the owner never typed.
69 display_name: profile.display_name.as_deref().unwrap_or(""),
70 bio: profile.bio.as_deref().unwrap_or(""),
71 saved: saved,
72 error: "",
73 )
74 }
75}
76
77/// Applies an edit.
78///
79/// Success redirects, so a reload cannot resubmit. Failure re-renders with the message
80/// and **what was typed** — bouncing back to a blank form would throw away the work and
81/// leave the reason invisible, which is the whole problem `flash` exists to fix.
82#[page(POST "/{handle}/settings")]
83async fn save(cx: &Cx, Form(submitted): Form<ProfileForm>) -> Result {
84 let profile = profile_for(cx).await?;
85
86 let outcome = update_profile(
87 &current_actor(cx).await?,
88 &profile.handle,
89 optional(&submitted.display_name),
90 optional(&submitted.bio),
91 &orgs(cx),
92 &memberships(cx),
93 )
94 .await;
95
96 let message = match outcome {
97 Ok(()) => {
98 return Err(redirect(&format!("/{}/settings?saved", profile.handle)).into());
99 }
100 // The visitor's to fix, so it is shown.
101 Err(Error::Domain(DomainError::Validation { field, reason })) => {
102 format!("That {field} is no good: {reason}.")
103 }
104 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
105 // Ours, so it is logged and answered generically.
106 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
107 };
108
109 view! {
110 settings_form(
111 handle: profile.handle.as_str(),
112 display_name: submitted.display_name.as_str(),
113 bio: submitted.bio.as_str(),
114 saved: false,
115 error: message.as_str(),
116 )
117 }
118}
119
120/// The edit form.
121///
122/// Values arrive as parameters rather than being read back from storage, so a rejected
123/// submission can re-render exactly what was typed.
124#[component]
125async fn settings_form(
126 handle: &str,
127 display_name: &str,
128 bio: &str,
129 saved: bool,
130 error: &str,
131) -> Result {
132 view! {
133 <h1 class="text-xl font-semibold tracking-tight">"Profile settings"</h1>
134 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
135
136 if saved {
137 <div class="mt-6">
138 flash(kind: FlashKind::Success, "Profile updated.")
139 </div>
140 }
141
142 if !error.is_empty() {
143 <div class="mt-6">
144 flash(kind: FlashKind::Error, (error))
145 </div>
146 }
147
148 <form method="post" action=(format!("/{handle}/settings")) class="mt-6 space-y-5">
149 <div class="space-y-2">
150 label(attrs: attributes! { for="display_name" }, "Display name")
151 input(attrs: attributes! {
152 id="display_name"
153 name="display_name"
154 type="text"
155 value=(display_name)
156 placeholder=(handle)
157 })
158 <p class="text-xs text-muted-foreground">
159 "Shown instead of your handle. Leave blank to use @" (handle) "."
160 </p>
161 </div>
162
163 <div class="space-y-2">
164 label(attrs: attributes! { for="bio" }, "Bio")
165 textarea(
166 attrs: attributes! {
167 id="bio"
168 name="bio"
169 rows="4"
170 maxlength=(Organization::MAX_BIO_LEN.to_string())
171 },
172 (bio)
173 )
174 <p class="text-xs text-muted-foreground">
175 "At most " (Organization::MAX_BIO_LEN.to_string()) " characters."
176 </p>
177 </div>
178
179 <div class="flex items-center gap-3">
180 button(attrs: attributes! { type="submit" }, "Save")
181 <a
182 href=(format!("/{handle}"))
183 class="text-sm text-muted-foreground hover:text-foreground"
184 >"Back to profile"</a>
185 </div>
186 </form>
187 }
188}