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