tallyweb_frontend/
app.rs

1#![allow(non_snake_case)]
2use components::*;
3use leptos::*;
4use leptos_meta::*;
5use leptos_router::*;
6
7use super::{elements::*, pages::*, preferences::ProvidePreferences, session::*, *};
8
9pub const SIDEBAR_MIN_WIDTH: usize = 280;
10
11#[component]
12pub fn App() -> impl IntoView {
13    // Provides context that manages stylesheets, titles, meta tags, etc.
14    provide_meta_context();
15
16    let close_overlay_signal = create_rw_signal(CloseOverlays());
17    provide_context(close_overlay_signal);
18
19    let close_overlays = move |_| {
20        close_overlay_signal.update(|_| ());
21    };
22
23    let show_sidebar = create_rw_signal(ShowSidebar(true));
24    provide_context(show_sidebar);
25
26    view! {
27        <Stylesheet href=format!("/pkg/{LEPTOS_OUTPUT_NAME}.css") />
28        <Stylesheet href="/fa/css/all.css" />
29
30        <Link rel="shortcut icon" type_="image/ico" href="/favicon.svg" />
31        <Link href="https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet" />
32
33        <Meta name="viewport" content="width=device-width, initial-scale=1.0" />
34        <Meta name="apple-mobile-web-app-capable" content="yes" />
35
36        <Title text="TallyWeb" />
37
38        <ProvideMessageSystem />
39        <Router>
40            <main on:click=close_overlays>
41                <Routes>
42                    <Route
43                        path=""
44                        ssr=SsrMode::Async
45                        view=|| {
46                            view! {
47                                <ProvideSessionSignal>
48                                    <ProvideScreenSignal>
49                                        <ProvidePreferences>
50                                            <ProvideStore>
51                                                <ProvideCountableSignals>
52                                                    <Outlet />
53                                                </ProvideCountableSignals>
54                                            </ProvideStore>
55                                        </ProvidePreferences>
56                                    </ProvideScreenSignal>
57                                </ProvideSessionSignal>
58                            }
59                        }
60                    >
61
62                        <Route
63                            path="/"
64                            view=|| {
65                                view! {
66                                    <Outlet />
67                                    <HomePage />
68                                }
69                            }
70                        >
71
72                            <Route path="" view=UnsetCountable />
73                            <Route path=":key" view=SetCountable />
74                        </Route>
75                        <Route path="/edit" view=EditWindow>
76                            <Route path=":key" view=move || view! { <EditCountableWindow /> } />
77                        </Route>
78
79                        <Route path="/preferences" view=move || view! { <PreferencesWindow /> } />
80
81                        <Route
82                            path="/change-username"
83                            view=move || view! { <ChangeAccountInfo /> }
84                        />
85                        <Route path="/change-password" view=ChangePassword />
86                    </Route>
87                    <TestRoutes />
88                    <Route path="/login" view=LoginPage />
89                    <Route path="/create-account" view=CreateAccount />
90                    <Route path="/*any" view=NotFound />
91                </Routes>
92            </main>
93        </Router>
94    }
95}
96
97/// 404 - Not Found
98#[component]
99fn NotFound() -> impl IntoView {
100    // set an HTTP status code 404
101    // this is feature gated because it can only be done during
102    // initial server-side rendering
103    // if you navigate to the 404 page subsequently, the status
104    // code will not be set because there is not a new HTTP request
105    // to the server
106    #[cfg(feature = "ssr")]
107    {
108        // this can be done inline because it's synchronous
109        // if it were async, we'd use a server function
110        let resp = expect_context::<leptos_actix::ResponseOptions>();
111        resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
112    }
113
114    view! { <h1>"Not Found"</h1> }
115}
116
117#[component]
118fn RouteSidebar(children: ChildrenFn) -> impl IntoView {
119    let selection = expect_context::<SelectionSignal>();
120    let show_sidebar = expect_context::<RwSignal<ShowSidebar>>();
121    let screen = expect_context::<Screen>();
122
123    let sidebar_layout: Signal<SidebarLayout> = create_read_slice(screen.style, |s| (*s).into());
124
125    let sidebar_width = create_rw_signal(400);
126    provide_context(sidebar_width);
127
128    let section_width = create_memo(move |_| {
129        if show_sidebar().0 {
130            format!("calc(100vw - {}px)", sidebar_width())
131        } else {
132            String::from("100vw")
133        }
134    });
135
136    create_isomorphic_effect(move |_| {
137        if screen.style.get() != ScreenStyle::Big {
138            let sel_memo = create_read_slice(selection, |sel| sel.is_empty());
139            sel_memo.with(|sel| show_sidebar.update(|s| *s = ShowSidebar(*sel)));
140        }
141    });
142
143    let suppress_transition = create_rw_signal(false);
144    let trans_class = move || (!suppress_transition()).then_some("transition-width");
145
146    let on_resize = move |ev: ev::DragEvent| {
147        if ev.client_x() as usize > SIDEBAR_MIN_WIDTH {
148            suppress_transition.set(true);
149            sidebar_width.update(|w| *w = ev.client_x() as usize);
150        } else {
151            suppress_transition.set(false);
152        }
153    };
154
155    view! {
156        <div style:display="flex">
157            <Sidebar
158                display=show_sidebar
159                layout=sidebar_layout
160                width=sidebar_width
161                attr:class=trans_class
162            >
163                <SidebarContent />
164                <Show when=move || (screen.style)() != ScreenStyle::Portrait>
165                    <ResizeBar
166                        position=sidebar_width
167                        direction=Direction::Vertical
168                        on:drag=on_resize
169                    />
170                </Show>
171            </Sidebar>
172            <section style:flex-grow="1" class=trans_class style:width=section_width>
173                {children}
174            </section>
175        </div>
176    }
177}
178
179#[component]
180pub fn HomePage() -> impl IntoView {
181    let selection_signal = expect_context::<SelectionSignal>();
182    let show_sidebar = expect_context::<RwSignal<ShowSidebar>>();
183
184    let active = create_memo(move |_| {
185        selection_signal
186            .get()
187            .get_selected_keys()
188            .into_iter()
189            .copied()
190            .collect()
191    });
192
193    view! {
194        <RouteSidebar>
195            <div id="HomeGrid">
196                <Navbar show_sidebar />
197                <InfoBox countable_list=active />
198            </div>
199        </RouteSidebar>
200    }
201}
202
203#[component(transparent)]
204fn SidebarContent() -> impl IntoView {
205    let selection_signal = expect_context::<SelectionSignal>();
206    let preferences = expect_context::<RwSignal<Preferences>>();
207    let store = expect_context::<RwSignal<CountableStore>>();
208    let sort_method = expect_context::<RwSignal<SortMethod>>();
209
210    let show_sort_search = create_rw_signal(true);
211    let show_sep = create_read_slice(preferences, |pref| pref.show_separator);
212    let search = create_rw_signal(String::new());
213    provide_context(search);
214
215    let each = create_memo(move |_| {
216        let mut root_nodes = store()
217            .filter(move |c| c.name().to_lowercase().contains(&search().to_lowercase()))
218            .raw_filter(move |c| !c.is_archived())
219            .root_nodes();
220        root_nodes.sort_by(|a, b| {
221            sort_method().sort_by()(&store.get_untracked(), &a.uuid().into(), &b.uuid().into())
222        });
223        root_nodes
224    });
225
226    #[allow(clippy::single_match)]
227    let on_sort_key = move |ev: ev::KeyboardEvent| match ev.key().as_str() {
228        "Enter" => {
229            let mut nodes = store()
230                .raw_filter(move |c| c.name().to_lowercase().contains(&search().to_lowercase()))
231                .nodes();
232            nodes.sort_by(|a, b| {
233                sort_method().sort_by()(&store.get_untracked(), &a.uuid().into(), &b.uuid().into())
234            });
235            if let Some(first) = nodes.first() {
236                leptos_router::use_navigate()(&first.uuid().to_string(), Default::default());
237            }
238        }
239        _ => {}
240    };
241
242    view! {
243        <nav>
244            <SortSearch shown=show_sort_search search on_keydown=on_sort_key />
245        </nav>
246        <TreeViewWidget
247            each
248            key=|countable| countable.uuid()
249            each_child=move |countable| {
250                let key = countable.uuid().into();
251                let children = create_read_slice(
252                    store,
253                    move |s| {
254                        let mut children = s.children(&key);
255                        children
256                            .sort_by(|a, b| sort_method()
257                                .sort_by()(
258                                &store.get_untracked(),
259                                &a.uuid().into(),
260                                &b.uuid().into(),
261                            ));
262                        children
263                    },
264                );
265                children()
266            }
267
268            view=|countable| view! { <TreeViewRow key=countable.uuid() /> }
269            show_separator=show_sep
270            selection_model=selection_signal
271            on_click=|_, _| ()
272        />
273
274        <NewCounterButton />
275    }
276}
277
278#[component]
279fn TreeViewRow(key: uuid::Uuid) -> impl IntoView {
280    let selection = expect_context::<SelectionSignal>();
281    let data_resource = expect_context::<StateResource>();
282    let store = expect_context::<RwSignal<CountableStore>>();
283    let save_handler = expect_context::<RwSignal<SaveHandlers>>();
284    let search = create_memo(move |_| expect_context::<RwSignal<String>>()());
285
286    let expand_node = move |key: uuid::Uuid, expand: bool| {
287        selection.update(|s| {
288            if let Some(node) = s.get_node_mut(&key) {
289                node.set_expand(expand)
290            }
291        })
292    };
293
294    let includes_search = create_memo(move |_| {
295        !search().is_empty()
296            && store
297                .get_untracked()
298                .name(&key.into())
299                .to_lowercase()
300                .contains(&search().to_lowercase())
301    });
302    let selected = create_memo(move |_| selection().is_selected(&key));
303    let parent = store.get_untracked().parent(&key.into());
304
305    create_isomorphic_effect(move |_| {
306        if let Some(p) = parent.clone() {
307            if includes_search() || selected() {
308                expand_node(p.uuid(), true)
309            } else {
310                expand_node(p.uuid(), false)
311            }
312        }
313    });
314
315    let click_new_phase = move |ev: ev::MouseEvent| {
316        ev.stop_propagation();
317
318        let phase_number = store.get_untracked().children(&key.into()).len();
319        let name = format!("Phase {}", phase_number + 1);
320
321        store.update(move |s| {
322            let id = s.new_countable(&name, CountableKind::Phase, Some(key.into()));
323            let _ = save_handler().save(
324                Box::new([s.get(&id).unwrap()].to_vec()),
325                Box::new(move |_| data_resource.refetch()),
326            );
327        });
328
329        request_animation_frame(move || expand_node(key, true))
330    };
331
332    let show_context_menu = create_rw_signal(false);
333    let (click_location, set_click_location) = create_signal((0, 0));
334    let on_right_click = move |ev: web_sys::MouseEvent| {
335        ev.prevent_default();
336        expect_context::<RwSignal<CloseOverlays>>().update(|_| ());
337        show_context_menu.set(!show_context_menu());
338        set_click_location((ev.x(), ev.y()))
339    };
340
341    let has_children = move || matches!(store().get(&key.into()), Some(Countable::Counter(_)));
342
343    let search_split = create_memo(move |_| {
344        if search().is_empty() {
345            return None;
346        }
347        let name = store().name(&key.into()).to_lowercase();
348        if let Some(idx) = name.find(&search().to_lowercase()) {
349            let (first, rest) = name.split_at(idx);
350            let (_, last) = rest.split_at(search().len());
351            Some((first.to_string(), last.to_string()))
352        } else {
353            None
354        }
355    });
356
357    view! {
358        <A href=move || key.to_string()>
359            <div class="row-body" on:contextmenu=on_right_click>
360                <Show
361                    when=move || search_split().is_some()
362                    fallback=move || view! { <span>{move || store().name(&key.into())}</span> }
363                >
364                    <div>
365                        <span>{move || search_split().unwrap().0}</span>
366                        <span style:background="var(--accent)" style:color="black">
367                            {search}
368                        </span>
369                        <span>{move || search_split().unwrap().1}</span>
370                    </div>
371                </Show>
372                <Show when=has_children>
373                    <button on:click=click_new_phase>+</button>
374                </Show>
375            </div>
376        </A>
377        <CountableContextMenu show_overlay=show_context_menu location=click_location key />
378    }
379}
380
381#[component]
382fn SetCountable() -> impl IntoView {
383    #[derive(Debug, PartialEq, Params, Clone)]
384    struct Key {
385        key: String,
386    }
387
388    let selection = expect_context::<SelectionSignal>();
389
390    let key_memo = create_memo(move |old_key| {
391        let new_key = use_params::<Key>()
392            .get()
393            .ok()
394            .and_then(|p| uuid::Uuid::parse_str(&p.key).ok());
395
396        if let Some(key) = new_key
397            && old_key != Some(&new_key)
398        {
399            selection.update(|sel| sel.select(&key));
400        }
401
402        new_key
403    });
404
405    create_isomorphic_effect(move |_| key_memo.track());
406}
407
408#[component]
409fn UnsetCountable() -> impl IntoView {
410    let selection = expect_context::<SelectionSignal>();
411    selection.update(|sel| sel.clear_selection())
412}
413
414#[component(transparent)]
415fn ProvideCountableSignals(children: ChildrenFn) -> impl IntoView {
416    let msg = expect_context::<MessageJar>();
417    let store = expect_context::<RwSignal<CountableStore>>();
418
419    let selection = SelectionModel::<uuid::Uuid, Countable>::new();
420    let selection_signal = create_rw_signal(selection);
421    provide_context(selection_signal);
422
423    provide_context(create_rw_signal(SortMethod::default()));
424
425    let save_handlers = create_rw_signal(SaveHandlers::new());
426
427    let server_handler = Box::new(ServerSaveHandler::new());
428    save_handlers.update(|sh| sh.connect_handler(server_handler.clone()));
429
430    // when the page closes, gets minimized or navigated away from save the store
431    window_event_listener(ev::blur, move |_| {
432        if let Err(err) = save_handlers
433            .get_untracked()
434            .save(Box::new(store), Box::new(|_| ()))
435        {
436            msg.set_err(err)
437        }
438    });
439
440    create_effect(move |_| {
441        spawn_local(async move {
442            let indexed_handler = indexed::IndexedSaveHandler::new().await;
443            match indexed_handler {
444                Ok(ih) => {
445                    let mut s = store.get_untracked();
446                    if let Err(err) = ih.sync_store(&mut s).await {
447                        msg.set_err(err);
448                    };
449                    if let Err(err) = save_handlers
450                        .get_untracked()
451                        .save(Box::new(s.clone()), Box::new(|_| ()))
452                    {
453                        msg.set_err(err);
454                    }
455                    store.set(s.clone());
456                    save_handlers.update(|sh| sh.connect_handler(Box::new(ih)));
457                    if let Err(err) = save_handlers
458                        .get_untracked()
459                        .save(Box::new(store), Box::new(|_| ()))
460                    {
461                        msg.set_err(err)
462                    }
463                }
464                Err(err) => msg.set_msg(format!(
465                    "Local saving could not be initialised\nGot error: {}",
466                    err
467                )),
468            }
469        })
470    });
471    provide_context(save_handlers);
472
473    children()
474}