URLs: /files/ and /shares/ prefixes; keep sidebar in sync with root changes

- Rename URL prefixes: #/r/{id} → #/files/{id}, #/s/{token} →
  #/shares/{token}; #/shares without a token is the manage view
- Re-fetch /me after saving a user when that user is the current one,
  so the sidebar's folders update in place
- Refresh button in the FILES sidebar section to re-fetch /me manually
- If a folder listing 403/404s (root removed or changed by an admin),
  re-fetch /me once per (root, path) so the stale tab disappears and
  the view falls back; skipped on public share pages where the session
  is derived from the share itself

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commitf67b1bc2457e532b4743da33ba9d60d914cc0316
Parentfb5d2ac
7 files changed, 114 insertions(+), 27 deletions(-)
▾Mweb/app.css
@@ -273,6 +273,31 @@ button:disabled {
padding: 0 10px 6px;
}
/* Section header with a right-aligned action (the Files refresh). */
.nav-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.nav-section-head .nav-section-label {
flex: 1;
padding-bottom: 2px;
}
.nav-section-refresh {
color: var(--muted);
background: none;
border-color: transparent;
padding: 3px 5px;
}
.nav-section-refresh:hover {
color: var(--text);
border-color: var(--border);
}
.nav-tab {
display: flex;
align-items: center;
▾Mweb/src/app.rs
@@ -50,7 +50,7 @@ pub fn App() -> impl IntoView {
view! {
{move || {
// Public share links (`#/s/{token}/...`) bypass the auth flow.
// Public share links (`#/shares/{token}/...`) bypass the auth flow.
let loc_now = loc.get();
if let Some(token) = loc_now.share_token.clone() {
return view! {
▾Mweb/src/router.rs
@@ -1,10 +1,13 @@
//! Tiny hash-based router.
//!
//! - `#/` — root picker / single-root browser
//! - `#/r/{root_id}/{segment/...}` — a user's folder
//! - `#/s/{token}/{segment/...}` — a public share (no login)
//! - `#/files/{root_id}/{segment/...}` — a user's folder
//! - `#/shares/{token}/{segment/...}` — a public share (no login)
//! - `#/shares`, `#/users`, `#/settings` — the signed-in shell's manage views
//!
//! (`#/shares` with a token is the public share; without one it is the
//! manage view that lists your own shares.)
//!
//! The URL hash is the single source of truth; the `Location` signal in the
//! app mirrors it.
@@ -75,7 +78,7 @@ pub fn parse_location() -> Location {
let h = h.trim_start_matches('/');
let mut parts = h.split('/').filter(|s| !s.is_empty());
match parts.next() {
Some("r") => match parts.next().and_then(|s| s.parse().ok()) {
Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
Some(root_id) => Location {
root_id: Some(root_id),
path: parts.map(decode_segment).collect(),
@@ -84,20 +87,21 @@ pub fn parse_location() -> Location {
},
None => Location::root(),
},
Some("s") => match parts.next() {
Some("shares") => match parts.next() {
// `#/shares/{token}/...` — public share page.
Some(token) => 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,
// `#/shares` — the signed-in shares manage view.
None => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Shares,
},
},
Some("users") => Location {
root_id: None,
@@ -117,7 +121,7 @@ pub fn parse_location() -> Location {
pub fn location_to_hash(loc: &Location) -> String {
if let Some(token) = &loc.share_token {
let mut s = format!("#/s/{token}");
let mut s = format!("#/shares/{token}");
for seg in &loc.path {
s.push('/');
s.push_str(&encode_segment(seg));
@@ -130,7 +134,7 @@ pub fn location_to_hash(loc: &Location) -> String {
match loc.root_id {
None => "#/".to_string(),
Some(id) => {
let mut s = format!("#/r/{id}");
let mut s = format!("#/files/{id}");
for seg in &loc.path {
s.push('/');
s.push_str(&encode_segment(seg));
@@ -154,5 +158,5 @@ pub fn share_url(token: &str) -> String {
.unwrap_or_default();
// Cut off any existing hash, then append the share hash.
let cut = base.find('#').unwrap_or(base.len());
format!("{}#/s/{token}", &base[..cut])
format!("{}#/shares/{token}", &base[..cut])
}
▾Mweb/src/views/admin.rs
@@ -109,7 +109,7 @@ enum Editing {
}
#[component]
pub fn UsersView(me: ReadSignal<Option<Me>>) -> impl IntoView {
pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
let (editing, set_editing) = signal(Option::<Editing>::None);
@@ -128,6 +128,15 @@ pub fn UsersView(me: ReadSignal<Option<Me>>) -> impl IntoView {
};
reload();
// After a save: reload the list, and — if the saved user is the current
// one — refresh /me so the sidebar's folders update in place.
let on_saved = Callback::new(move |saved: Option<i64>| {
reload();
if saved.is_some_and(|id| self_id() == Some(id)) {
crate::views::shell::refresh_me(set_me, toast);
}
});
let delete = move |u: AdminUser| {
let is_self = self_id() == Some(u.id);
if is_self {
@@ -262,7 +271,7 @@ pub fn UsersView(me: ReadSignal<Option<Me>>) -> impl IntoView {
existing=existing
self_id=sid
close=Callback::new(move |_| set.set(None))
on_saved=Callback::new(move |_| reload())
on_saved=on_saved
/>
}
.into_view()
@@ -291,7 +300,7 @@ fn UserForm(
existing: Option<AdminUser>,
self_id: Option<i64>,
close: Callback<()>,
on_saved: Callback<()>,
on_saved: Callback<Option<i64>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_new = existing.is_none();
@@ -416,7 +425,7 @@ fn UserForm(
"User updated".to_string()
},
);
on_saved2.run(());
on_saved2.run(existing_id);
close2.run(());
}
Err(e) => {
▾Mweb/src/views/browser.rs
@@ -41,7 +41,11 @@ struct CtxMenu {
}
#[component]
pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl IntoView {
pub fn Browser(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
loc: ReadSignal<Location>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (view_mode, set_view_mode) = signal(ViewMode::load());
let (list_state, set_list_state) = signal(ListState::Loading);
@@ -53,6 +57,11 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
// (context-menu event handlers); otherwise they get disposed with the
// menu and the open dialog panics on submit.
let me_owner = Owner::current().unwrap();
// A 403/404 on the listing usually means an admin removed or changed
// this root, so re-fetch /me once per (root, path) to update the
// sidebar and this view. The key guards against a refresh loop when
// /me comes back unchanged.
let (me_refresh_key, set_me_refresh_key) = signal(Option::<(i64, String)>::None);
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
@@ -66,10 +75,29 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
set_list_state.set(ListState::Loading);
let root_id = root.id;
let path_str = loc.path.join("/");
// On a public share page the (synthetic) "me" comes from the share
// itself, not from a session — refreshing /me would be wrong there.
let is_share = loc.share_token.is_some();
let set_me2 = set_me;
let refresh_key = me_refresh_key;
let set_refresh_key = set_me_refresh_key;
spawn_local(async move {
match api::list_files(root_id, &path_str).await {
Ok(r) => set_list_state.set(ListState::Entries(r.entries)),
Err(e) => set_list_state.set(ListState::Error(e.to_string())),
Err(e) => {
if !is_share
&& matches!(e.status(), Some(403) | Some(404))
&& refresh_key.get() != Some((root_id, path_str.clone()))
{
set_refresh_key.set(Some((root_id, path_str.clone())));
spawn_local(async move {
if let Ok(m) = api::me().await {
set_me2.set(Some(m));
}
});
}
set_list_state.set(ListState::Error(e.to_string()));
}
}
});
});
▾Mweb/src/views/share_page.rs
@@ -159,7 +159,7 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
.into_any();
}
view! {
<Browser me=me loc=loc/>
<Browser me=me set_me=set_me loc=loc/>
}
.into_view()
.into_any()
▾Mweb/src/views/shell.rs
@@ -6,7 +6,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastView, provide_toast};
use crate::components::toast::{ToastMsg, ToastView, provide_toast, show};
use crate::icons::IconName;
use crate::router::{Location, Section, navigate};
use crate::views::admin::{SettingsView, UsersView};
@@ -20,6 +20,18 @@ fn is_admin_of(me: &Option<Me>) -> bool {
.is_some_and(|u| u.is_admin)
}
/// Re-fetch this session's `/me`. For things that can change the session
/// outside of the current view: the sidebar's refresh button, or saving a
/// user that is the current user.
pub fn refresh_me(set_me: WriteSignal<Option<Me>>, toast: ToastMsg) {
spawn_local(async move {
match api::me().await {
Ok(m) => set_me.set(Some(m)),
Err(e) => show(toast, e.to_string()),
}
});
}
#[component]
pub fn ShellView(
me: ReadSignal<Option<Me>>,
@@ -128,7 +140,16 @@ pub fn ShellView(
.into_any()
}}
<div class="nav-section">
<div class="nav-section-label">"Files"</div>
<div class="nav-section-head">
<div class="nav-section-label">"Files"</div>
<button
class="icon-btn icon-btn-sm nav-section-refresh"
title="Refresh your folders"
on:click=move |_| refresh_me(set_me, toast)
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
</button>
</div>
{move || {
let Some(m) = me.get() else {
return {
@@ -251,21 +272,21 @@ pub fn ShellView(
{move || {
let admin = is_admin_of(&me.get());
match loc.get().section {
Section::Files => view! { <Browser me=me loc=loc/> }
Section::Files => view! { <Browser me=me set_me=set_me loc=loc/> }
.into_view()
.into_any(),
Section::Shares => view! { <SharesView/> }
.into_view()
.into_any(),
Section::Users if admin => {
view! { <UsersView me=me/> }.into_view().into_any()
view! { <UsersView me=me set_me=set_me/> }.into_view().into_any()
}
Section::Settings if admin => {
view! { <SettingsView set_me=set_me/> }.into_view().into_any()
}
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! { <Browser me=me loc=loc/> }
_ => view! { <Browser me=me set_me=set_me loc=loc/> }
.into_view()
.into_any(),
}