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