UI: per-user settings, entry selection with topbar actions
Every user gets a Settings view now (Profile tab for everyone, Server tab for admins). Profile has a "Use single click to open" setting (stored per user, default off): off means a single click selects an entry and double-click opens it; on means a single click opens and Ctrl/Cmd+click toggles the selection. The selected entry's actions moved to the top bar: the same operations as the right-click menu, icon-only, overflowing into a three-dot menu when they stop fitting (button count measured from the rendered width, reserving the overflow button's slot so the group never eats into the brand). With nothing selected the top bar offers New folder / Upload files / Upload folder instead. - api-types: UserInfo gains single_click_open - server: schema v3 (users.single_click, default 0), set_user_single_click, PUT /api/auth/me (ProfilePatch) returning the fresh /me; session-cookie extraction factored into require_user - web: SettingsView takes the current user; the Profile tab saves via update_profile; the active tab survives the /me-triggered remount via localStorage; both tab bodies share one SettingToggle component - web: single selection (click / dblclick / context menu / Escape / empty-area click), with selection + dialog state lifted into the shell and share page so the top bar can render the actions - web: Escape with a preview/editor open closes the view but keeps the selection (the browser's handler runs in the capture phase, before the shell's close handler); Escape-deselect blurs the row so the :focus-visible outline does not linger Co-Authored-By: Qwen3.8 27b
Mapi-types/src/lib.rs
@@ -217,6 +217,9 @@ pub struct UserInfo {
pub id: i64,
pub name: String,
pub is_admin: bool,
/// Profile setting: single click opens entries (off = click selects,
/// double click opens).
pub single_click_open: bool,
}
#[derive(Serialize, Deserialize, Clone)]
Mserver/src/api/auth.rs
@@ -5,9 +5,11 @@ use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::api::common::{display_name, hash_password, validate_account_name, validate_password};
use crate::auth::{self, clear_session_cookie, parse_session_cookie, session_cookie};
use crate::db::User;
use crate::error::{ApiError, AppState};
/// GET /api/auth/me
@@ -28,16 +30,25 @@ pub async fn me(
}));
}
let Some(token) = parse_session_cookie(&headers) else {
let user = require_user(&state, &headers).await?;
Ok(Json(me_for(&state, &user).await?))
}
/// The signed-in user behind the session cookie, or 401.
async fn require_user(state: &AppState, headers: &HeaderMap) -> Result<User, ApiError> {
let Some(token) = parse_session_cookie(headers) else {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"));
};
let Some(user) = state.db.session_user(&token).await? else {
return Err(ApiError::new(
state.db.session_user(&token).await?.ok_or_else(|| {
ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
));
};
)
})
}
/// Build the `/api/auth/me` payload for an authenticated user.
async fn me_for(state: &AppState, user: &User) -> Result<Me, ApiError> {
let roots: Vec<RootInfo> = state
.db
.user_roots(user.id)
@@ -51,16 +62,40 @@ pub async fn me(
})
.collect();
Ok(Json(Me {
Ok(Me {
first_boot: false,
user: Some(UserInfo {
id: user.id,
name: user.name,
name: user.name.clone(),
is_admin: user.is_admin,
single_click_open: user.single_click,
}),
roots,
allow_writable_shares: state.db.allow_writable_shares().await?,
}))
})
}
/// PUT /api/auth/me
///
/// Update the signed-in user's profile settings. Returns the fresh `/me`
/// payload so clients can apply the change immediately.
#[derive(Deserialize)]
pub(crate) struct ProfilePatch {
pub single_click_open: bool,
}
pub async fn update_profile(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<ProfilePatch>,
) -> Result<Json<Me>, ApiError> {
let mut user = require_user(&state, &headers).await?;
state
.db
.set_user_single_click(user.id, body.single_click_open)
.await?;
user.single_click = body.single_click_open;
Ok(Json(me_for(&state, &user).await?))
}
/// POST /api/auth/setup — create the first admin account.
Mserver/src/api/mod.rs
@@ -82,7 +82,7 @@ pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route(AUTH_LOGIN, post(auth::login))
.route(AUTH_LOGOUT, post(auth::logout))
.route(AUTH_ME, get(auth::me))
.route(AUTH_ME, get(auth::me).put(auth::update_profile))
.route(AUTH_SETUP, post(auth::setup))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
Mserver/src/db.rs
@@ -5,7 +5,7 @@ pub use api_types::Mode;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, OptionalExtension, params};
const SCHEMA_VERSION: i64 = 2;
const SCHEMA_VERSION: i64 = 3;
/// SQL adapter for [`Mode`]. A newtype is needed because both the rusqlite
/// traits and `Mode` are foreign to this crate.
@@ -35,6 +35,8 @@ pub struct User {
pub is_admin: bool,
/// Disabled users cannot sign in and their sessions are rejected.
pub active: bool,
/// Profile setting: single click opens entries (off = click selects).
pub single_click: bool,
}
#[derive(Debug, Clone)]
@@ -132,6 +134,14 @@ impl Db {
// accounts without deleting them.
conn.execute_batch("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")?;
}
if version < 3 {
// Per-user profile settings: click-to-open mode. No migration
// from the old behaviour — everyone starts on the new default
// (off: single click selects, double click opens).
conn.execute_batch(
"ALTER TABLE users ADD COLUMN single_click INTEGER NOT NULL DEFAULT 0",
)?;
}
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
[SCHEMA_VERSION.to_string()],
@@ -167,6 +177,7 @@ impl Db {
name: name.to_string(),
is_admin: true,
active: true,
single_click: false,
})
}
@@ -174,16 +185,26 @@ impl Db {
// The guard is scoped to the query alone. Argon2 below is slow by
// design; holding the single connection lock across it would make one
// login serialize every other database access.
let row: Option<(i64, String, bool, String, bool)> = {
let row: Option<(i64, String, bool, String, bool, bool)> = {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, pass_hash, active != 0 FROM users WHERE name = ?1",
"SELECT id, name, is_admin != 0, pass_hash, active != 0, single_click != 0
FROM users WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
},
)
.optional()?
};
let Some((id, name, is_admin, hash, active)) = row else {
let Some((id, name, is_admin, hash, active, single_click)) = row else {
return Ok(None);
};
if !active {
@@ -200,6 +221,7 @@ impl Db {
name,
is_admin,
active,
single_click,
}))
}
@@ -224,7 +246,7 @@ impl Db {
pub async fn session_user(&self, token: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0
"SELECT u.id, u.name, u.is_admin != 0, u.active != 0, u.single_click != 0
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?1 AND u.active = 1",
[token],
@@ -253,8 +275,9 @@ impl Db {
pub async fn all_users(&self) -> DbResult<Vec<User>> {
let c = self.0.lock().await;
let mut stmt =
c.prepare("SELECT id, name, is_admin != 0, active != 0 FROM users ORDER BY id")?;
let mut stmt = c.prepare(
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users ORDER BY id",
)?;
let rows = stmt.query_map([], map_user)?;
rows.collect()
}
@@ -262,7 +285,7 @@ impl Db {
pub async fn find_user_by_id(&self, id: i64) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE id = ?1",
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE id = ?1",
[id],
map_user,
)
@@ -272,7 +295,7 @@ impl Db {
pub async fn find_user_by_name(&self, name: &str) -> DbResult<Option<User>> {
let c = self.0.lock().await;
c.query_row(
"SELECT id, name, is_admin != 0, active != 0 FROM users WHERE name = ?1",
"SELECT id, name, is_admin != 0, active != 0, single_click != 0 FROM users WHERE name = ?1",
[name],
map_user,
)
@@ -316,6 +339,7 @@ impl Db {
name: name.to_string(),
is_admin,
active: true,
single_click: false,
})
}
@@ -346,6 +370,15 @@ impl Db {
Ok(())
}
pub async fn set_user_single_click(&self, id: i64, single_click: bool) -> DbResult<()> {
let c = self.0.lock().await;
c.execute(
"UPDATE users SET single_click = ?1 WHERE id = ?2",
params![single_click as i64, id],
)?;
Ok(())
}
/// Delete a user. `false` means no row matched.
pub async fn delete_user(&self, id: i64) -> DbResult<bool> {
let c = self.0.lock().await;
@@ -470,6 +503,7 @@ fn map_user(r: &rusqlite::Row) -> DbResult<User> {
name: r.get(1)?,
is_admin: r.get(2)?,
active: r.get(3)?,
single_click: r.get(4)?,
})
}
Mweb/Cargo.toml
@@ -15,16 +15,21 @@ thiserror = "2"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"AddEventListenerOptions",
"Blob",
"Clipboard",
"Document",
"DomRect",
"Element",
"Event",
"EventTarget",
"EventListenerOptions",
"File",
"FileList",
"HashChangeEvent",
"Headers",
"HtmlAnchorElement",
"HtmlCollection",
"HtmlDivElement",
"HtmlElement",
"HtmlInputElement",
Mweb/app.css
@@ -705,6 +705,94 @@ button:disabled {
background: none;
}
/* toolbar selection actions (top right) */
.sel-actions {
display: flex;
align-items: center;
gap: 6px;
position: relative;
}
.sel-actions .icon-btn:disabled {
opacity: 0.45;
cursor: default;
}
.sel-actions .icon-btn:disabled:hover {
border-color: var(--border);
color: var(--muted);
}
.sel-more-wrap {
position: relative;
}
.sel-more-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 100;
min-width: 190px;
background: var(--panel);
border: 1px solid var(--border);
padding: 5px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.sel-more-menu.hidden {
display: none;
}
/* selection actions in the top bar */
.topbar .sel-actions {
min-height: 0;
}
.topbar .sel-actions .icon-btn {
padding: 5px 7px;
}
/* selection highlight in the grid and list */
.tile.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.row.selected {
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
box-shadow: inset 2px 0 0 var(--accent);
}
/* settings tabs (profile / server) */
.settings-tabs {
display: flex;
gap: 8px;
}
.settings-tab-btn {
padding: 7px 14px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.settings-tab-btn:hover {
border-color: var(--muted);
color: var(--text);
}
.settings-tab-btn.active {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
/* toast */
.toast {
Mweb/src/api.rs
@@ -73,6 +73,22 @@ pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
request("GET", AUTH_ME.to_string(), None::<()>)
}
/// PUT /api/auth/me — update the signed-in user's profile settings.
#[derive(Serialize)]
struct ProfilePatch {
single_click_open: bool,
}
pub fn update_profile(
single_click_open: bool,
) -> impl std::future::Future<Output = Result<Me, ApiError>> {
request(
"PUT",
AUTH_ME.to_string(),
Some(ProfilePatch { single_click_open }),
)
}
pub fn login(
name: String,
password: String,
Mweb/src/icons.rs
@@ -83,6 +83,8 @@ pub enum IconName {
ThemeLight,
/// material-symbols:dark-mode
ThemeDark,
/// material-symbols:more-vert
MoreVert,
}
impl IconName {
@@ -196,6 +198,9 @@ impl IconName {
Self::ThemeDark => {
r#"<path fill="currentColor" d="M12 21q-3.75 0-6.375-2.625T3 12t2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.638 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.613T20.9 10.65q.05.325.075.662T21 12q0 3.75-2.625 6.375T12 21"/>"#
}
Self::MoreVert => {
r#"<path fill="currentColor" d="M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8"/>"#
}
}
}
}
Mweb/src/util.rs
@@ -1,7 +1,9 @@
//! Formatting + small browser helpers.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// A window event listener that is removed when the current owner is disposed.
///
@@ -19,6 +21,44 @@ pub fn owned_window_listener<E: leptos::ev::EventDescriptor + 'static>(
on_cleanup(move || handle.remove());
}
/// Same as [`owned_window_listener`], but registered in the capture phase,
/// so it runs before the bubble-phase window listeners (registration order
/// otherwise decides, and that depends on component creation order).
pub fn owned_window_listener_capture<E: leptos::ev::EventDescriptor + 'static>(
event: E,
cb: impl Fn(E::EventType) + 'static,
) where
E::EventType: wasm_bindgen::JsCast,
{
let closure = Closure::<dyn FnMut(web_sys::Event)>::new({
move |e: web_sys::Event| cb(e.unchecked_into::<E::EventType>())
});
let add_options = web_sys::AddEventListenerOptions::new();
add_options.set_capture(true);
let remove_options = web_sys::EventListenerOptions::new();
remove_options.set_capture(true);
let name = event.name();
web_sys::window()
.expect("window")
.add_event_listener_with_callback_and_add_event_listener_options(
&name,
closure.as_ref().unchecked_ref(),
&add_options,
)
.expect("failed to add window event listener");
let closure_js = closure.into_js_value();
on_cleanup(move || {
let closure: js_sys::Function = closure_js.unchecked_into();
let _ = web_sys::window()
.expect("window")
.remove_event_listener_with_callback_and_event_listener_options(
&name,
&closure,
&remove_options,
);
});
}
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = bytes as f64;
Mweb/src/views/admin.rs
@@ -14,9 +14,137 @@ use crate::icons::IconName;
// Settings view
// ---------------------------------------------------------------------------
/// The two settings tabs. Every user sees Profile; Server is admin-only.
#[derive(Clone, Copy, PartialEq)]
enum SettingsTab {
Profile,
Server,
}
impl SettingsTab {
const KEY: &'static str = "fbng.settings-tab";
fn load(is_admin: bool) -> Self {
match crate::util::storage_get(Self::KEY).as_deref() {
Some("server") if is_admin => SettingsTab::Server,
Some("profile") => SettingsTab::Profile,
// First visit: admins land on the server tab (the pre-existing
// default), everyone else on their profile.
_ if is_admin => SettingsTab::Server,
_ => SettingsTab::Profile,
}
}
fn save(self) {
crate::util::storage_set(
Self::KEY,
match self {
SettingsTab::Server => "server",
SettingsTab::Profile => "profile",
},
);
}
}
/// One "setting" tab body: a checkbox with description and a busy save
/// button. Both tabs (profile, server) share exactly this shape, so the
/// markup lives here once.
#[component]
fn SettingToggle(
label: &'static str,
desc: &'static str,
value: ReadSignal<Option<bool>>,
set_value: WriteSignal<Option<bool>>,
busy: ReadSignal<bool>,
save: Callback<()>,
save_label: &'static str,
) -> impl IntoView {
view! {
{move || match value.get() {
None => view! {
<p class="muted">"Loading…"</p>
}
.into_view()
.into_any(),
Some(v) => view! {
<label class="setting-row">
<input
type="checkbox"
checked=v
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_value.set(Some(t.checked()));
}
}
/>
<span>
<span class="setting-label">{label}</span>
<span class="setting-desc">{desc}</span>
</span>
</label>
<div class="modal-actions">
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=move |_| save.run(())
>
{move || if busy.get() { "Saving…" } else { save_label }}
</button>
</div>
}
.into_view()
.into_any(),
}}
}
}
#[component]
pub fn SettingsView(set_me: WriteSignal<Option<Me>>) -> impl IntoView {
pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
// The shell re-creates this view when /me changes (saving resets it), so
// the active tab is remembered in localStorage like the view mode.
let (tab, set_tab) = signal(SettingsTab::load(is_admin()));
let pick_tab = move |t: SettingsTab| {
t.save();
set_tab.set(t);
};
// --- profile: single-click-to-open ------------------------------------
let (single_click, set_single_click) = signal(Option::<bool>::None);
let (profile_busy, set_profile_busy) = signal(false);
Effect::new(move |_| {
if let Some(v) = me.get().and_then(|m| m.user).map(|u| u.single_click_open) {
set_single_click.set(Some(v));
}
});
let save_profile = move |_| {
if profile_busy.get() {
return;
}
let Some(v) = single_click.get() else { return };
set_profile_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
let set_val = set_single_click;
spawn_local(async move {
match api::update_profile(v).await {
Ok(m) => {
show(toast2, "Profile saved".to_string());
// The response already carries the fresh /me.
set_me2.set(Some(m));
set_val.set(Some(v));
}
Err(e) => show(toast2, format!("Could not save profile: {e}")),
}
set_profile_busy.set(false);
});
};
// --- server: allow writable shares (admin only) ------------------------
let (value, set_value) = signal(Option::<bool>::None);
let (busy, set_busy) = signal(false);
{
@@ -52,47 +180,50 @@ pub fn SettingsView(set_me: WriteSignal<Option<Me>>) -> impl IntoView {
set_busy.set(false);
});
};
view! {
<div class="admin-view">
<div class="settings-tab">
<h3 class="admin-section-title">"Server settings"</h3>
{move || match value.get() {
None => view! {
<p class="muted">"Loading…"</p>
}
.into_view()
.into_any(),
Some(v) => view! {
<label class="setting-row">
<input
type="checkbox"
checked=v
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_value.set(Some(t.checked()));
}
}
/>
<span>
<span class="setting-label">"Allow writable shares"</span>
<span class="setting-desc">
"Let users create read-write share links. Off by default."
</span>
</span>
</label>
<div class="modal-actions">
<button class="btn btn-primary" disabled=move || busy.get() on:click=save>
{move || if busy.get() { "Saving…" } else { "Save settings" }}
</button>
</div>
}
.into_view()
.into_any(),
}}
<div class="settings-tabs">
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Profile
on:click=move |_| pick_tab(SettingsTab::Profile)
>
"Profile"
</button>
<Show when=move || is_admin()>
<button
class="settings-tab-btn"
class:active=move || tab.get() == SettingsTab::Server
on:click=move |_| pick_tab(SettingsTab::Server)
>
"Server"
</button>
</Show>
</div>
<Show when=move || tab.get() == SettingsTab::Profile>
<SettingToggle
label="Use single click to open"
desc="When on, a single click opens an entry and Ctrl+click selects it. \
When off, a single click selects and a double click opens."
value=single_click
set_value=set_single_click
busy=profile_busy
save=Callback::new(save_profile)
save_label="Save profile"
/>
</Show>
<Show when=move || tab.get() == SettingsTab::Server && is_admin()>
<SettingToggle
label="Allow writable shares"
desc="Let users create read-write share links. Off by default."
value=value
set_value=set_value
busy=busy
save=Callback::new(save)
save_label="Save settings"
/>
</Show>
</div>
}
}
Mweb/src/views/browser.rs
@@ -2,6 +2,7 @@
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
@@ -13,7 +14,7 @@ use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::{ViewMode, format_date, format_size};
use crate::views::dialogs::{Dialog, DialogView, Op};
use crate::views::dialogs::{Dialog, Op};
use crate::views::file_view::{FileView, UnsupportedTarget};
#[derive(Clone, Debug, PartialEq)]
@@ -51,22 +52,64 @@ pub fn Browser(
/// The browser exposes its fetch there so the full-page editor can
/// re-list the folder after a save.
set_browser_refresh: WriteSignal<Option<Callback<()>>>,
/// The single selected entry, owned by the shell so its action buttons
/// can live in the top bar. `None` shows the empty-area actions there.
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
/// Dialogs are rendered at the shell level.
set_dialog: WriteSignal<Option<Dialog>>,
/// A file opened in the full-page view. Escape must not clear the
/// selection while this is set — that keypress closes the view (handled
/// by the shell), and the selection should survive.
file_view: ReadSignal<Option<FileView>>,
) -> 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);
let (ctx, set_ctx) = signal(Option::<CtxMenu>::None);
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
// Long-lived owner for callbacks created inside short-lived scopes
// (context-menu event handlers); otherwise they get disposed with the
// menu and the open dialog panics on submit.
let me_owner = Owner::current().unwrap();
let ctx_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);
// Drop the selection when the entry disappears from the listing
// (deleted, renamed, …).
Effect::new(move |_| {
if let ListState::Entries(entries) = list_state.get()
&& let Some(s) = selected.get()
&& !entries.iter().any(|e| e.name == s.name)
{
set_selected.set(None);
}
});
// Escape clears the selection — but only when neither the context menu
// (it has its own Escape handler) nor a full-page file view (the shell
// closes it on the same keypress) is open. Registered in the capture
// phase so it sees the file view *before* the shell's bubble listener
// closes it; and it blurs the focused row, because the keypress would
// otherwise leave the row's :focus-visible outline behind.
crate::util::owned_window_listener_capture(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" || ctx.get().is_some() || file_view.get().is_some() {
return;
}
if let Some(active) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.active_element())
.and_then(|a| a.dyn_into::<web_sys::HtmlElement>().ok())
{
let _ = active.blur();
}
set_selected.set(None);
},
);
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
@@ -117,25 +160,28 @@ pub fn Browser(
view! {
<div class="browser">
{move || {
let Some(me) = me.get() else {
let Some(me_now) = me.get() else {
return {
view! {}.into_any()
};
};
let loc_now = loc.get();
match effective_root(&me.roots, &loc_now) {
match effective_root(&me_now.roots, &loc_now) {
Some(root) => file_browser(
me,
root,
loc,
list_state,
view_mode,
set_view_mode,
set_ctx,
selected,
set_selected,
fetch,
open_file,
)
.into_any(),
None => no_folder_view(&me.roots, &loc_now).into_any(),
None => no_folder_view(&me_now.roots, &loc_now).into_any(),
}
}}
<CtxMenuView
@@ -147,9 +193,8 @@ pub fn Browser(
toast=toast
set_dialog=set_dialog
open_file=open_file
owner=me_owner
owner=ctx_owner
/>
<DialogView dialog=dialog set_dialog=set_dialog/>
</div>
}
}
@@ -193,12 +238,15 @@ fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
#[allow(clippy::too_many_arguments)] // explicit signal props
fn file_browser(
me: ReadSignal<Option<Me>>,
root: &RootInfo,
loc: ReadSignal<Location>,
list_state: ReadSignal<ListState>,
view_mode: ReadSignal<ViewMode>,
set_view_mode: WriteSignal<ViewMode>,
set_ctx: WriteSignal<Option<CtxMenu>>,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
fetch: Callback<()>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
@@ -206,6 +254,12 @@ fn file_browser(
let root_name = root.name.clone();
let is_rw = root.mode.is_writable();
let loc_now = loc.get();
// The user's profile setting: single click opens entries; when off,
// a single click selects and a double click opens.
let single_click = me
.get()
.and_then(|m| m.user)
.is_some_and(|u| u.single_click_open);
// Breadcrumbs
let crumbs = view! {
@@ -253,7 +307,8 @@ fn file_browser(
</div>
};
// Toolbar: refresh on the left, view toggles on the right.
// Toolbar: refresh on the left, view toggles on the right. The
// selection's action buttons live in the app's top bar.
let toolbar = view! {
<div class="toolbar">
<button
@@ -304,8 +359,11 @@ fn file_browser(
let entries_area = view! {
<div
class="entries-area"
// Clicking empty space clears the selection.
on:click=move |_| set_selected.set(None)
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_selected.set(None);
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
@@ -336,9 +394,31 @@ fn file_browser(
.into_view()
.into_any()
} else if view_mode.get() == ViewMode::Grid {
grid_view(&entries, root_id, loc, is_rw, set_ctx, open_file).into_any()
grid_view(
&entries,
root_id,
loc,
is_rw,
single_click,
selected,
set_selected,
set_ctx,
open_file,
)
.into_any()
} else {
list_view(&entries, root_id, loc, is_rw, set_ctx, open_file).into_any()
list_view(
&entries,
root_id,
loc,
is_rw,
single_click,
selected,
set_selected,
set_ctx,
open_file,
)
.into_any()
}
}
}
@@ -353,14 +433,16 @@ fn file_browser(
}
}
/// Right-click on an entry: open the context menu on it.
/// Right-click on an entry: select it and open the context menu on it.
fn ctx_menu_handler(
entry: Entry,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_selected: WriteSignal<Option<Entry>>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
set_selected.set(Some(entry.clone()));
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
@@ -369,6 +451,35 @@ fn ctx_menu_handler(
}
}
/// Left-click behaviour per the "single click opens" profile setting:
/// - off (default): the click selects the entry; a double click opens it;
/// - on: the click opens it, Ctrl/Cmd+click toggles the selection.
///
/// Selecting clicks stop propagation so the empty-area click (deselect) and
/// the double-click handler don't interfere.
fn selection_click(
entry: Entry,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
open: Callback<()>,
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
if single_click {
if ev.ctrl_key() || ev.meta_key() {
ev.stop_propagation();
let is_sel = selected.get().is_some_and(|s| s.name == entry.name);
set_selected.set(if is_sel { None } else { Some(entry.clone()) });
return;
}
open.run(());
return;
}
ev.stop_propagation();
set_selected.set(Some(entry.clone()));
}
}
/// What "open" does for an entry: descend into a folder, open a text file in
/// the editor (read-only in read-only folders), open a media preview — or
/// say so when there is no viewer. Shared by left-click, Enter, and the
@@ -423,27 +534,42 @@ fn open_entry(
})
}
/// Click/Enter on an entry: the shared "open" action plus the context-menu
/// handler. The grid and the list differ only in markup.
fn open_callback(
/// Click/Enter on an entry: the shared "open" action, the selection-aware
/// click handler, and the context-menu handler. The grid and the list differ
/// only in markup.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn entry_callbacks(
entry: &Entry,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
) -> (
Callback<()>,
impl Fn(web_sys::MouseEvent) + 'static,
impl Fn(web_sys::MouseEvent) + 'static,
) {
let open_cb = open_entry(entry, root_id, loc, is_rw, open_file);
(
open_entry(entry, root_id, loc, is_rw, open_file),
ctx_menu_handler(entry.clone(), set_ctx),
open_cb,
selection_click(entry.clone(), single_click, selected, set_selected, open_cb),
ctx_menu_handler(entry.clone(), set_ctx, set_selected),
)
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn grid_view(
entries: &[Entry],
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
@@ -453,18 +579,22 @@ fn grid_view(
let icon = icon_for(e.kind, &e.name);
let name = e.name.clone();
let title = name.clone();
let (open_cb, on_ctx) = open_callback(e, root_id, loc, is_rw, set_ctx, open_file);
let oc1 = open_cb;
let oc2 = open_cb;
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let tile_name = name.clone();
let selected_now = move || sel_check.get().is_some_and(|s| s.name == tile_name);
view! {
<div
class="tile"
class:selected=selected_now
tabindex=0
on:click=move |_| oc1.run(())
on:click=on_click
on:dblclick=move |_| open_cb.run(())
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
oc2.run(());
open_cb.run(());
}
}
on:contextmenu=on_ctx
@@ -479,11 +609,15 @@ fn grid_view(
}
}
#[allow(clippy::too_many_arguments)] // explicit signal props
fn list_view(
entries: &[Entry],
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
single_click: bool,
selected: ReadSignal<Option<Entry>>,
set_selected: WriteSignal<Option<Entry>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
@@ -496,18 +630,22 @@ fn list_view(
let date = format_date(&e.mtime);
let is_dir = e.is_dir;
let title = name.clone();
let (open_cb, on_ctx) = open_callback(e, root_id, loc, is_rw, set_ctx, open_file);
let oc1 = open_cb;
let oc2 = open_cb;
let (open_cb, on_click, on_ctx) =
entry_callbacks(e, root_id, loc, is_rw, single_click, selected, set_selected, set_ctx, open_file);
let sel_check = selected;
let row_name = name.clone();
let selected_now = move || sel_check.get().is_some_and(|s| s.name == row_name);
view! {
<div
class="row"
class:selected=selected_now
tabindex=0
on:click=move |_| oc1.run(())
on:click=on_click
on:dblclick=move |_| open_cb.run(())
on:keydown=move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" || ev.key() == " " {
ev.prevent_default();
oc2.run(());
open_cb.run(());
}
}
on:contextmenu=on_ctx
@@ -528,6 +666,158 @@ fn list_view(
// Context menu
// ---------------------------------------------------------------------------
/// Build the action list for an entry (or the empty area, when `entry` is
/// `None`). Shared by the right-click context menu and the toolbar's
/// selection actions so both always offer the same operations.
#[allow(clippy::too_many_arguments)] // explicit signal props
fn menu_items(
entry: Option<&Entry>,
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Vec<MenuItem> {
let me_now = me.get();
let loc_now = loc.get();
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`.
let root = me_now
.as_ref()
.and_then(|m| effective_root(&m.roots, &loc_now));
let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false);
let root_name = root.map(|r| r.name.clone()).unwrap_or_default();
let root_id = root.map(|r| r.id);
let allow_writable_shares = me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
let own = || owner.clone();
match entry {
Some(e) => {
let mut v = vec![];
// Exactly what left-clicking the entry does: descend into a
// folder, open a text file in the editor (read-only in read-only
// folders), or preview media.
if let Some(rid) = root_id {
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
"Open",
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG render as
// real pages there (sandboxed by the server), which is how
// you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
"Open in new tab",
action_open_tab(e, root_id, loc, own()),
));
}
v.push(MenuItem::new(
IconName::Download,
"Download",
action_download(e, root_id, loc, set_dialog, toast, own()),
));
v.push(MenuItem::rw(
IconName::Rename,
"Rename",
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Move,
"Move",
is_rw,
action_move_copy(
Op::Move,
e,
root_id,
me,
loc,
refresh,
toast,
set_dialog,
own(),
),
));
// Copying only writes at the destination, so a read-only source
// folder is fine.
v.push(MenuItem::new(
IconName::Copy,
"Copy",
action_move_copy(
Op::Copy,
e,
root_id,
me,
loc,
refresh,
toast,
set_dialog,
own(),
),
));
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
"Share",
action_share(
e,
root_id,
loc,
allow_writable_shares,
is_rw,
set_dialog,
own(),
),
));
}
v.push(MenuItem::new(
IconName::Info,
"Info",
action_info(e, loc, root_name, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Delete,
"Delete",
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v
}
None => vec![
MenuItem::rw(
IconName::NewFolder,
"New folder",
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
),
MenuItem::rw(
IconName::Upload,
"Upload files",
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
"Upload folder",
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
],
}
}
#[component]
fn CtxMenuView(
ctx: ReadSignal<Option<CtxMenu>>,
@@ -575,112 +865,16 @@ fn CtxMenuView(
let x = m.x.min(max_x).max(0);
let y = m.y.min(max_y).max(0);
let me_now = me.get();
let loc_now = loc.get();
// The root that is actually shown. Resolves the single-root default
// location, where `loc.root_id` is `None`.
let root = me_now.as_ref().and_then(|m2| effective_root(&m2.roots, &loc_now));
let is_rw = root.map(|r| r.mode.is_writable()).unwrap_or(false);
let root_name = root.map(|r| r.name.clone()).unwrap_or_default();
let root_id = root.map(|r| r.id);
let allow_writable_shares = me_now
.as_ref()
.map(|m2| m2.allow_writable_shares)
.unwrap_or(false);
let own = || owner.clone();
let items: Vec<MenuItem> = match &m.entry {
Some(e) => {
let mut v = vec![];
// Exactly what left-clicking the entry does: descend into
// a folder, open a text file in the editor (read-only in
// read-only folders), or preview media.
if let Some(rid) = root_id {
v.push(MenuItem::new(
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
"Open",
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
// Serve the file itself in a new tab. HTML and SVG
// render as real pages there (sandboxed by the server),
// which is how you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
"Open in new tab",
action_open_tab(e, root_id, loc, own()),
));
}
v.push(MenuItem::new(
IconName::Download,
"Download",
action_download(e, root_id, loc, set_dialog, toast, own()),
));
v.push(MenuItem::rw(
IconName::Rename,
"Rename",
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Move,
"Move",
is_rw,
action_move_copy(Op::Move, e, root_id, me, loc, refresh, toast, set_dialog, own()),
));
// Copying only writes at the destination, so a read-only
// source folder is fine.
v.push(MenuItem::new(
IconName::Copy,
"Copy",
action_move_copy(Op::Copy, e, root_id, me, loc, refresh, toast, set_dialog, own()),
));
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
"Share",
action_share(e, root_id, loc, allow_writable_shares, is_rw, set_dialog, own()),
));
}
v.push(MenuItem::new(
IconName::Info,
"Info",
action_info(e, loc, root_name, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Delete,
"Delete",
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v
}
None => vec![
MenuItem::rw(
IconName::NewFolder,
"New folder",
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
),
MenuItem::rw(
IconName::Upload,
"Upload files",
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
"Upload folder",
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
],
};
let items = menu_items(
m.entry.as_ref(),
me,
loc,
open_file,
refresh,
toast,
set_dialog,
owner.clone(),
);
view! {
<div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
@@ -697,6 +891,7 @@ fn CtxMenuView(
/// One row of the context menu. `action` is `None` for a disabled row, and
/// `hint` then says why it is disabled.
#[derive(Clone)]
struct MenuItem {
label: &'static str,
icon: IconName,
@@ -751,6 +946,185 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
}
}
// ---------------------------------------------------------------------------
// Toolbar selection actions (top right)
// ---------------------------------------------------------------------------
/// Icon-only buttons for the currently selected entry — the same set of
/// actions as its right-click menu — or the empty-area actions (new folder,
/// uploads) when nothing is selected. Rendered in the app's top bar (top
/// right). Buttons that don't fit collapse into a three-dot overflow menu.
#[component]
pub fn SelectionActions(
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
selected: ReadSignal<Option<Entry>>,
open_file: WriteSignal<Option<FileView>>,
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
topbar_ref: NodeRef<leptos::html::Header>,
) -> impl IntoView {
// How many buttons are shown before the overflow kicks in. Starts small
// so the first paint never overflows; the effect below widens it as soon
// as the real widths are measurable.
let (visible_n, set_visible_n) = signal(3usize);
// The three-dot overflow menu state. Any outside click or Escape closes
// it; a new selection does too. The toggle button stops propagation so
// it can actually open the menu.
let (more_open, set_more_open) = signal(false);
crate::util::owned_window_listener(leptos::ev::click, move |_| set_more_open.set(false));
crate::util::owned_window_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_more_open.set(false);
}
});
Effect::new(move |_| {
let _ = selected.get();
set_more_open.set(false);
});
let group_ref = NodeRef::<leptos::html::Div>::new();
let recompute = move || {
let Some(tb) = topbar_ref
.get()
.and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
else {
return;
};
let Some(g) = group_ref
.get()
.and_then(|d| d.dyn_into::<web_sys::HtmlElement>().ok())
else {
return;
};
// Every button is icon-only with identical padding, so one
// rendered button gives the width of all of them.
let Some(btn) = g.first_element_child() else {
return;
};
// Available width: from the top bar's left content (brand) to its
// right edge — independent of where the group currently sits,
// so the measurement stays valid even while the group overflows.
let tb_r = tb.get_bounding_client_rect();
let start = tb
.query_selector(".brand")
.ok()
.flatten()
.map(|b| b.get_bounding_client_rect())
.map(|r| r.right() + 16.0)
.unwrap_or(0.0);
let avail = (tb_r.right() - start - 10.0).max(40.0);
let step = btn.get_bounding_client_rect().width() + 6.0; // + toolbar gap
// How many buttons fit (not counting the overflow button).
set_visible_n.set(((avail / step) as usize).max(1));
};
Effect::new(move |_| {
topbar_ref.get();
group_ref.get();
// Re-measure when the item set (selection, user) or width inputs
// change.
selected.get();
me.get();
recompute();
});
crate::util::owned_window_listener(leptos::ev::resize, move |_| recompute());
view! {
<div class="sel-actions" node_ref=group_ref>
// The visible buttons and the overflow rows are one item list
// split at the measured count, so the actions are computed once.
{move || {
let items = menu_items(
selected.get().as_ref(),
me,
loc,
open_file,
refresh,
toast,
set_dialog,
owner.clone(),
);
let fit = visible_n.get();
let n = if fit >= items.len() {
items.len()
} else {
// The overflow button takes a slot too, so drop one —
// otherwise the group eats into the space after the brand.
(fit - 1).max(1)
};
let (visible, more) = items.split_at(n);
let visible_view = visible
.iter()
.cloned()
.map(|i| view! {
<SelButton item=i/>
})
.collect_view();
// Rendered as nothing while every action fits.
let more_block = (!more.is_empty()).then(|| {
let more_view = more
.iter()
.cloned()
.map(|i| view! {
<CtxItem item=i/>
})
.collect_view();
view! {
<div class="sel-more-wrap">
<button
class="icon-btn"
title="More actions"
aria-label="More actions"
on:click=move |ev: MouseEvent| {
ev.stop_propagation();
set_more_open.set(!more_open.get());
}
>
<Icon name=IconName::MoreVert class="ic-btn".to_string()/>
</button>
<div class="sel-more-menu" class:hidden=move || !more_open.get()>
{more_view}
</div>
</div>
}
});
view! {
{visible_view}
{more_block}
}
}}
</div>
}
}
/// One icon-only toolbar button. A disabled button keeps its tooltip, which
/// then explains why.
#[component]
fn SelButton(item: MenuItem) -> impl IntoView {
let enabled = item.action.is_some();
let title = if enabled {
item.label.to_string()
} else {
format!("{} — {}", item.label, item.hint.unwrap_or_default())
};
view! {
<button
class="icon-btn"
disabled=!enabled
title=title
on:click=move |_| {
if let Some(a) = &item.action {
a.run(());
}
}
>
<Icon name=item.icon class="ic-btn".to_string()/>
</button>
}
}
// ---------------------------------------------------------------------------
// Menu actions
// ---------------------------------------------------------------------------
Mweb/src/views/shell.rs
@@ -3,14 +3,15 @@
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::api::{self, Entry, Me};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
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};
use crate::views::browser::{Browser, effective_root};
use crate::views::browser::{Browser, SelectionActions, effective_root};
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::shares::SharesView;
@@ -57,6 +58,22 @@ pub fn ShellView(
let (saving, set_saving) = signal(false);
// The browser's fetch, exposed so the editor can re-list after a save.
let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
let topbar_ref = NodeRef::<leptos::html::Header>::new();
// The single selected entry in the file browser; its action buttons live
// in the top bar's top right. `None` shows the empty-area actions.
let (selected, set_selected) = signal(Option::<Entry>::None);
// Dialogs are rendered here, above everything (used by both the browser
// and the top bar's selection actions).
let (dialog, set_dialog) = signal(Option::<Dialog>::None);
// Long-lived owner for callbacks created inside short-lived scopes
// (top-bar selection actions).
let shell_owner = Owner::current().unwrap();
// Navigating (new folder, root, section) invalidates the selection.
Effect::new(move |_| {
let _ = loc.get();
set_selected.set(None);
});
let close_file = Callback::new(move |_| {
set_file_view.set(None);
@@ -103,7 +120,7 @@ pub fn ShellView(
return;
}
let section = loc2.get().section;
if section == Section::Users || section == Section::Settings {
if section == Section::Users {
navigate(&Location::root());
}
});
@@ -139,7 +156,7 @@ pub fn ShellView(
}
}
>
<header class="topbar">
<header class="topbar" node_ref=topbar_ref>
<div class="topbar-left">
<button
class="icon-btn nav-toggle"
@@ -179,6 +196,25 @@ pub fn ShellView(
}
.into_view()
.into_any()
} else if loc.get().section == Section::Files {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<SelectionActions
me=me
loc=loc
selected=selected
open_file=set_file_view
refresh=refresh
toast=toast
set_dialog=set_dialog
owner=shell_owner.clone()
topbar_ref=topbar_ref
/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
@@ -308,15 +344,15 @@ pub fn ShellView(
<Icon name=IconName::User class="ic-tab".to_string()/>
<span class="nav-tab-name">"Users"</span>
</button>
<button
class="nav-tab"
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>
</button>
</Show>
<button
class="nav-tab"
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>
</button>
</div>
</nav>
<div class="sidebar-footer">
@@ -368,6 +404,10 @@ pub fn ShellView(
loc=loc
open_file=set_file_view
set_browser_refresh=set_browser_refresh
selected=selected
set_selected=set_selected
set_dialog=set_dialog
file_view=file_view
/>
}
.into_view()
@@ -378,9 +418,11 @@ pub fn ShellView(
Section::Users if admin => {
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()
Section::Settings => view! {
<SettingsView me=me set_me=set_me/>
}
.into_view()
.into_any(),
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! {
@@ -390,6 +432,10 @@ pub fn ShellView(
loc=loc
open_file=set_file_view
set_browser_refresh=set_browser_refresh
selected=selected
set_selected=set_selected
set_dialog=set_dialog
file_view=file_view
/>
}
.into_view()
@@ -422,6 +468,7 @@ pub fn ShellView(
</main>
</div>
<ToastView toast=toast/>
<DialogView dialog=dialog set_dialog=set_dialog/>
</div>
}
}