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_svg;
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 if me.with(Option::is_some) && !admin.get() && section.get() == Section::Users {
107 navigate(&Location::root());
108 }
109 });
110
111 // Open the profile's default root once per page load. The guards keep
112 // this from looping: a stale default that 404s refreshes `/me`, which
113 // must not trigger a second redirect.
114 {
115 let applied = StoredValue::new(false);
116 Effect::new(move |_| {
117 let Some(m) = me.get() else {
118 return;
119 };
120 let target = loc.with_untracked(|l| pick_default_root(&m, l, applied.get_value()));
121 applied.set_value(true);
122 if let Some(id) = target {
123 navigate(&Location::folder(Some(id), None, vec![]));
124 }
125 });
126 }
127
128 let on_logout = move |_| {
129 spawn_local(async move {
130 let _ = api::logout().await;
131 set_me.set(None);
132 set_phase.set(crate::app::AuthPhase::Login);
133 });
134 };
135
136 // Navigate to a manage section (each has its own URL) and, on narrow
137 // screens, close the off-canvas sidebar.
138 let open_section = move |section: Section| {
139 set_nav_open.set(false);
140 navigate(&Location {
141 section,
142 ..Location::root()
143 });
144 };
145
146 view! {
147 <div class="layout" class:file-mode=move || file_view.get().is_some()>
148 <header class="topbar" node_ref=topbar_ref>
149 <div class="topbar-left">
150 <button
151 class="icon-btn nav-toggle"
152 title={i18n::tr(i18n::k::MENU)}
153 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
154 on:click=move |_| set_nav_open.update(|v| *v = !*v)
155 >
156 {icon_svg(IconName::Menu, "ic-btn")}
157 </button>
158 {topbar_left}
159 </div>
160 <div class="topbar-right">
161 {topbar_right}
162 </div>
163 </header>
164 <Show when=move || nav_open.get()>
165 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
166 </Show>
167 <div class="body-row">
168 {move || {
169 file_view.get().is_none().then(|| view! {
170 <aside
171 class="sidebar"
172 class:open=move || nav_open.get()
173 >
174 <nav class="nav">
175 {move || me.get().and_then(|m| m.user).map(|u| view! {
176 <div class="sidebar-user">{u.name}</div>
177 })}
178 <div class="nav-section">
179 <div class="nav-section-head">
180 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
181 <button
182 class="icon-btn icon-btn-sm nav-section-refresh"
183 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
184 on:click=move |_| refresh_me(set_me, toast)
185 >
186 {icon_svg(IconName::Refresh, "ic-btn")}
187 </button>
188 </div>
189 <button
190 class="nav-tab"
191 class:active=move || section.get() == Section::Search
192 on:click=move |_| open_section(Section::Search)
193 >
194 {icon_svg(IconName::Search, "ic-tab")}
195 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
196 </button>
197 {move || {
198 let m = me.get()?;
199 Some(m.roots
200 .iter()
201 .map(|r| {
202 let id = r.id;
203 let name = r.name.clone();
204 // The root's path never changes while
205 // the button exists, so the tooltip is
206 // not a reactive closure.
207 let title = if r.path == "." {
208 "/".to_string()
209 } else {
210 r.path.clone()
211 };
212 let ro = !r.mode.is_writable();
213 view! {
214 <button
215 class="nav-tab"
216 class:active=move || {
217 section.get() == Section::Files
218 && cur_root.get() == Some(id)
219 }
220 title=title
221 on:click=move |_| {
222 set_nav_open.set(false);
223 navigate(&Location::folder(
224 Some(id),
225 None,
226 vec![],
227 ));
228 }
229 >
230 {icon_svg(IconName::Folder, "ic-tab")}
231 <span class="nav-tab-name">{name}</span>
232 {ro.then(|| view! { <span class="nav-tab-note">"ro"</span> })}
233 </button>
234 }
235 })
236 .collect::<Vec<_>>())
237 }}
238 </div>
239 <div class="nav-section">
240 <div class="nav-section-label">{i18n::tr(i18n::k::PIM_NAV)}</div>
241 {[
242 (Section::Calendar, IconName::Calendar, i18n::k::PIM_CALENDAR),
243 (Section::Contacts, IconName::Contacts, i18n::k::PIM_CONTACTS),
244 ]
245 .map(|(sec, icon, label)| view! {
246 <button
247 class="nav-tab"
248 class:active=move || section.get() == sec
249 on:click=move |_| open_section(sec)
250 >
251 {icon_svg(icon, "ic-tab")}
252 <span class="nav-tab-name">{i18n::tr(label)}</span>
253 </button>
254 })}
255 </div>
256 <div class="nav-section">
257 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
258 {[
259 (Section::Shares, IconName::Share, i18n::k::SHARES),
260 (Section::Users, IconName::User, i18n::k::USERS),
261 (Section::Uploads, IconName::Upload, i18n::k::UPLOADS),
262 (Section::Settings, IconName::Settings, i18n::k::SETTINGS),
263 ]
264 .map(|(sec, icon, label)| view! {
265 <Show when=move || sec != Section::Users || admin.get()>
266 <button
267 class="nav-tab"
268 class:active=move || section.get() == sec
269 on:click=move |_| open_section(sec)
270 >
271 {icon_svg(icon, "ic-tab")}
272 <span class="nav-tab-name">{i18n::tr(label)}</span>
273 </button>
274 </Show>
275 })}
276 </div>
277 </nav>
278 <div class="sidebar-footer">
279 <button
280 class="nav-tab sidebar-action"
281 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
282 on:click=move |_| {
283 let next = theme.get().next();
284 set_theme.set(next);
285 next.store();
286 next.apply();
287 }
288 >
289 {move || icon_svg(theme.get().icon(), "ic-tab")}
290 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
291 </button>
292 <button
293 class="nav-tab sidebar-action logout"
294 title={i18n::tr(i18n::k::LOG_OUT)}
295 on:click=on_logout
296 >
297 {icon_svg(IconName::Logout, "ic-tab")}
298 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
299 </button>
300 <div class="sidebar-version">
301 {format!("dovenest v{}", env!("CARGO_PKG_VERSION"))}
302 </div>
303 </div>
304 </aside>
305 })
306 }}
307 <main class="content" class:file-open=move || file_view.get().is_some()>
308 {move || {
309 match section.get() {
310 Section::Users if admin.get() => view! {
311 <div class="admin-stack">
312 <UsersView me=me set_me=set_me set_dialog=set_dialog/>
313 <RoomsPanel/>
314 </div>
315 }
316 .into_any(),
317 // Users without admin (e.g. permissions changed)
318 // falls back to the files view.
319 Section::Files | Section::Users => view! {
320 <Browser
321 me=me
322 set_me=set_me
323 loc=loc
324 open_file=open_file
325 set_browser_refresh=set_browser_refresh
326 selected=selected
327 set_selected=set_selected
328 set_dialog=set_dialog
329 dialog=dialog
330 file_view=file_view
331 />
332 }
333 .into_any(),
334 Section::Search => view! {
335 <SearchView
336 me=me
337 loc=loc
338 open_file=open_file
339 file_view=file_view
340 set_dialog=set_dialog
341 />
342 }
343 .into_any(),
344 Section::Shares => view! { <SharesView me=me/> }
345 .into_any(),
346 Section::Settings => view! {
347 <SettingsView me=me loc=loc set_me=set_me set_dialog=set_dialog/>
348 }
349 .into_any(),
350 Section::Uploads => view! { <UploadsView/> }.into_any(),
351 Section::Calendar => view! {
352 <PimView kind=PimCollectionKind::Calendar me=me loc=loc/>
353 }
354 .into_any(),
355 Section::Contacts => view! {
356 <PimView kind=PimCollectionKind::Addressbook me=me loc=loc/>
357 }
358 .into_any(),
359 }
360 }}
361 {file_content}
362 </main>
363 </div>
364 {overlays}
365 </div>
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::api::{Mode, RootInfo, UserInfo};
373
374 fn me(default: Option<i64>) -> Me {
375 Me {
376 first_boot: false,
377 user: Some(UserInfo {
378 id: 1,
379 name: "u".into(),
380 is_admin: false,
381 single_click_open: false,
382 thumbnails: false,
383 language: None,
384 week_start: 1,
385 default_root_id: default,
386 auth_mode: Default::default(),
387 has_password: true,
388 }),
389 roots: vec![RootInfo {
390 id: 7,
391 name: "r".into(),
392 path: "r".into(),
393 mode: Mode::Rw,
394 }],
395 allow_writable_shares: false,
396 thumbnails_available: false,
397 public_url: None,
398 }
399 }
400
401 #[test]
402 fn default_root_rules() {
403 let home = Location::root();
404 assert_eq!(pick_default_root(&me(Some(7)), &home, false), Some(7));
405 // Once per page load.
406 assert_eq!(pick_default_root(&me(Some(7)), &home, true), None);
407 // A deep link wins.
408 let deep = Location {
409 root_id: Some(7),
410 ..Location::root()
411 };
412 assert_eq!(pick_default_root(&me(Some(7)), &deep, false), None);
413 let section = Location {
414 section: Section::Shares,
415 ..Location::root()
416 };
417 assert_eq!(pick_default_root(&me(Some(7)), &section, false), None);
418 // Unknown root, or none set.
419 assert_eq!(pick_default_root(&me(Some(8)), &home, false), None);
420 assert_eq!(pick_default_root(&me(None), &home, false), None);
421 }
422}
423