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::shares::SharesView;
15
16/// Whether the signed-in user has admin rights.
17fn is_admin_of(me: &Option<Me>) -> bool {
18 me.as_ref()
19 .and_then(|m| m.user.as_ref())
20 .is_some_and(|u| u.is_admin)
21}
22
23/// Re-fetch this session's `/me`. For things that can change the session
24/// outside of the current view: the sidebar's refresh button, or saving a
25/// user that is the current user.
26pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
27 spawn_local(async move {
28 match api::me().await {
29 Ok(m) => set_me.set(Some(m)),
30 Err(e) => show(toast, e.to_string()),
31 }
32 });
33}
34
35#[component]
36pub fn ShellView(
37 me: ReadSignal<Option<Me>>,
38 set_me: WriteSignal<Option<Me>>,
39 set_phase: WriteSignal<crate::app::AuthPhase>,
40 loc: ReadSignal<Location>,
41 theme: ReadSignal<crate::theme::Theme>,
42 set_theme: WriteSignal<crate::theme::Theme>,
43) -> impl IntoView {
44 let toast = provide_toast();
45 // True while the off-canvas sidebar is open (narrow screens only).
46 let (nav_open, set_nav_open) = signal(false);
47
48 // A non-admin cannot open the admin sections. If the URL points at one
49 // (deep link, hand-typed, or left over from a demotion), redirect to the
50 // files view so the URL, the content fallback, and the sidebar highlight
51 // all agree.
52 {
53 let me2 = me;
54 let loc2 = loc;
55 Effect::new(move |_| {
56 let Some(m) = me2.get() else {
57 return;
58 };
59 let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
60 if admin {
61 return;
62 }
63 let section = loc2.get().section;
64 if section == Section::Users || section == Section::Settings {
65 navigate(&Location::root());
66 }
67 });
68 }
69
70 let on_logout = move |_| {
71 spawn_local(async move {
72 let _ = api::logout().await;
73 set_me.set(None);
74 set_phase.set(crate::app::AuthPhase::Login);
75 });
76 };
77
78 // Navigate to a manage section (each has its own URL) and, on narrow
79 // screens, close the off-canvas sidebar.
80 let open_section = move |section: Section| {
81 set_nav_open.set(false);
82 navigate(&Location {
83 root_id: None,
84 path: Vec::new(),
85 share_token: None,
86 section,
87 });
88 };
89
90 view! {
91 <div class="layout">
92 <header class="topbar">
93 <div class="topbar-left">
94 <button
95 class="icon-btn nav-toggle"
96 title="Menu"
97 aria-label="Open menu"
98 on:click=move |_| set_nav_open.update(|v| *v = !*v)
99 >
100 <Icon name=IconName::Menu class="ic-btn".to_string()/>
101 </button>
102 <div class="brand">
103 <Logo/>
104 <span class="brand-name">"filebrowser-ng"</span>
105 </div>
106 </div>
107 </header>
108 {move || {
109 if nav_open.get() {
110 view! {
111 <div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
112 }
113 .into_view()
114 .into_any()
115 } else {
116 view! {}.into_any()
117 }
118 }}
119 <div class="body-row">
120 <aside
121 class=move || {
122 if nav_open.get() {
123 "sidebar open".to_string()
124 } else {
125 "sidebar".to_string()
126 }
127 }
128 >
129 <nav class="nav">
130 {move || {
131 let Some(u) = me.get().and_then(|m| m.user) else {
132 return {
133 view! {}.into_any()
134 };
135 };
136 view! {
137 <div class="sidebar-user">{u.name.clone()}</div>
138 }
139 .into_view()
140 .into_any()
141 }}
142 <div class="nav-section">
143 <div class="nav-section-head">
144 <div class="nav-section-label">"Files"</div>
145 <button
146 class="icon-btn icon-btn-sm nav-section-refresh"
147 title="Refresh your folders"
148 on:click=move |_| refresh_me(set_me, toast)
149 >
150 <Icon name=IconName::Refresh class="ic-btn".to_string()/>
151 </button>
152 </div>
153 {move || {
154 let Some(m) = me.get() else {
155 return {
156 view! {}.into_any()
157 };
158 };
159 let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
160 m.roots
161 .iter()
162 .map(|r| {
163 let id = r.id;
164 let name = r.name.clone();
165 let path = r.path.clone();
166 let ro = !r.mode.is_writable();
167 view! {
168 <button
169 class="nav-tab"
170 class:active=move || {
171 loc.get().section == Section::Files
172 && cur_root == Some(id)
173 }
174 title=move || {
175 if path == "." {
176 "/".to_string()
177 } else {
178 path.clone()
179 }
180 }
181 on:click=move |_| {
182 set_nav_open.set(false);
183 navigate(&Location {
184 root_id: Some(id),
185 path: vec![],
186 share_token: None,
187 section: Section::Files,
188 });
189 }
190 >
191 <Icon name=IconName::Folder class="ic-tab".to_string()/>
192 <span class="nav-tab-name">{name}</span>
193 {move || {
194 if ro {
195 view! { <span class="nav-tab-note">"ro"</span> }
196 .into_view()
197 .into_any()
198 } else {
199 view! {}.into_any()
200 }
201 }}
202 </button>
203 }
204 })
205 .collect::<Vec<_>>()
206 .into_view()
207 .into_any()
208 }}
209 </div>
210 <div class="nav-section">
211 <div class="nav-section-label">"Manage"</div>
212 <button
213 class="nav-tab"
214 class:active=move || loc.get().section == Section::Shares
215 on:click=move |_| open_section(Section::Shares)
216 >
217 <Icon name=IconName::Share class="ic-tab".to_string()/>
218 <span class="nav-tab-name">"Shares"</span>
219 </button>
220 <Show when=move || is_admin_of(&me.get())>
221 <button
222 class="nav-tab"
223 class:active=move || loc.get().section == Section::Users
224 on:click=move |_| open_section(Section::Users)
225 >
226 <Icon name=IconName::User class="ic-tab".to_string()/>
227 <span class="nav-tab-name">"Users"</span>
228 </button>
229 <button
230 class="nav-tab"
231 class:active=move || loc.get().section == Section::Settings
232 on:click=move |_| open_section(Section::Settings)
233 >
234 <Icon name=IconName::Settings class="ic-tab".to_string()/>
235 <span class="nav-tab-name">"Settings"</span>
236 </button>
237 </Show>
238 </div>
239 </nav>
240 <div class="sidebar-footer">
241 <button
242 class="nav-tab sidebar-action"
243 title=move || format!("Theme: {} (click to switch)", theme.get().label())
244 on:click=move |_| {
245 let next = theme.get().next();
246 set_theme.set(next);
247 next.store();
248 next.apply();
249 }
250 >
251 {move || {
252 view! {
253 <Icon name=theme.get().icon() class="ic-tab".to_string()/>
254 }
255 }}
256 <span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
257 </button>
258 <button
259 class="nav-tab sidebar-action logout"
260 title="Log out"
261 on:click=move |_| on_logout(())
262 >
263 <Icon name=IconName::Logout class="ic-tab".to_string()/>
264 <span class="nav-tab-name">"Log out"</span>
265 </button>
266 <div class="sidebar-version">
267 {format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
268 </div>
269 </div>
270 </aside>
271 <main class="content">
272 {move || {
273 let admin = is_admin_of(&me.get());
274 match loc.get().section {
275 Section::Files => view! { <Browser me=me set_me=set_me loc=loc/> }
276 .into_view()
277 .into_any(),
278 Section::Shares => view! { <SharesView/> }
279 .into_view()
280 .into_any(),
281 Section::Users if admin => {
282 view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
283 }
284 Section::Settings if admin => {
285 view! { <SettingsView set_me=set_me/> }.into_view().into_any()
286 }
287 // Not an admin anymore (e.g. permissions changed):
288 // fall back to the files view.
289 _ => view! { <Browser me=me set_me=set_me loc=loc/> }
290 .into_view()
291 .into_any(),
292 }
293 }}
294 </main>
295 </div>
296 <ToastView toast=toast/>
297 </div>
298 }
299}
300