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