shell.rs
⎇
Raw
1//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar.
2
3use crate::i18n;
4use leptos::prelude::*;
5use wasm_bindgen_futures::spawn_local;
6
7use crate::api::PimCollectionKind;
8use crate::api::{self, Me};
9use crate::components::icon::Icon;
10use crate::components::toast::{ToastMsg, provide_toast, show_error};
11use crate::icons::IconName;
12use crate::router::{Location, Section, navigate};
13use crate::views::admin::{RoomsPanel, SettingsView, UsersView};
14use crate::views::browser::{Browser, effective_root};
15use crate::views::pim::PimView;
16use crate::views::search::SearchView;
17use crate::views::shares::SharesView;
18use crate::views::uploads::UploadsView;
19use crate::views::workspace::FileWorkspace;
20
21/// Whether the signed-in user has admin rights.
22fn is_admin_of(me: &Option<Me>) -> bool {
23 me.as_ref()
24 .and_then(|m| m.user.as_ref())
25 .is_some_and(|u| u.is_admin)
26}
27
28/// The root to open on page load, or None to stay where the URL points.
29///
30/// Rules, each one closing a loop or overriding case:
31/// 1. Only once per page load (`applied`), so a later `/me` refresh never
32/// navigates again.
33/// 2. Only from the bare `#/` location. A deep link wins.
34/// 3. Only when the default root is in the root list. The server drops a
35/// stale id already; this is the second guard.
36pub fn pick_default_root(me: &Me, loc: &Location, applied: bool) -> Option<i64> {
37 if applied || *loc != Location::root() {
38 return None;
39 }
40 let id = me.user.as_ref()?.default_root_id?;
41 me.roots.iter().any(|r| r.id == id).then_some(id)
42}
43
44/// Re-fetch this session's `/me`. For things that can change the session
45/// outside of the current view: the sidebar's refresh button, or saving a
46/// user that is the current user.
47pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
48 spawn_local(async move {
49 match api::me().await {
50 Ok(m) => set_me.set(Some(m)),
51 Err(e) => show_error(toast, e.to_string()),
52 }
53 });
54}
55
56#[component]
57pub fn ShellView(
58 me: ReadSignal<Option<Me>>,
59 set_me: WriteSignal<Option<Me>>,
60 set_phase: WriteSignal<crate::app::AuthPhase>,
61 loc: ReadSignal<Location>,
62 theme: ReadSignal<crate::theme::Theme>,
63 set_theme: WriteSignal<crate::theme::Theme>,
64) -> impl IntoView {
65 let toast = provide_toast();
66 // True while the off-canvas sidebar is open (narrow screens only).
67 let (nav_open, set_nav_open) = signal(false);
68 // The shared file-view plumbing (topbar, close/save/dirty, selection,
69 // dialogs, Escape). Closing returns to the browser listing; the
70 // selection actions show wherever the browser is the visible section.
71 let ws = FileWorkspace::new(
72 me,
73 loc,
74 || None,
75 move || loc.get().section == Section::Files,
76 );
77 let file_view = ws.file_view;
78 let open_file = ws.open_file;
79 let selected = ws.selected;
80 let set_selected = ws.set_selected;
81 let dialog = ws.dialog;
82 let set_dialog = ws.set_dialog;
83 let set_browser_refresh = ws.set_browser_refresh;
84 let topbar_ref = ws.topbar_ref;
85 let topbar_left = ws.topbar_left();
86 let topbar_right = ws.topbar_right();
87 let file_content = ws.file_content();
88 let overlays = ws.overlays();
89 // The section and the admin flag change far less often than the
90 // location does. Reading them through memos keeps a folder change from
91 // remounting the whole content component (and the sidebar's root list).
92 let section = Memo::new(move |_| loc.with(|l| l.section));
93 let admin = Memo::new(move |_| is_admin_of(&me.get()));
94 let cur_root = Memo::new(move |_| {
95 me.with(|m| {
96 m.as_ref()
97 .and_then(|m| loc.with(|l| effective_root(&m.roots, l).map(|r| r.id)))
98 })
99 });
100
101 // A non-admin cannot open the admin sections. If the URL points at one
102 // (deep link, hand-typed, or left over from a demotion), redirect to the
103 // files view so the URL, the content fallback, and the sidebar highlight
104 // all agree.
105 Effect::new(move |_| {
106 let Some(m) = me.get() else {
107 return;
108 };
109 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
110 if !admin && loc.get().section == Section::Users {
111 navigate(&Location::root());
112 }
113 });
114
115 // Open the profile's default root once per page load. The guards keep
116 // this from looping: a stale default that 404s refreshes `/me`, which
117 // must not trigger a second redirect.
118 {
119 let applied = StoredValue::new(false);
120 Effect::new(move |_| {
121 let Some(m) = me.get() else {
122 return;
123 };
124 let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value()));
125 applied.set_value(true);
126 if let Some(id) = target {
127 navigate(&Location::folder(Some(id), None, vec![]));
128 }
129 });
130 }
131
132 let on_logout = move |_| {
133 spawn_local(async move {
134 let _ = api::logout().await;
135 set_me.set(None);
136 set_phase.set(crate::app::AuthPhase::Login);
137 });
138 };
139
140 // Navigate to a manage section (each has its own URL) and, on narrow
141 // screens, close the off-canvas sidebar.
142 let open_section = move |section: Section| {
143 set_nav_open.set(false);
144 navigate(&Location {
145 section,
146 ..Location::root()
147 });
148 };
149
150 view! {
151 <div
152 class=move || {
153 if file_view.get().is_some() {
154 "layout file-mode".to_string()
155 } else {
156 "layout".to_string()
157 }
158 }
159 >
160 <header class="topbar" node_ref=topbar_ref>
161 <div class="topbar-left">
162 <button
163 class="icon-btn nav-toggle"
164 title={i18n::tr(i18n::k::MENU)}
165 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
166 on:click=move |_| set_nav_open.update(|v| *v = !*v)
167 >
168 <Icon name=IconName::Menu class="ic-btn".to_string()/>
169 </button>
170 {topbar_left}
171 </div>
172 <div class="topbar-right">
173 {topbar_right}
174 </div>
175 </header>
176 <Show when=move || nav_open.get()>
177 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
178 </Show>
179 <div class="body-row">
180 {move || {
181 file_view.get().is_none().then(|| view! {
182 <aside
183 class="sidebar"
184 class:open=move || nav_open.get()
185 >
186 <nav class="nav">
187 {move || me.get().and_then(|m| m.user).map(|u| view! {
188 <div class="sidebar-user">{u.name}</div>
189 })}
190 <div class="nav-section">
191 <div class="nav-section-head">
192 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
193 <button
194 class="icon-btn icon-btn-sm nav-section-refresh"
195 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
196 on:click=move |_| refresh_me(set_me, toast)
197 >
198 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
199 </button>
200 </div>
201 <button
202 class="nav-tab"
203 class:active=move || section.get() == Section::Search
204 on:click=move |_| open_section(Section::Search)
205 >
206 <Icon name=IconName::Search class="ic-tab".to_string()/>
207 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
208 </button>
209 {move || {
210 let m = me.get()?;
211 Some(m.roots
212 .iter()
213 .map(|r| {
214 let id = r.id;
215 let name = r.name.clone();
216 // The root's path never changes while
217 // the button exists, so the tooltip is
218 // not a reactive closure.
219 let title = if r.path == "." {
220 "/".to_string()
221 } else {
222 r.path.clone()
223 };
224 let ro = !r.mode.is_writable();
225 view! {
226 <button
227 class="nav-tab"
228 class:active=move || {
229 section.get() == Section::Files
230 && cur_root.get() == Some(id)
231 }
232 title=title
233 on:click=move |_| {
234 set_nav_open.set(false);
235 navigate(&Location::folder(
236 Some(id),
237 None,
238 vec![],
239 ));
240 }
241 >
242 <Icon name=IconName::Folder class="ic-tab".to_string()/>
243 <span class="nav-tab-name">{name}</span>
244 {ro.then(|| view! { <span class="nav-tab-note">"ro"</span> })}
245 </button>
246 }
247 })
248 .collect::<Vec<_>>())
249 }}
250 </div>
251 <div class="nav-section">
252 <div class="nav-section-label">{i18n::tr(i18n::k::PIM_NAV)}</div>
253 {[
254 (Section::Calendar, IconName::Calendar, i18n::k::PIM_CALENDAR),
255 (Section::Contacts, IconName::Contacts, i18n::k::PIM_CONTACTS),
256 ]
257 .map(|(sec, icon, label)| view! {
258 <button
259 class="nav-tab"
260 class:active=move || section.get() == sec
261 on:click=move |_| open_section(sec)
262 >
263 <Icon name=icon class="ic-tab".to_string()/>
264 <span class="nav-tab-name">{i18n::tr(label)}</span>
265 </button>
266 })}
267 </div>
268 <div class="nav-section">
269 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
270 {[
271 (Section::Shares, IconName::Share, i18n::k::SHARES),
272 (Section::Users, IconName::User, i18n::k::USERS),
273 (Section::Uploads, IconName::Upload, i18n::k::UPLOADS),
274 (Section::Settings, IconName::Settings, i18n::k::SETTINGS),
275 ]
276 .map(|(sec, icon, label)| view! {
277 <Show when=move || sec != Section::Users || admin.get()>
278 <button
279 class="nav-tab"
280 class:active=move || section.get() == sec
281 on:click=move |_| open_section(sec)
282 >
283 <Icon name=icon class="ic-tab".to_string()/>
284 <span class="nav-tab-name">{i18n::tr(label)}</span>
285 </button>
286 </Show>
287 })}
288 </div>
289 </nav>
290 <div class="sidebar-footer">
291 <button
292 class="nav-tab sidebar-action"
293 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
294 on:click=move |_| {
295 let next = theme.get().next();
296 set_theme.set(next);
297 next.store();
298 next.apply();
299 }
300 >
301 {move || {
302 view! {
303 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
304 }
305 }}
306 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
307 </button>
308 <button
309 class="nav-tab sidebar-action logout"
310 title={i18n::tr(i18n::k::LOG_OUT)}
311 on:click=move |_| on_logout(())
312 >
313 <Icon name=IconName::Logout class="ic-tab".to_string()/>
314 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
315 </button>
316 <div class="sidebar-version">
317 {format!("dovenest v{}", env!("CARGO_PKG_VERSION"))}
318 </div>
319 </div>
320 </aside>
321 })
322 }}
323 <main class="content" class:file-open=move || file_view.get().is_some()>
324 {move || {
325 match section.get() {
326 Section::Users if admin.get() => view! {
327 <div class="admin-stack">
328 <UsersView me=me set_me=set_me set_dialog=set_dialog/>
329 <RoomsPanel/>
330 </div>
331 }
332 .into_any(),
333 // Users without admin (e.g. permissions changed)
334 // falls back to the files view.
335 Section::Files | Section::Users => view! {
336 <Browser
337 me=me
338 set_me=set_me
339 loc=loc
340 open_file=open_file
341 set_browser_refresh=set_browser_refresh
342 selected=selected
343 set_selected=set_selected
344 set_dialog=set_dialog
345 dialog=dialog
346 file_view=file_view
347 />
348 }
349 .into_any(),
350 Section::Search => view! {
351 <SearchView
352 me=me
353 loc=loc
354 open_file=open_file
355 file_view=file_view
356 set_dialog=set_dialog
357 />
358 }
359 .into_any(),
360 Section::Shares => view! { <SharesView me=me/> }
361 .into_any(),
362 Section::Settings => view! {
363 <SettingsView me=me loc=loc set_me=set_me set_dialog=set_dialog/>
364 }
365 .into_any(),
366 Section::Uploads => view! { <UploadsView/> }.into_any(),
367 Section::Calendar => view! {
368 <PimView kind=PimCollectionKind::Calendar me=me loc=loc/>
369 }
370 .into_any(),
371 Section::Contacts => view! {
372 <PimView kind=PimCollectionKind::Addressbook me=me loc=loc/>
373 }
374 .into_any(),
375 }
376 }}
377 {file_content}
378 </main>
379 </div>
380 {overlays}
381 </div>
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::api::{Mode, RootInfo, UserInfo};
389
390 fn me(default: Option<i64>) -> Me {
391 Me {
392 first_boot: false,
393 user: Some(UserInfo {
394 id: 1,
395 name: "u".into(),
396 is_admin: false,
397 single_click_open: false,
398 thumbnails: false,
399 language: None,
400 week_start: 1,
401 default_root_id: default,
402 auth_mode: Default::default(),
403 has_password: true,
404 }),
405 roots: vec![RootInfo {
406 id: 7,
407 name: "r".into(),
408 path: "r".into(),
409 mode: Mode::Rw,
410 }],
411 allow_writable_shares: false,
412 thumbnails_available: false,
413 public_url: None,
414 }
415 }
416
417 #[test]
418 fn default_root_rules() {
419 let home = Location::root();
420 assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7));
421 // Once per page load.
422 assert_eq!(pick_default_root(&me(Some(7)), &home, true), None);
423 // A deep link wins.
424 let deep = Location {
425 root_id: Some(7),
426 ..Location::root()
427 };
428 assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None);
429 let section = Location {
430 section: Section::Shares,
431 ..Location::root()
432 };
433 assert_eq!(pick_default_root(&me(Some(7)), &section, false), None);
434 // Unknown root, or none set.
435 assert_eq!(pick_default_root(&me(Some(8)), &home, false), None);
436 assert_eq!(pick_default_root(&me(None), &home, false), None);
437 }
438}
439