forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.9 KiB
1//! Page: appearance (`/settings/appearance`). The colour theme and the language, both stored per
2//! user. Anonymous visitors set the theme from the footer instead (see `pages/theme_cookie.rs`).
3//! Every POST checks the CSRF token first.
4
5use forge_core::AuditAction;
6use forge_core::store::ui_prefs::THEMES;
7use forge_core::{Error as CoreError, Session, Store, User};
8use serde::Deserialize;
9use topcoat::Result;
10use topcoat::context::{Cx, app_context};
11use topcoat::router::content::Form;
12use topcoat::router::error::{SeeOther, see_other};
13use topcoat::router::{page, query_params, route};
14use topcoat::view::{View, component, view};
15
16use crate::audit::{self, Actor, Target};
17use crate::auth::{require_csrf, require_user};
18use crate::i18n::misc::{self as g, K};
19use crate::i18n::{self, Key, Lang};
20use crate::state::AppState;
21use crate::views::shell;
22
23/// Language codes offered on the page. `auto` follows the browser. Other codes must be
24/// translated in `i18n/mod.rs` before they are added here.
25pub const LANGUAGE_CODES: &[&str] = &["auto", "en", "tr"];
26
27/// Theme codes, in display order.
28const THEME_CODES: &[&str] = &["auto", "light", "dark"];
29
30#[query_params(error = bad_request)]
31struct AppearanceQuery {
32 saved: Option<String>,
33}
34
35#[derive(Deserialize)]
36struct AppearanceForm {
37 #[serde(default)]
38 csrf: String,
39 #[serde(default)]
40 theme: String,
41 #[serde(default)]
42 language: String,
43}
44
45struct Choice {
46 value: &'static str,
47 label: &'static str,
48 selected: bool,
49}
50
51struct AppearancePage {
52 lang: Lang,
53 csrf: String,
54 theme: Vec<Choice>,
55 language: Vec<Choice>,
56 notice: Option<&'static str>,
57}
58
59/// The label of a language code. The two native names stay as they are in every language.
60fn language_label(lang: Lang, code: &str) -> &'static str {
61 match code {
62 "auto" => g::t(lang, K::MatchBrowser),
63 "en" => "English",
64 "tr" => "T\u{fc}rk\u{e7}e",
65 _ => "",
66 }
67}
68
69/// The label of a theme code. Light is the default for everyone who has not chosen, so it is
70/// marked as the default.
71fn theme_label(lang: Lang, code: &str) -> &'static str {
72 match code {
73 "light" => match lang {
74 Lang::En => "Light (default)",
75 Lang::Tr => "A\u{e7}\u{131}k (varsay\u{131}lan)",
76 },
77 "dark" => g::t(lang, K::Dark),
78 _ => g::t(lang, K::MatchSystem),
79 }
80}
81
82fn choices(
83 codes: &[&'static str],
84 current: &str,
85 label: impl Fn(&str) -> &'static str,
86) -> Vec<Choice> {
87 codes
88 .iter()
89 .map(|&value| Choice {
90 value,
91 label: label(value),
92 selected: value == current,
93 })
94 .collect()
95}
96
97#[component]
98async fn appearance_body(page: AppearancePage) -> Result<impl View> {
99 let AppearancePage {
100 lang,
101 csrf,
102 theme,
103 language,
104 notice,
105 } = page;
106 Ok(view! {
107 <div class="settings-layout">
108 <section class="page-head">
109 <h1>(g::t(lang, K::Appearance))</h1>
110 </section>
111 if let Some(message) = notice {
112 <section class="card notice" role="status">
113 <p>(message)</p>
114 </section>
115 }
116 <section class="card">
117 <h2>(g::t(lang, K::ThemeAndLanguage))</h2>
118 <form method="post" action="/settings/appearance" class="stack">
119 <input type="hidden" name="csrf" value=(csrf.as_str())>
120 <label>
121 <span>(g::t(lang, K::ThemeLabel))</span>
122 <select name="theme">
123 for choice in theme.into_iter() {
124 <option value=(choice.value) selected=(choice.selected)>(choice.label)</option>
125 }
126 </select>
127 <small class="muted">(g::t(lang, K::MatchSystemHelp))</small>
128 </label>
129 <label>
130 <span>(g::t(lang, K::LanguageLabel))</span>
131 <select name="language">
132 for choice in language.into_iter() {
133 <option value=(choice.value) selected=(choice.selected)>(choice.label)</option>
134 }
135 </select>
136 </label>
137 <button type="submit" class="primary">(i18n::t(lang, Key::Save))</button>
138 </form>
139 </section>
140 </div>
141 })
142}
143
144/// Rejects a theme or language that is not offered. Nothing is saved for either.
145fn check_choice(lang: Lang, theme: &str, language: &str) -> forge_core::Result<()> {
146 if !THEMES.contains(&theme) {
147 return Err(CoreError::Invalid(g::t(lang, K::ChooseTheme).into()));
148 }
149 if !LANGUAGE_CODES.contains(&language) {
150 return Err(CoreError::Invalid(g::t(lang, K::ChooseLanguage).into()));
151 }
152 Ok(())
153}
154
155fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> {
156 match saved {
157 Some("1") => Some(g::t(lang, K::AppearanceSaved)),
158 _ => None,
159 }
160}
161
162/// Builds the page for a viewer. `theme` and `language` are the stored values.
163fn page_for(viewer: &(User, Session), lang: Lang, notice: Option<&'static str>) -> AppearancePage {
164 AppearancePage {
165 lang,
166 csrf: viewer.1.csrf_token.clone(),
167 theme: choices(THEME_CODES, &viewer.0.theme, |code| theme_label(lang, code)),
168 language: choices(LANGUAGE_CODES, &viewer.0.language, |code| {
169 language_label(lang, code)
170 }),
171 notice,
172 }
173}
174
175#[page("/settings/appearance")]
176async fn appearance_page(cx: &Cx) -> Result<impl View> {
177 let viewer = require_user(cx).await?;
178 let lang = i18n::lang(cx, Some(&viewer.0));
179 let saved = query_params::<AppearanceQuery>(cx)?.saved.clone();
180 let page = page_for(&viewer, lang, notice_for(lang, saved.as_deref()));
181 Ok(view! {
182 shell(title: g::t(lang, K::Appearance), viewer: Some(&viewer), appearance_body(page: page))
183 })
184}
185
186#[route(POST "/settings/appearance")]
187async fn appearance_save(cx: &Cx, Form(form): Form<AppearanceForm>) -> Result<SeeOther> {
188 let viewer = require_csrf(cx, &form.csrf).await?;
189 let lang = i18n::lang(cx, Some(&viewer.0));
190 let store: &Store = &app_context::<AppState>(cx).store;
191
192 check_choice(lang, &form.theme, &form.language)?;
193 store
194 .set_ui_prefs(&viewer.0.id, &form.theme, &form.language)
195 .await?;
196 audit::record(
197 store,
198 Some(cx),
199 Actor::User(&viewer.0),
200 AuditAction::UiPrefsChanged,
201 Target {
202 kind: "user",
203 id: &viewer.0.id,
204 name: &viewer.0.username,
205 },
206 serde_json::json!({ "theme": form.theme, "language": form.language }),
207 )
208 .await;
209 Ok(see_other("/settings/appearance?saved=1"))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn only_offered_choices_pass() {
218 assert!(check_choice(Lang::En, "dark", "tr").is_ok());
219 assert!(check_choice(Lang::Tr, "auto", "auto").is_ok());
220 assert!(check_choice(Lang::En, "neon", "en").is_err());
221 assert!(check_choice(Lang::En, "light", "de").is_err());
222 assert!(check_choice(Lang::Tr, "light", "").is_err());
223 }
224
225 #[test]
226 fn the_stored_value_is_selected() {
227 let options = choices(THEME_CODES, "dark", |code| theme_label(Lang::En, code));
228 let selected: Vec<&str> = options
229 .iter()
230 .filter(|c| c.selected)
231 .map(|c| c.value)
232 .collect();
233 assert_eq!(selected, ["dark"]);
234 }
235
236 #[test]
237 fn labels_follow_the_page_language() {
238 assert_eq!(theme_label(Lang::En, "dark"), "Dark");
239 assert_eq!(theme_label(Lang::Tr, "dark"), "Koyu");
240 }
241
242 #[test]
243 fn light_is_marked_as_the_default() {
244 assert_eq!(theme_label(Lang::En, "light"), "Light (default)");
245 assert_eq!(
246 theme_label(Lang::Tr, "light"),
247 "A\u{e7}\u{131}k (varsay\u{131}lan)"
248 );
249 assert_eq!(language_label(Lang::Tr, "tr"), "T\u{fc}rk\u{e7}e");
250 }
251}