yggdrasil/pages/admin/settings/
kit.rs1use std::future::Future;
9
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12
13use super::SettingsSection;
14use crate::api::error::ApiError;
15use crate::components::ui::use_toast;
16use crate::utils::error::failure_message;
17
18#[derive(Clone, Copy, PartialEq)]
20pub(super) struct SettingsBus {
21 pub dirty: RwSignal<Vec<SettingsSection>>,
23 pub saving: RwSignal<Vec<SettingsSection>>,
25 pub failed: RwSignal<Vec<SettingsSection>>,
27 pub save_gen: RwSignal<u32>,
29 pub reset_gen: RwSignal<u32>,
31}
32
33impl SettingsBus {
34 pub fn new() -> Self {
35 Self {
36 dirty: RwSignal::new(Vec::new()),
37 saving: RwSignal::new(Vec::new()),
38 failed: RwSignal::new(Vec::new()),
39 save_gen: RwSignal::new(0),
40 reset_gen: RwSignal::new(0),
41 }
42 }
43
44 pub fn finish(self, section: SettingsSection, ok: bool) {
46 self.saving.update(|list| list.retain(|s| *s != section));
47 if !ok {
48 self.failed.update(|list| {
49 if !list.contains(§ion) {
50 list.push(section);
51 }
52 });
53 }
54 }
55
56 pub fn save_all(self) {
58 if !self.saving.with_untracked(|list| list.is_empty()) {
59 return;
60 }
61 self.failed.set(Vec::new());
62 self.save_gen.update(|g| *g += 1);
63 }
64
65 pub fn reset_all(self) {
67 self.failed.set(Vec::new());
68 self.reset_gen.update(|g| *g += 1);
69 }
70}
71
72fn set_member(list: RwSignal<Vec<SettingsSection>>, section: SettingsSection, on: bool) {
73 let present = list
74 .try_with_untracked(|l| l.contains(§ion))
75 .unwrap_or(on);
76 if on && !present {
77 list.update(|l| l.push(section));
78 } else if !on && present {
79 list.update(|l| l.retain(|s| *s != section));
80 }
81}
82
83pub(super) fn use_settings_slot(
85 section: SettingsSection,
86 dirty: Memo<bool>,
87 save: Callback<()>,
88 reset: Callback<()>,
89) -> SettingsBus {
90 let bus = expect_context::<SettingsBus>();
91 Effect::new(move |_| set_member(bus.dirty, section, dirty.get()));
92 let seen_save = StoredValue::new(bus.save_gen.get_untracked());
93 Effect::new(move |_| {
94 let generation = bus.save_gen.get();
95 if generation == seen_save.get_value() {
96 return;
97 }
98 seen_save.set_value(generation);
99 if dirty.get_untracked() {
100 set_member(bus.saving, section, true);
101 save.run(());
102 }
103 });
104 let seen_reset = StoredValue::new(bus.reset_gen.get_untracked());
105 Effect::new(move |_| {
106 let generation = bus.reset_gen.get();
107 if generation == seen_reset.get_value() {
108 return;
109 }
110 seen_reset.set_value(generation);
111 reset.run(());
112 });
113 on_cleanup(move || {
114 set_member(bus.dirty, section, false);
115 set_member(bus.saving, section, false);
116 });
117 bus
118}
119
120pub(super) struct SectionState<T: Send + Sync + 'static> {
122 pub draft: RwSignal<T>,
123 pub saved: RwSignal<Option<T>>,
124 pub load_failed: RwSignal<bool>,
125 pub bus: SettingsBus,
126 pub section: SettingsSection,
127}
128
129impl<T: Send + Sync + 'static> Clone for SectionState<T> {
130 fn clone(&self) -> Self {
131 *self
132 }
133}
134
135impl<T: Send + Sync + 'static> Copy for SectionState<T> {}
136
137impl<T: Clone + PartialEq + Send + Sync + 'static> SectionState<T> {
138 pub fn ready(&self) -> Signal<bool> {
140 let saved = self.saved;
141 Signal::derive(move || saved.with(|s| s.is_some()))
142 }
143
144 pub fn load_failed(&self) -> Signal<bool> {
146 self.load_failed.into()
147 }
148
149 pub fn failed(&self) -> Signal<bool> {
151 let (bus, section) = (self.bus, self.section);
152 Signal::derive(move || bus.failed.with(|list| list.contains(§ion)))
153 }
154
155 pub fn field<U: Send + Sync + 'static>(
157 &self,
158 read: impl Fn(&T) -> U + Send + Sync + 'static,
159 ) -> Signal<U> {
160 let draft = self.draft;
161 Signal::derive(move || draft.with(&read))
162 }
163
164 pub fn edit(&self, change: impl FnOnce(&mut T)) {
166 self.draft.update(change);
167 }
168}
169
170pub(super) fn use_section<T, L, LF, S, SF>(
172 section: SettingsSection,
173 load: L,
174 save: S,
175) -> SectionState<T>
176where
177 T: Clone + PartialEq + Default + Send + Sync + 'static,
178 L: FnOnce() -> LF + 'static,
179 LF: Future<Output = Result<T, ApiError>> + 'static,
180 S: Fn(T) -> SF + Copy + Send + Sync + 'static,
181 SF: Future<Output = Result<T, ApiError>> + 'static,
182{
183 let toast = use_toast();
184 let bus = expect_context::<SettingsBus>();
185 let draft = RwSignal::new(T::default());
186 let saved = RwSignal::new(None::<T>);
187 let load_failed = RwSignal::new(false);
188 let mut load = Some(load);
190 Effect::new(move |_| {
191 let Some(load) = load.take() else {
192 return;
193 };
194 spawn_local(async move {
195 match load().await {
196 Ok(value) => {
197 draft.set(value.clone());
198 saved.set(Some(value));
199 }
200 Err(_) => load_failed.set(true),
201 }
202 });
203 });
204 let dirty =
205 Memo::new(move |_| saved.with(|s| s.as_ref().is_some_and(|s| draft.with(|d| s != d))));
206 let on_save = Callback::new(move |()| {
207 let value = draft.get_untracked();
208 spawn_local(async move {
209 match save(value).await {
210 Ok(next) => {
211 draft.set(next.clone());
212 saved.set(Some(next));
213 bus.finish(section, true);
214 }
215 Err(e) => {
216 toast.error(failure_message(
217 &format!("「{}」保存失败", section.label()),
218 &e,
219 ));
220 bus.finish(section, false);
221 }
222 }
223 });
224 });
225 let on_reset = Callback::new(move |()| {
226 if let Some(value) = saved.get_untracked() {
227 draft.set(value);
228 }
229 });
230 use_settings_slot(section, dirty, on_save, on_reset);
231 SectionState {
232 draft,
233 saved,
234 load_failed,
235 bus,
236 section,
237 }
238}
239
240#[component]
242pub(super) fn SettingsCard(
243 section: SettingsSection,
244 #[prop(into)] description: String,
245 #[prop(optional, into)] failed: Signal<bool>,
246 #[prop(into)] ready: Signal<bool>,
247 #[prop(into)] load_failed: Signal<bool>,
248 children: ChildrenFn,
249) -> impl IntoView {
250 let heading = format!("{}-h", section.dom_id());
251 view! {
252 <section
253 id=section.dom_id()
254 class="admin-panel settings-card"
255 data-failed=move || failed.get().then_some("true")
256 aria-labelledby=heading.clone()
257 >
258 <div class="settings-card-head">
259 <div>
260 <h2 id=heading.clone()>{section.label()}</h2>
261 <p>{description}</p>
262 </div>
263 {section.needs_restart().then(|| view! { <span class="settings-restart">"需重启"</span> })}
264 </div>
265 {move || {
266 if load_failed.get() {
267 view! {
268 <p class="settings-load-error" role="alert">"读取失败,请刷新页面重试。"</p>
269 }
270 .into_any()
271 } else if ready.get() {
272 view! { <div class="settings-rows">{children()}</div> }.into_any()
273 } else {
274 view! {
275 <div class="settings-rows" aria-busy="true">
276 {(0..2)
277 .map(|_| {
278 view! {
279 <div class="settings-row">
280 <crate::components::skeletons::atoms::SkeletonBox class="h-3.5 w-20 rounded"/>
281 <crate::components::skeletons::atoms::SkeletonBox class="h-9 w-full rounded-lg"/>
282 </div>
283 }
284 })
285 .collect_view()}
286 </div>
287 }
288 .into_any()
289 }
290 }}
291 </section>
292 }
293}
294
295#[component]
297pub(super) fn SettingRow(
298 #[prop(into)] label: String,
299 #[prop(optional, into)] html_for: Option<String>,
300 #[prop(optional, into)] hint: Option<String>,
301 children: Children,
302) -> impl IntoView {
303 let label_view = match html_for {
304 Some(id) => view! { <label class="settings-label" for=id>{label}</label> }.into_any(),
305 None => view! { <span class="settings-label">{label}</span> }.into_any(),
306 };
307 view! {
308 <div class="settings-row">
309 {label_view} <div class="settings-control">
310 {children()} {hint.map(|hint| view! { <p class="settings-hint">{hint}</p> })}
311 </div>
312 </div>
313 }
314}
315
316#[component]
318pub(super) fn SwitchRow(
319 #[prop(into)] label: String,
320 #[prop(into)] hint: String,
321 #[prop(into)] checked: Signal<bool>,
322 #[prop(into)] ontoggle: Callback<()>,
323) -> impl IntoView {
324 view! {
325 <div class="settings-row settings-switch-row">
326 <span class="settings-label">{label.clone()}</span>
327 <p class="settings-hint">{hint}</p>
328 <crate::components::forms::ToggleSwitch checked aria_label=label ontoggle/>
329 </div>
330 }
331}