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