Shell: URL routes for manage views, reworked sidebar

- Shares/Users/Settings get real hash routes (#/shares, #/users,
  #/settings); the in-memory panel state is gone, the URL is the
  single source of truth
- Sidebar: username above the FILES section; footer below MANAGE with
  the theme toggle (icon + name), log out, and project name + version
- Topbar reduced to brand + mobile menu toggle
- Sign-in no longer resets the hash, so deep links survive login
- A non-admin whose URL points at an admin section is redirected to
  the files view, keeping URL, content, and sidebar highlight in sync

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commitfb5d2ac49b8d46fcb4de835ebbb9e52ab894342c
Parent1815e5d
6 files changed, 192 insertions(+), 80 deletions(-)
▾Mweb/app.css
@@ -206,12 +206,6 @@ button:disabled {
display: none;
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.body-row {
flex: 1;
display: flex;
@@ -261,6 +255,15 @@ button:disabled {
gap: 20px;
}
.sidebar-user {
padding: 0 10px 2px;
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-section-label {
font-size: 11px;
font-weight: 700;
@@ -334,8 +337,34 @@ button:disabled {
display: none;
}
.user-badge {
font-size: 14px;
/* Sidebar footer: theme toggle, log out, and the project version.
Sits at the bottom of the sidebar, below the manage section. */
.sidebar-footer {
flex: none;
border-top: 1px solid var(--border);
padding: 8px;
}
.sidebar-footer .nav-tab {
margin-bottom: 0;
}
.sidebar-action.logout {
color: var(--danger);
}
.sidebar-action.logout:hover {
color: var(--danger);
}
.sidebar-version {
margin-top: 8px;
padding: 0 10px;
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
▾Mweb/src/router.rs
@@ -3,10 +3,34 @@
//! - `#/` — root picker / single-root browser
//! - `#/r/{root_id}/{segment/...}` — a user's folder
//! - `#/s/{token}/{segment/...}` — a public share (no login)
//! - `#/shares`, `#/users`, `#/settings` — the signed-in shell's manage views
//!
//! The URL hash is the single source of truth; the `Location` signal in the
//! app mirrors it.
/// Which section of the signed-in shell a location points at. `Files` is the
/// browser; the rest are the manage views, each with its own URL.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Section {
Files,
Shares,
Users,
Settings,
}
impl Section {
/// The URL segment for this section, or `None` for `Files` (which has no
/// segment of its own — the browser is the default view).
const fn segment(self) -> Option<&'static str> {
match self {
Section::Files => None,
Section::Shares => Some("shares"),
Section::Users => Some("users"),
Section::Settings => Some("settings"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
/// None = the top-level "folders" view (root picker).
@@ -15,6 +39,8 @@ pub struct Location {
pub path: Vec<String>,
/// Present when the location is a public share (`#/s/{token}/...`).
pub share_token: Option<String>,
/// Which shell section the location points at.
pub section: Section,
}
impl Location {
@@ -23,6 +49,7 @@ impl Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Files,
}
}
}
@@ -53,6 +80,7 @@ pub fn parse_location() -> Location {
root_id: Some(root_id),
path: parts.map(decode_segment).collect(),
share_token: None,
section: Section::Files,
},
None => Location::root(),
},
@@ -61,9 +89,28 @@ pub fn parse_location() -> Location {
root_id: None,
path: parts.map(decode_segment).collect(),
share_token: Some(token.to_string()),
section: Section::Files,
},
None => Location::root(),
},
Some("shares") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Shares,
},
Some("users") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Users,
},
Some("settings") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Settings,
},
_ => Location::root(),
}
}
@@ -77,6 +124,9 @@ pub fn location_to_hash(loc: &Location) -> String {
}
return s;
}
if let Some(segment) = loc.section.segment() {
return format!("#/{segment}");
}
match loc.root_id {
None => "#/".to_string(),
Some(id) => {
▾Mweb/src/theme.rs
@@ -38,6 +38,15 @@ impl Theme {
}
}
/// Display name for the UI ("Auto", "Light", "Dark").
pub const fn name(self) -> &'static str {
match self {
Theme::Auto => "Auto",
Theme::Light => "Light",
Theme::Dark => "Dark",
}
}
/// The glyph the toggle button shows for this state.
pub const fn icon(self) -> IconName {
match self {
▾Mweb/src/views/browser.rs
@@ -11,7 +11,7 @@ use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, EditorModal};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind};
use crate::router::{Location, navigate};
use crate::router::{Location, Section, navigate};
use crate::util::{ViewMode, format_date, format_size};
use crate::views::dialogs::{Dialog, DialogView, Op};
@@ -216,6 +216,7 @@ fn file_browser(
root_id: Some(root_id),
path: vec![],
share_token: loc.get().share_token.clone(),
section: Section::Files,
});
}
>
@@ -240,6 +241,7 @@ fn file_browser(
root_id: Some(root_id),
path: target.clone(),
share_token: loc.get().share_token.clone(),
section: Section::Files,
});
}
>
@@ -389,6 +391,7 @@ fn open_entry(
root_id: Some(root_id),
path,
share_token: cur.share_token.clone(),
section: Section::Files,
});
return;
}
▾Mweb/src/views/login.rs
@@ -4,7 +4,6 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, input_value};
use crate::app::AuthPhase;
use crate::components::logo::Logo;
use crate::router::{Location, navigate};
#[component]
pub fn LoginView(
@@ -29,7 +28,9 @@ pub fn LoginView(
match api::login(name, pass).await {
Ok(_) => match api::me().await {
Ok(m) => {
navigate(&Location::root());
// Keep the current hash: deep links (e.g. #/users) survive
// the sign-in. A non-admin landing on an admin section is
// redirected to the files view by the shell instead.
set_me.set(Some(m));
set_phase.set(AuthPhase::Authed);
}
▾Mweb/src/views/shell.rs
@@ -8,7 +8,7 @@ use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastView, provide_toast};
use crate::icons::IconName;
use crate::router::{Location, navigate};
use crate::router::{Location, Section, navigate};
use crate::views::admin::{SettingsView, UsersView};
use crate::views::browser::{Browser, effective_root};
use crate::views::shares::SharesView;
@@ -20,17 +20,6 @@ fn is_admin_of(me: &Option<Me>) -> bool {
.is_some_and(|u| u.is_admin)
}
/// Which section of the shell is showing. `Files` is the browser; the rest are
/// the "manage" sections. The file location lives in the URL hash, the manage
/// panel is in-memory state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Panel {
Files,
Shares,
Users,
Settings,
}
#[component]
pub fn ShellView(
me: ReadSignal<Option<Me>>,
@@ -41,10 +30,31 @@ pub fn ShellView(
set_theme: WriteSignal<crate::theme::Theme>,
) -> impl IntoView {
let toast = provide_toast();
let (panel, set_panel) = signal(Panel::Files);
// True while the off-canvas sidebar is open (narrow screens only).
let (nav_open, set_nav_open) = signal(false);
// A non-admin cannot open the admin sections. If the URL points at one
// (deep link, hand-typed, or left over from a demotion), redirect to the
// files view so the URL, the content fallback, and the sidebar highlight
// all agree.
{
let me2 = me;
let loc2 = loc;
Effect::new(move |_| {
let Some(m) = me2.get() else {
return;
};
let admin = m.user.as_ref().is_some_and(|u| u.is_admin);
if admin {
return;
}
let section = loc2.get().section;
if section == Section::Users || section == Section::Settings {
navigate(&Location::root());
}
});
}
let on_logout = move |_| {
spawn_local(async move {
let _ = api::logout().await;
@@ -53,11 +63,17 @@ pub fn ShellView(
});
};
// Select a manage section; on narrow screens the sidebar closes.
let open_panel = Callback::new(move |p: Panel| {
set_panel.set(p);
// Navigate to a manage section (each has its own URL) and, on narrow
// screens, close the off-canvas sidebar.
let open_section = move |section: Section| {
set_nav_open.set(false);
});
navigate(&Location {
root_id: None,
path: Vec::new(),
share_token: None,
section,
});
};
view! {
<div class="layout">
@@ -76,45 +92,6 @@ pub fn ShellView(
<span class="brand-name">"filebrowser-ng"</span>
</div>
</div>
<div class="topbar-right">
{move || {
let Some(u) = me.get().and_then(|m| m.user) else {
return {
view! {}.into_any()
};
};
view! {
<span class="user-badge">{u.name.clone()}</span>
}
.into_view()
.into_any()
}}
<button
class="icon-btn"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
aria-label="Toggle theme"
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
next.store();
next.apply();
}
>
{move || {
view! {
<Icon name=theme.get().icon() class="ic-btn".to_string()/>
}
}}
</button>
<button
class="icon-btn"
title="Log out"
aria-label="Log out"
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-btn".to_string()/>
</button>
</div>
</header>
{move || {
if nav_open.get() {
@@ -138,6 +115,18 @@ pub fn ShellView(
}
>
<nav class="nav">
{move || {
let Some(u) = me.get().and_then(|m| m.user) else {
return {
view! {}.into_any()
};
};
view! {
<div class="sidebar-user">{u.name.clone()}</div>
}
.into_view()
.into_any()
}}
<div class="nav-section">
<div class="nav-section-label">"Files"</div>
{move || {
@@ -158,7 +147,8 @@ pub fn ShellView(
<button
class="nav-tab"
class:active=move || {
panel.get() == Panel::Files && cur_root == Some(id)
loc.get().section == Section::Files
&& cur_root == Some(id)
}
title=move || {
if path == "." {
@@ -168,12 +158,12 @@ pub fn ShellView(
}
}
on:click=move |_| {
set_panel.set(Panel::Files);
set_nav_open.set(false);
navigate(&Location {
root_id: Some(id),
path: vec![],
share_token: None,
section: Section::Files,
});
}
>
@@ -200,8 +190,8 @@ pub fn ShellView(
<div class="nav-section-label">"Manage"</div>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Shares
on:click=move |_| open_panel.run(Panel::Shares)
class:active=move || loc.get().section == Section::Shares
on:click=move |_| open_section(Section::Shares)
>
<Icon name=IconName::Share class="ic-tab".to_string()/>
<span class="nav-tab-name">"Shares"</span>
@@ -209,16 +199,16 @@ pub fn ShellView(
<Show when=move || is_admin_of(&me.get())>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Users
on:click=move |_| open_panel.run(Panel::Users)
class:active=move || loc.get().section == Section::Users
on:click=move |_| open_section(Section::Users)
>
<Icon name=IconName::User class="ic-tab".to_string()/>
<span class="nav-tab-name">"Users"</span>
</button>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Settings
on:click=move |_| open_panel.run(Panel::Settings)
class:active=move || loc.get().section == Section::Settings
on:click=move |_| open_section(Section::Settings)
>
<Icon name=IconName::Settings class="ic-tab".to_string()/>
<span class="nav-tab-name">"Settings"</span>
@@ -226,21 +216,51 @@ pub fn ShellView(
</Show>
</div>
</nav>
<div class="sidebar-footer">
<button
class="nav-tab sidebar-action"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
next.store();
next.apply();
}
>
{move || {
view! {
<Icon name=theme.get().icon() class="ic-tab".to_string()/>
}
}}
<span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
</button>
<button
class="nav-tab sidebar-action logout"
title="Log out"
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-tab".to_string()/>
<span class="nav-tab-name">"Log out"</span>
</button>
<div class="sidebar-version">
{format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
</div>
</div>
</aside>
<main class="content">
{move || {
let admin = is_admin_of(&me.get());
match panel.get() {
Panel::Files => view! { <Browser me=me loc=loc/> }
match loc.get().section {
Section::Files => view! { <Browser me=me loc=loc/> }
.into_view()
.into_any(),
Panel::Shares => view! { <SharesView/> }
Section::Shares => view! { <SharesView/> }
.into_view()
.into_any(),
Panel::Users if admin => {
Section::Users if admin => {
view! { <UsersView me=me/> }.into_view().into_any()
}
Panel::Settings if admin => {
Section::Settings if admin => {
view! { <SettingsView set_me=set_me/> }.into_view().into_any()
}
// Not an admin anymore (e.g. permissions changed):