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