forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.4 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
69fn theme_label(lang: Lang, code: &str) -> &'static str {
70 match code {
71 "light" => g::t(lang, K::Light),
72 "dark" => g::t(lang, K::Dark),
73 _ => g::t(lang, K::MatchSystem),
74 }
75}
76
77fn choices(
78 codes: &[&'static str],
79 current: &str,
80 label: impl Fn(&str) -> &'static str,
81) -> Vec<Choice> {
82 codes
83 .iter()
84 .map(|&value| Choice {
85 value,
86 label: label(value),
87 selected: value == current,
88 })
89 .collect()
90}
91
92#[component]
93async fn appearance_body(page: AppearancePage) -> Result<impl View> {
94 let AppearancePage {
95 lang,
96 csrf,
97 theme,
98 language,
99 notice,
100 } = page;
101 Ok(view! {
102 <div class="settings-layout">
103 <section class="page-head">
104 <h1>(g::t(lang, K::Appearance))</h1>
105 </section>
106 if let Some(message) = notice {
107 <section class="card notice" role="status">
108 <p>(message)</p>
109 </section>
110 }
111 <section class="card">
112 <h2>(g::t(lang, K::ThemeAndLanguage))</h2>
113 <form method="post" action="/settings/appearance" class="stack">
114 <input type="hidden" name="csrf" value=(csrf.as_str())>
115 <label>
116 <span>(g::t(lang, K::ThemeLabel))</span>
117 <select name="theme">
118 for choice in theme.into_iter() {
119 <option value=(choice.value) selected=(choice.selected)>(choice.label)</option>
120 }
121 </select>
122 <small class="muted">(g::t(lang, K::MatchSystemHelp))</small>
123 </label>
124 <label>
125 <span>(g::t(lang, K::LanguageLabel))</span>
126 <select name="language">
127 for choice in language.into_iter() {
128 <option value=(choice.value) selected=(choice.selected)>(choice.label)</option>
129 }
130 </select>
131 </label>
132 <button type="submit" class="primary">(i18n::t(lang, Key::Save))</button>
133 </form>
134 </section>
135 </div>
136 })
137}
138
139/// Rejects a theme or language that is not offered. Nothing is saved for either.
140fn check_choice(lang: Lang, theme: &str, language: &str) -> forge_core::Result<()> {
141 if !THEMES.contains(&theme) {
142 return Err(CoreError::Invalid(g::t(lang, K::ChooseTheme).into()));
143 }
144 if !LANGUAGE_CODES.contains(&language) {
145 return Err(CoreError::Invalid(g::t(lang, K::ChooseLanguage).into()));
146 }
147 Ok(())
148}
149
150fn notice_for(lang: Lang, saved: Option<&str>) -> Option<&'static str> {
151 match saved {
152 Some("1") => Some(g::t(lang, K::AppearanceSaved)),
153 _ => None,
154 }
155}
156
157/// Builds the page for a viewer. `theme` and `language` are the stored values.
158fn page_for(viewer: &(User, Session), lang: Lang, notice: Option<&'static str>) -> AppearancePage {
159 AppearancePage {
160 lang,
161 csrf: viewer.1.csrf_token.clone(),
162 theme: choices(THEME_CODES, &viewer.0.theme, |code| theme_label(lang, code)),
163 language: choices(LANGUAGE_CODES, &viewer.0.language, |code| {
164 language_label(lang, code)
165 }),
166 notice,
167 }
168}
169
170#[page("/settings/appearance")]
171async fn appearance_page(cx: &Cx) -> Result<impl View> {
172 let viewer = require_user(cx).await?;
173 let lang = i18n::lang(cx, Some(&viewer.0));
174 let saved = query_params::<AppearanceQuery>(cx)?.saved.clone();
175 let page = page_for(&viewer, lang, notice_for(lang, saved.as_deref()));
176 Ok(view! {
177 shell(title: g::t(lang, K::Appearance), viewer: Some(&viewer), appearance_body(page: page))
178 })
179}
180
181#[route(POST "/settings/appearance")]
182async fn appearance_save(cx: &Cx, Form(form): Form<AppearanceForm>) -> Result<SeeOther> {
183 let viewer = require_csrf(cx, &form.csrf).await?;
184 let lang = i18n::lang(cx, Some(&viewer.0));
185 let store: &Store = &app_context::<AppState>(cx).store;
186
187 check_choice(lang, &form.theme, &form.language)?;
188 store
189 .set_ui_prefs(&viewer.0.id, &form.theme, &form.language)
190 .await?;
191 audit::record(
192 store,
193 Some(cx),
194 Actor::User(&viewer.0),
195 AuditAction::UiPrefsChanged,
196 Target {
197 kind: "user",
198 id: &viewer.0.id,
199 name: &viewer.0.username,
200 },
201 serde_json::json!({ "theme": form.theme, "language": form.language }),
202 )
203 .await;
204 Ok(see_other("/settings/appearance?saved=1"))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn only_offered_choices_pass() {
213 assert!(check_choice(Lang::En, "dark", "tr").is_ok());
214 assert!(check_choice(Lang::Tr, "auto", "auto").is_ok());
215 assert!(check_choice(Lang::En, "neon", "en").is_err());
216 assert!(check_choice(Lang::En, "light", "de").is_err());
217 assert!(check_choice(Lang::Tr, "light", "").is_err());
218 }
219
220 #[test]
221 fn the_stored_value_is_selected() {
222 let options = choices(THEME_CODES, "dark", |code| theme_label(Lang::En, code));
223 let selected: Vec<&str> = options
224 .iter()
225 .filter(|c| c.selected)
226 .map(|c| c.value)
227 .collect();
228 assert_eq!(selected, ["dark"]);
229 }
230
231 #[test]
232 fn labels_follow_the_page_language() {
233 assert_eq!(theme_label(Lang::En, "dark"), "Dark");
234 assert_eq!(theme_label(Lang::Tr, "dark"), "Koyu");
235 assert_eq!(language_label(Lang::Tr, "tr"), "T\u{fc}rk\u{e7}e");
236 }
237}