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, Me};
8use crate::components::icon::Icon;
9use crate::components::toast::{ToastMsg, provide_toast, show_error};
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::search::SearchView;
15use crate::views::shares::SharesView;
16use crate::views::workspace::FileWorkspace;
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_error(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 // The shared file-view plumbing (topbar, close/save/dirty, selection,
50 // dialogs, Escape). Closing returns to the browser listing; the
51 // selection actions show wherever the browser is the visible section.
52 let ws = FileWorkspace::new(
53 me,
54 loc,
55 || None,
56 move || loc.get().section == Section::Files,
57 );
58 let file_view = ws.file_view;
59 let open_file = ws.open_file;
60 let selected = ws.selected;
61 let set_selected = ws.set_selected;
62 let dialog = ws.dialog;
63 let set_dialog = ws.set_dialog;
64 let set_browser_refresh = ws.set_browser_refresh;
65 let topbar_ref = ws.topbar_ref;
66 let topbar_left = ws.topbar_left();
67 let topbar_right = ws.topbar_right();
68 let file_content = ws.file_content();
69 let overlays = ws.overlays();
70 // The section and the admin flag change far less often than the
71 // location does. Reading them through memos keeps a folder change from
72 // remounting the whole content component (and the sidebar's root list).
73 let section = Memo::new(move |_| loc.with(|l| l.section));
74 let admin = Memo::new(move |_| is_admin_of(&me.get()));
75 let cur_root = Memo::new(move |_| {
76 me.with(|m| {
77 m.as_ref()
78 .and_then(|m| loc.with(|l| effective_root(&m.roots, l).map(|r| r.id)))
79 })
80 });
81
82 // A non-admin cannot open the admin sections. If the URL points at one
83 // (deep link, hand-typed, or left over from a demotion), redirect to the
84 // files view so the URL, the content fallback, and the sidebar highlight
85 // all agree.
86 {
87 let me2 = me;
88 let loc2 = loc;
89 Effect::new(move |_| {
90 let Some(m) = me2.get() else {
91 return;
92 };
93 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
94 if admin {
95 return;
96 }
97 let section = loc2.get().section;
98 if section == Section::Users {
99 navigate(&Location::root());
100 }
101 });
102 }
103
104 let on_logout = move |_| {
105 spawn_local(async move {
106 let _ = api::logout().await;
107 set_me.set(None);
108 set_phase.set(crate::app::AuthPhase::Login);
109 });
110 };
111
112 // Navigate to a manage section (each has its own URL) and, on narrow
113 // screens, close the off-canvas sidebar.
114 let open_section = move |section: Section| {
115 set_nav_open.set(false);
116 navigate(&Location {
117 root_id: None,
118 path: Vec::new(),
119 share_token: None,
120 section,
121 });
122 };
123
124 view! {
125 <div
126 class=move || {
127 if file_view.get().is_some() {
128 "layout file-mode".to_string()
129 } else {
130 "layout".to_string()
131 }
132 }
133 >
134 <header class="topbar" node_ref=topbar_ref>
135 <div class="topbar-left">
136 <button
137 class="icon-btn nav-toggle"
138 title={i18n::tr(i18n::k::MENU)}
139 aria-label={i18n::tr(i18n::k::OPEN_MENU)}
140 on:click=move |_| set_nav_open.update(|v| *v = !*v)
141 >
142 <Icon name=IconName::Menu class="ic-btn".to_string()/>
143 </button>
144 {topbar_left}
145 </div>
146 <div class="topbar-right">
147 {topbar_right}
148 </div>
149 </header>
150 {move || {
151 if nav_open.get() {
152 view! {
153 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
154 }
155 .into_view()
156 .into_any()
157 } else {
158 view! {}.into_any()
159 }
160 }}
161 <div class="body-row">
162 {move || {
163 if file_view.get().is_none() {
164 view! {
165 <aside
166 class="sidebar"
167 class:open=move || nav_open.get()
168 >
169 <nav class="nav">
170 {move || {
171 let Some(u) = me.get().and_then(|m| m.user) else {
172 return {
173 view! {}.into_any()
174 };
175 };
176 view! {
177 <div class="sidebar-user">{u.name.clone()}</div>
178 }
179 .into_view()
180 .into_any()
181 }}
182 <div class="nav-section">
183 <div class="nav-section-head">
184 <div class="nav-section-label">{i18n::tr(i18n::k::FILES)}</div>
185 <button
186 class="icon-btn icon-btn-sm nav-section-refresh"
187 title={i18n::tr(i18n::k::REFRESH_FOLDERS)}
188 on:click=move |_| refresh_me(set_me, toast)
189 >
190 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
191 </button>
192 </div>
193 <button
194 class="nav-tab"
195 class:active=move || section.get() == Section::Search
196 on:click=move |_| open_section(Section::Search)
197 >
198 <Icon name=IconName::Search class="ic-tab".to_string()/>
199 <span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
200 </button>
201 {move || {
202 let Some(m) = me.get() else {
203 return {
204 view! {}.into_any()
205 };
206 };
207 m.roots
208 .iter()
209 .map(|r| {
210 let id = r.id;
211 let name = r.name.clone();
212 // The root's path never changes while
213 // the button exists, so the tooltip is
214 // not a reactive closure.
215 let title = if r.path == "." {
216 "/".to_string()
217 } else {
218 r.path.clone()
219 };
220 let ro = !r.mode.is_writable();
221 view! {
222 <button
223 class="nav-tab"
224 class:active=move || {
225 section.get() == Section::Files
226 && cur_root.get() == Some(id)
227 }
228 title=title
229 on:click=move |_| {
230 set_nav_open.set(false);
231 navigate(&Location {
232 root_id: Some(id),
233 path: vec![],
234 share_token: None,
235 section: Section::Files,
236 });
237 }
238 >
239 <Icon name=IconName::Folder class="ic-tab".to_string()/>
240 <span class="nav-tab-name">{name}</span>
241 {move || {
242 if ro {
243 view! { <span class="nav-tab-note">"ro"</span> }
244 .into_view()
245 .into_any()
246 } else {
247 view! {}.into_any()
248 }
249 }}
250 </button>
251 }
252 })
253 .collect::<Vec<_>>()
254 .into_view()
255 .into_any()
256 }}
257 </div>
258 <div class="nav-section">
259 <div class="nav-section-label">{i18n::tr(i18n::k::MANAGE)}</div>
260 <button
261 class="nav-tab"
262 class:active=move || section.get() == Section::Shares
263 on:click=move |_| open_section(Section::Shares)
264 >
265 <Icon name=IconName::Share class="ic-tab".to_string()/>
266 <span class="nav-tab-name">{i18n::tr(i18n::k::SHARES)}</span>
267 </button>
268 <Show when=move || admin.get()>
269 <button
270 class="nav-tab"
271 class:active=move || section.get() == Section::Users
272 on:click=move |_| open_section(Section::Users)
273 >
274 <Icon name=IconName::User class="ic-tab".to_string()/>
275 <span class="nav-tab-name">{i18n::tr(i18n::k::USERS)}</span>
276 </button>
277 </Show>
278 <button
279 class="nav-tab"
280 class:active=move || section.get() == Section::Settings
281 on:click=move |_| open_section(Section::Settings)
282 >
283 <Icon name=IconName::Settings class="ic-tab".to_string()/>
284 <span class="nav-tab-name">{i18n::tr(i18n::k::SETTINGS)}</span>
285 </button>
286 </div>
287 </nav>
288 <div class="sidebar-footer">
289 <button
290 class="nav-tab sidebar-action"
291 title=move || i18n::t_fmt(i18n::k::THEME_TITLE, i18n::t(theme.get().name_key()))
292 on:click=move |_| {
293 let next = theme.get().next();
294 set_theme.set(next);
295 next.store();
296 next.apply();
297 }
298 >
299 {move || {
300 view! {
301 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
302 }
303 }}
304 <span class="nav-tab-name">{move || i18n::t(theme.get().name_key()).to_string()}</span>
305 </button>
306 <button
307 class="nav-tab sidebar-action logout"
308 title={i18n::tr(i18n::k::LOG_OUT)}
309 on:click=move |_| on_logout(())
310 >
311 <Icon name=IconName::Logout class="ic-tab".to_string()/>
312 <span class="nav-tab-name">{i18n::tr(i18n::k::LOG_OUT)}</span>
313 </button>
314 <div class="sidebar-version">
315 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
316 </div>
317 </div>
318 </aside>
319 }
320 .into_view()
321 .into_any()
322 } else {
323 view! {}.into_any()
324 }
325 }}
326 <main class="content" class:file-open=move || file_view.get().is_some()>
327 {move || {
328 match section.get() {
329 Section::Files => view! {
330 <Browser
331 me=me
332 set_me=set_me
333 loc=loc
334 open_file=open_file
335 set_browser_refresh=set_browser_refresh
336 selected=selected
337 set_selected=set_selected
338 set_dialog=set_dialog
339 dialog=dialog
340 file_view=file_view
341 />
342 }
343 .into_view()
344 .into_any(),
345 Section::Search => view! {
346 <SearchView
347 me=me
348 open_file=open_file
349 file_view=file_view
350 set_dialog=set_dialog
351 />
352 }
353 .into_view()
354 .into_any(),
355 Section::Shares => view! { <SharesView me=me/> }
356 .into_view()
357 .into_any(),
358 Section::Users if admin.get() => {
359 view! { <UsersView me=me set_me=set_me set_dialog=set_dialog/> }.into_view().into_any()
360 }
361 Section::Settings => view! {
362 <SettingsView me=me set_me=set_me set_dialog=set_dialog/>
363 }
364 .into_view()
365 .into_any(),
366 // Not an admin anymore (e.g. permissions changed):
367 // fall back to the files view.
368 _ => view! {
369 <Browser
370 me=me
371 set_me=set_me
372 loc=loc
373 open_file=open_file
374 set_browser_refresh=set_browser_refresh
375 selected=selected
376 set_selected=set_selected
377 set_dialog=set_dialog
378 dialog=dialog
379 file_view=file_view
380 />
381 }
382 .into_view()
383 .into_any(),
384 }
385 }}
386 {file_content}
387 </main>
388 </div>
389 {overlays}
390 </div>
391 }
392}
393