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