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, Entry, Me};
8use crate::components::icon::Icon;
9use crate::components::logo::Logo;
10use crate::components::toast::{ToastMsg, ToastView, provide_toast, show_error};
11use crate::icons::IconName;
12use crate::router::{Location, Section, navigate};
13use crate::views::admin::{SettingsView, UsersView};
14use crate::views::browser::{Browser, SelectionActions, effective_root};
15use crate::views::dialogs::{Dialog, DialogView};
16use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
17use crate::views::search::SearchView;
18use crate::views::shares::SharesView;
19
20/// Whether the signed-in user has admin rights.
21fn is_admin_of(me: &Option<Me>) -> bool {
22 me.as_ref()
23 .and_then(|m| m.user.as_ref())
24 .is_some_and(|u| u.is_admin)
25}
26
27/// Re-fetch this session's `/me`. For things that can change the session
28/// outside of the current view: the sidebar's refresh button, or saving a
29/// user that is the current user.
30pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
31 spawn_local(async move {
32 match api::me().await {
33 Ok(m) => set_me.set(Some(m)),
34 Err(e) => show_error(toast, e.to_string()),
35 }
36 });
37}
38
39#[component]
40pub fn ShellView(
41 me: ReadSignal<Option<Me>>,
42 set_me: WriteSignal<Option<Me>>,
43 set_phase: WriteSignal<crate::app::AuthPhase>,
44 loc: ReadSignal<Location>,
45 theme: ReadSignal<crate::theme::Theme>,
46 set_theme: WriteSignal<crate::theme::Theme>,
47) -> impl IntoView {
48 let toast = provide_toast();
49 // True while the off-canvas sidebar is open (narrow screens only).
50 let (nav_open, set_nav_open) = signal(false);
51 // A file opened in the full-page view (preview / editor / "no preview");
52 // the top bar doubles as its header.
53 let (file_view, set_file_view) = signal(Option::<FileView>::None);
54 // The open editor stores its save callback here for the top-bar button.
55 let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
56 // The open editor's guarded close request (unsaved-changes prompt).
57 let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
58 // The editor's state, shared with the top bar (dirty dot, Save button).
59 let (dirty, set_dirty) = signal(false);
60 let (saving, set_saving) = signal(false);
61 // The browser's fetch, exposed so the editor can re-list after a save.
62 let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
63 let topbar_ref = NodeRef::<leptos::html::Header>::new();
64 // The single selected entry in the file browser; its action buttons live
65 // in the top bar's top right. `None` shows the empty-area actions.
66 let (selected, set_selected) = signal(Option::<Entry>::None);
67 // Dialogs are rendered here, above everything (used by both the browser
68 // and the top bar's selection actions).
69 let (dialog, set_dialog) = signal(Option::<Dialog>::None);
70 // Long-lived owner for callbacks created inside short-lived scopes
71 // (top-bar selection actions).
72 let shell_owner = Owner::current().unwrap();
73
74 // Navigating (new folder, root, section) invalidates the selection.
75 Effect::new(move |_| {
76 let _ = loc.get();
77 set_selected.set(None);
78 });
79
80 let close_file = Callback::new(move |_| {
81 set_file_view.set(None);
82 set_save_cb.set(None);
83 set_close_cb.set(None);
84 set_dirty.set(false);
85 set_saving.set(false);
86 });
87
88 // Escape closes previews and the "no preview" view. The editor handles
89 // Escape itself (it asks first when there are unsaved changes).
90 {
91 let fv = file_view;
92 let cf = close_file;
93 crate::util::owned_window_listener(
94 leptos::ev::keydown,
95 move |ev: web_sys::KeyboardEvent| {
96 if ev.key() != "Escape" {
97 return;
98 }
99 if fv
100 .get()
101 .is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
102 {
103 cf.run(());
104 }
105 },
106 );
107 }
108
109 // A non-admin cannot open the admin sections. If the URL points at one
110 // (deep link, hand-typed, or left over from a demotion), redirect to the
111 // files view so the URL, the content fallback, and the sidebar highlight
112 // all agree.
113 {
114 let me2 = me;
115 let loc2 = loc;
116 Effect::new(move |_| {
117 let Some(m) = me2.get() else {
118 return;
119 };
120 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
121 if admin {
122 return;
123 }
124 let section = loc2.get().section;
125 if section == Section::Users {
126 navigate(&Location::root());
127 }
128 });
129 }
130
131 let on_logout = move |_| {
132 spawn_local(async move {
133 let _ = api::logout().await;
134 set_me.set(None);
135 set_phase.set(crate::app::AuthPhase::Login);
136 });
137 };
138
139 // Navigate to a manage section (each has its own URL) and, on narrow
140 // screens, close the off-canvas sidebar.
141 let open_section = move |section: Section| {
142 set_nav_open.set(false);
143 navigate(&Location {
144 root_id: None,
145 path: Vec::new(),
146 share_token: None,
147 section,
148 });
149 };
150
151 view! {
152 <div
153 class=move || {
154 if file_view.get().is_some() {
155 "layout file-mode".to_string()
156 } else {
157 "layout".to_string()
158 }
159 }
160 >
161 <header class="topbar" node_ref=topbar_ref>
162 <div class="topbar-left">
163 <button
164 class="icon-btn nav-toggle"
165 title={i18n::tr(i18n::k::MENU)}
166 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
167 on:click=move |_| set_nav_open.update(|v| *v = !*v)
168 >
169 <Icon name=IconName::Menu class="ic-btn".to_string()/>
170 </button>
171 {move || match file_view.get() {
172 Some(ref v) => view! {
173 // The brand stays in file mode as an icon-only
174 // link to the root folder, left of the file title.
175 <a class="brand brand-icon" href="#/">
176 <Logo/>
177 </a>
178 <FileViewTitle view=v.clone() dirty=dirty/>
179 }
180 .into_view()
181 .into_any(),
182 None => view! {
183 <a class="brand" href="#/"><Logo/><span class="brand-name">"filebrowser-ng"</span></a>
184 }
185 .into_view()
186 .into_any(),
187 }}
188 </div>
189 {move || {
190 if let Some(v) = file_view.get() {
191 view! {
192 <FileViewActions
193 view=v
194 saving=saving
195 dirty=dirty
196 save_cb=save_cb
197 close_cb=close_cb
198 show_close=true
199 on_close=close_file
200 toast=toast
201 />
202 }
203 .into_view()
204 .into_any()
205 } else if loc.get().section == Section::Files {
206 let refresh = browser_refresh
207 .get()
208 .unwrap_or_else(|| Callback::new(move |_| {}));
209 view! {
210 <SelectionActions
211 me=me
212 loc=loc
213 selected=selected
214 open_file=set_file_view
215 refresh=refresh
216 toast=toast
217 set_dialog=set_dialog
218 owner=shell_owner.clone()
219 topbar_ref=topbar_ref
220 />
221 }
222 .into_view()
223 .into_any()
224 } else {
225 view! {}.into_any()
226 }
227 }}
228 </header>
229 {move || {
230 if nav_open.get() {
231 view! {
232 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
233 }
234 .into_view()
235 .into_any()
236 } else {
237 view! {}.into_any()
238 }
239 }}
240 <div class="body-row">
241 {move || {
242 if file_view.get().is_none() {
243 view! {
244 <aside
245 class="sidebar"
246 class:open=move || nav_open.get()
247 >
248 <nav class="nav">
249 {move || {
250 let Some(u) = me.get().and_then(|m| m.user) else {
251 return {
252 view! {}.into_any()
253 };
254 };
255 view! {
256 <div class="sidebar-user">{u.name.clone()}</div>
257 }
258 .into_view()
259 .into_any()
260 }}
261 <div class="nav-section">
262 <div class="nav-section-head">
263 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
264 <button
265 class="icon-btn icon-btn-sm nav-section-refresh"
266 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
267 on:click=move |_| refresh_me(set_me, toast)
268 >
269 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
270 </button>
271 </div>
272 <button
273 class="nav-tab"
274 class:active=move || loc.get().section == Section::Search
275 on:click=move |_| open_section(Section::Search)
276 >
277 <Icon name=IconName::Search class="ic-tab".to_string()/>
278 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
279 </button>
280 {move || {
281 let Some(m) = me.get() else {
282 return {
283 view! {}.into_any()
284 };
285 };
286 let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
287 m.roots
288 .iter()
289 .map(|r| {
290 let id = r.id;
291 let name = r.name.clone();
292 let path = r.path.clone();
293 let ro = !r.mode.is_writable();
294 view! {
295 <button
296 class="nav-tab"
297 class:active=move || {
298 loc.get().section == Section::Files
299 && cur_root == Some(id)
300 }
301 title=move || {
302 if path == "." {
303 "/".to_string()
304 } else {
305 path.clone()
306 }
307 }
308 on:click=move |_| {
309 set_nav_open.set(false);
310 navigate(&Location {
311 root_id: Some(id),
312 path: vec![],
313 share_token: None,
314 section: Section::Files,
315 });
316 }
317 >
318 <Icon name=IconName::Folder class="ic-tab".to_string()/>
319 <span class="nav-tab-name">{name}</span>
320 {move || {
321 if ro {
322 view! { <span class="nav-tab-note">"ro"</span> }
323 .into_view()
324 .into_any()
325 } else {
326 view! {}.into_any()
327 }
328 }}
329 </button>
330 }
331 })
332 .collect::<Vec<_>>()
333 .into_view()
334 .into_any()
335 }}
336 </div>
337 <div class="nav-section">
338 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
339 <button
340 class="nav-tab"
341 class:active=move || loc.get().section == Section::Shares
342 on:click=move |_| open_section(Section::Shares)
343 >
344 <Icon name=IconName::Share class="ic-tab".to_string()/>
345 <span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
346 </button>
347 <Show when=move || is_admin_of(&me.get())>
348 <button
349 class="nav-tab"
350 class:active=move || loc.get().section == Section::Users
351 on:click=move |_| open_section(Section::Users)
352 >
353 <Icon name=IconName::User class="ic-tab".to_string()/>
354 <span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
355 </button>
356 </Show>
357 <button
358 class="nav-tab"
359 class:active=move || loc.get().section == Section::Settings
360 on:click=move |_| open_section(Section::Settings)
361 >
362 <Icon name=IconName::Settings class="ic-tab".to_string()/>
363 <span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
364 </button>
365 </div>
366 </nav>
367 <div class="sidebar-footer">
368 <button
369 class="nav-tab sidebar-action"
370 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
371 on:click=move |_| {
372 let next = theme.get().next();
373 set_theme.set(next);
374 next.store();
375 next.apply();
376 }
377 >
378 {move || {
379 view! {
380 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
381 }
382 }}
383 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
384 </button>
385 <button
386 class="nav-tab sidebar-action logout"
387 title={i18n::tr(i18n::k::LOG_OUT)}
388 on:click=move |_| on_logout(())
389 >
390 <Icon name=IconName::Logout class="ic-tab".to_string()/>
391 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
392 </button>
393 <div class="sidebar-version">
394 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
395 </div>
396 </div>
397 </aside>
398 }
399 .into_view()
400 .into_any()
401 } else {
402 view! {}.into_any()
403 }
404 }}
405 <main class="content" class:file-open=move || file_view.get().is_some()>
406 {move || {
407 let admin = is_admin_of(&me.get());
408 match loc.get().section {
409 Section::Files => view! {
410 <Browser
411 me=me
412 set_me=set_me
413 loc=loc
414 open_file=set_file_view
415 set_browser_refresh=set_browser_refresh
416 selected=selected
417 set_selected=set_selected
418 set_dialog=set_dialog
419 dialog=dialog
420 file_view=file_view
421 />
422 }
423 .into_view()
424 .into_any(),
425 Section::Search => view! {
426 <SearchView
427 me=me
428 open_file=set_file_view
429 file_view=file_view
430 />
431 }
432 .into_view()
433 .into_any(),
434 Section::Shares => view! { <SharesView/> }
435 .into_view()
436 .into_any(),
437 Section::Users if admin => {
438 view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
439 }
440 Section::Settings => view! {
441 <SettingsView me=me set_me=set_me/>
442 }
443 .into_view()
444 .into_any(),
445 // Not an admin anymore (e.g. permissions changed):
446 // fall back to the files view.
447 _ => view! {
448 <Browser
449 me=me
450 set_me=set_me
451 loc=loc
452 open_file=set_file_view
453 set_browser_refresh=set_browser_refresh
454 selected=selected
455 set_selected=set_selected
456 set_dialog=set_dialog
457 dialog=dialog
458 file_view=file_view
459 />
460 }
461 .into_view()
462 .into_any(),
463 }
464 }}
465 {move || match file_view.get() {
466 Some(v) => {
467 let refresh = browser_refresh
468 .get()
469 .unwrap_or_else(|| Callback::new(move |_| {}));
470 view! {
471 <FileViewContent
472 view=v
473 register_save=set_save_cb
474 register_close=set_close_cb
475 dirty=dirty
476 set_dirty=set_dirty
477 set_saving=set_saving
478 close=close_file
479 toast=toast
480 refresh=refresh
481 />
482 }
483 .into_view()
484 .into_any()
485 }
486 None => view! {}.into_any(),
487 }}
488 </main>
489 </div>
490 <ToastView toast=toast/>
491 <DialogView dialog=dialog set_dialog=set_dialog/>
492 </div>
493 }
494}
495