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 <FileViewTitle view=v.clone() dirty=dirty/>
174 }
175 .into_view()
176 .into_any(),
177 None => view! {
178 <a class="brand" href="#/"><Logo/><span class="brand-name">"filebrowser-ng"</span></a>
179 }
180 .into_view()
181 .into_any(),
182 }}
183 </div>
184 {move || {
185 if file_view.get().is_some() {
186 view! {
187 <FileViewActions
188 saving=saving
189 dirty=dirty
190 save_cb=save_cb
191 close_cb=close_cb
192 show_close=true
193 on_close=close_file
194 />
195 }
196 .into_view()
197 .into_any()
198 } else if loc.get().section == Section::Files {
199 let refresh = browser_refresh
200 .get()
201 .unwrap_or_else(|| Callback::new(move |_| {}));
202 view! {
203 <SelectionActions
204 me=me
205 loc=loc
206 selected=selected
207 open_file=set_file_view
208 refresh=refresh
209 toast=toast
210 set_dialog=set_dialog
211 owner=shell_owner.clone()
212 topbar_ref=topbar_ref
213 />
214 }
215 .into_view()
216 .into_any()
217 } else {
218 view! {}.into_any()
219 }
220 }}
221 </header>
222 {move || {
223 if nav_open.get() {
224 view! {
225 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
226 }
227 .into_view()
228 .into_any()
229 } else {
230 view! {}.into_any()
231 }
232 }}
233 <div class="body-row">
234 {move || {
235 if file_view.get().is_none() {
236 view! {
237 <aside
238 class="sidebar"
239 class:open=move || nav_open.get()
240 >
241 <nav class="nav">
242 {move || {
243 let Some(u) = me.get().and_then(|m| m.user) else {
244 return {
245 view! {}.into_any()
246 };
247 };
248 view! {
249 <div class="sidebar-user">{u.name.clone()}</div>
250 }
251 .into_view()
252 .into_any()
253 }}
254 <div class="nav-section">
255 <div class="nav-section-head">
256 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
257 <button
258 class="icon-btn icon-btn-sm nav-section-refresh"
259 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
260 on:click=move |_| refresh_me(set_me, toast)
261 >
262 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
263 </button>
264 </div>
265 <button
266 class="nav-tab"
267 class:active=move || loc.get().section == Section::Search
268 on:click=move |_| open_section(Section::Search)
269 >
270 <Icon name=IconName::Search class="ic-tab".to_string()/>
271 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
272 </button>
273 {move || {
274 let Some(m) = me.get() else {
275 return {
276 view! {}.into_any()
277 };
278 };
279 let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
280 m.roots
281 .iter()
282 .map(|r| {
283 let id = r.id;
284 let name = r.name.clone();
285 let path = r.path.clone();
286 let ro = !r.mode.is_writable();
287 view! {
288 <button
289 class="nav-tab"
290 class:active=move || {
291 loc.get().section == Section::Files
292 && cur_root == Some(id)
293 }
294 title=move || {
295 if path == "." {
296 "/".to_string()
297 } else {
298 path.clone()
299 }
300 }
301 on:click=move |_| {
302 set_nav_open.set(false);
303 navigate(&Location {
304 root_id: Some(id),
305 path: vec![],
306 share_token: None,
307 section: Section::Files,
308 });
309 }
310 >
311 <Icon name=IconName::Folder class="ic-tab".to_string()/>
312 <span class="nav-tab-name">{name}</span>
313 {move || {
314 if ro {
315 view! { <span class="nav-tab-note">"ro"</span> }
316 .into_view()
317 .into_any()
318 } else {
319 view! {}.into_any()
320 }
321 }}
322 </button>
323 }
324 })
325 .collect::<Vec<_>>()
326 .into_view()
327 .into_any()
328 }}
329 </div>
330 <div class="nav-section">
331 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
332 <button
333 class="nav-tab"
334 class:active=move || loc.get().section == Section::Shares
335 on:click=move |_| open_section(Section::Shares)
336 >
337 <Icon name=IconName::Share class="ic-tab".to_string()/>
338 <span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
339 </button>
340 <Show when=move || is_admin_of(&me.get())>
341 <button
342 class="nav-tab"
343 class:active=move || loc.get().section == Section::Users
344 on:click=move |_| open_section(Section::Users)
345 >
346 <Icon name=IconName::User class="ic-tab".to_string()/>
347 <span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
348 </button>
349 </Show>
350 <button
351 class="nav-tab"
352 class:active=move || loc.get().section == Section::Settings
353 on:click=move |_| open_section(Section::Settings)
354 >
355 <Icon name=IconName::Settings class="ic-tab".to_string()/>
356 <span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
357 </button>
358 </div>
359 </nav>
360 <div class="sidebar-footer">
361 <button
362 class="nav-tab sidebar-action"
363 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
364 on:click=move |_| {
365 let next = theme.get().next();
366 set_theme.set(next);
367 next.store();
368 next.apply();
369 }
370 >
371 {move || {
372 view! {
373 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
374 }
375 }}
376 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
377 </button>
378 <button
379 class="nav-tab sidebar-action logout"
380 title={i18n::tr(i18n::k::LOG_OUT)}
381 on:click=move |_| on_logout(())
382 >
383 <Icon name=IconName::Logout class="ic-tab".to_string()/>
384 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
385 </button>
386 <div class="sidebar-version">
387 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
388 </div>
389 </div>
390 </aside>
391 }
392 .into_view()
393 .into_any()
394 } else {
395 view! {}.into_any()
396 }
397 }}
398 <main class="content" class:file-open=move || file_view.get().is_some()>
399 {move || {
400 let admin = is_admin_of(&me.get());
401 match loc.get().section {
402 Section::Files => view! {
403 <Browser
404 me=me
405 set_me=set_me
406 loc=loc
407 open_file=set_file_view
408 set_browser_refresh=set_browser_refresh
409 selected=selected
410 set_selected=set_selected
411 set_dialog=set_dialog
412 file_view=file_view
413 />
414 }
415 .into_view()
416 .into_any(),
417 Section::Search => view! {
418 <SearchView
419 me=me
420 open_file=set_file_view
421 file_view=file_view
422 />
423 }
424 .into_view()
425 .into_any(),
426 Section::Shares => view! { <SharesView/> }
427 .into_view()
428 .into_any(),
429 Section::Users if admin => {
430 view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
431 }
432 Section::Settings => view! {
433 <SettingsView me=me set_me=set_me/>
434 }
435 .into_view()
436 .into_any(),
437 // Not an admin anymore (e.g. permissions changed):
438 // fall back to the files view.
439 _ => view! {
440 <Browser
441 me=me
442 set_me=set_me
443 loc=loc
444 open_file=set_file_view
445 set_browser_refresh=set_browser_refresh
446 selected=selected
447 set_selected=set_selected
448 set_dialog=set_dialog
449 file_view=file_view
450 />
451 }
452 .into_view()
453 .into_any(),
454 }
455 }}
456 {move || match file_view.get() {
457 Some(v) => {
458 let refresh = browser_refresh
459 .get()
460 .unwrap_or_else(|| Callback::new(move |_| {}));
461 view! {
462 <FileViewContent
463 view=v
464 register_save=set_save_cb
465 register_close=set_close_cb
466 dirty=dirty
467 set_dirty=set_dirty
468 set_saving=set_saving
469 close=close_file
470 toast=toast
471 refresh=refresh
472 />
473 }
474 .into_view()
475 .into_any()
476 }
477 None => view! {}.into_any(),
478 }}
479 </main>
480 </div>
481 <ToastView toast=toast/>
482 <DialogView dialog=dialog set_dialog=set_dialog/>
483 </div>
484 }
485}
486