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