UI: Open in context menu, RO/RW text editor, share management polish
- Context menu: "Open" for all entries (same behavior as left-click); remove "Edit" — text files now always open in the editor, read-only or read-write depending on the root's permissions - Editor: read-only mode (contenteditable off, no Save, "Read-only" badge) - Shares view: "Delete all" button with warning dialog; Copy/Delete buttons share a consistent visible border; share dialog hides the writable checkbox for items in read-only roots - Settings: saving "allow writable shares" re-fetches /me so the session picks up the change without a reload - Buttons: per-theme border tokens (--accent-border/--danger-border) so accent/danger buttons have a visible edge in both themes Co-Authored-By: Qwen3.8 27b
Mweb/app.css
@@ -10,7 +10,9 @@
--muted: #6b7280;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-border: #1d4ed8;
--danger: #ef4444;
--danger-border: #dc2626;
--border: #e5e7eb;
--topbar-h: 48px;
}
@@ -23,7 +25,9 @@
--muted: #9ca3af;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--accent-border: #79b5fc;
--danger: #f87171;
--danger-border: #fa8b8b;
--border: #2a2f3a;
}
@@ -36,7 +40,9 @@
--muted: #9ca3af;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--accent-border: #79b5fc;
--danger: #f87171;
--danger-border: #fa8b8b;
--border: #2a2f3a;
}
}
@@ -147,14 +153,16 @@ button:disabled {
.btn-primary {
width: 100%;
background: var(--accent);
border-color: var(--accent);
/* One step away from the fill, per theme (see palette above), so the
button's edge reads the same as on neutral buttons. */
border-color: var(--accent-border);
color: #fff;
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
border-color: var(--accent-border);
}
.error {
@@ -706,7 +714,9 @@ button:disabled {
.btn-danger {
background: var(--danger);
border-color: var(--danger);
/* A visible border (one step away from the fill, per theme) so the
button's edge reads the same as on neutral buttons. */
border-color: var(--danger-border);
color: #fff;
}
@@ -854,12 +864,7 @@ button:disabled {
max-width: 480px;
}
/* CodeMirror text preview */
.cm-wrap {
position: relative;
width: 100%;
height: 100%;
}
/* CodeMirror (text editor) */
.cm-container {
height: 100%;
}
Mweb/src/editor.rs
@@ -22,6 +22,8 @@ pub struct EditTarget {
pub path: String,
/// Display name (used for the title + language detection).
pub name: String,
/// True in read-only folders: the file is shown, not editable.
pub readonly: bool,
}
/// Read the current document and PUT it, updating the editor's state.
@@ -89,6 +91,7 @@ pub fn EditorModal(
let suppress = StoredValue::new(false);
let name = target.name.clone();
let ro = target.readonly;
// Load the file and create an editable CodeMirror.
{
@@ -99,6 +102,13 @@ pub fn EditorModal(
spawn_local(async move {
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, mtime)) => {
if ro {
match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => cm_view.set_value(Some(c.view.clone())),
Err(e) => st.set(Some(e)),
}
return;
}
let closure = Closure::<dyn FnMut(JsValue)>::new(move |_| {
// Consume the suppression flag: only the change
// right after a programmatic set_value is ignored.
@@ -202,8 +212,9 @@ pub fn EditorModal(
})
};
// Ctrl/Cmd+S saves (scoped to the editor's lifetime).
{
// Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
// in read-only mode.
if !ro {
let cb = save_cb;
crate::util::owned_window_listener(
leptos::ev::keydown,
@@ -248,13 +259,25 @@ pub fn EditorModal(
{move || if dirty.get() { " •" } else { " " }}
</span>
<div class="editor-actions">
<button
class="btn btn-primary"
disabled=move || !dirty.get() || saving.get()
on:click=move |_| save_cb.run(())
>
{move || if saving.get() { "Saving…" } else { "Save" }}
</button>
{move || {
if ro {
view! { <span class="badge">"Read-only"</span> }
.into_view()
.into_any()
} else {
view! {
<button
class="btn btn-primary"
disabled=move || !dirty.get() || saving.get()
on:click=move |_| save_cb.run(())
>
{move || if saving.get() { "Saving…" } else { "Save" }}
</button>
}
.into_view()
.into_any()
}
}}
<button
class="preview-close"
title="Close"
Mweb/src/icons.rs
@@ -47,8 +47,8 @@ pub enum IconName {
NewFolder,
/// material-symbols:download
Download,
/// material-symbols:edit
Edit,
/// material-symbols:open-in-full
Open,
/// material-symbols:drive-file-rename
Rename,
/// material-symbols:drive-file-move
@@ -144,8 +144,8 @@ impl IconName {
Self::Download => {
r#"<path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/>"#
}
Self::Edit => {
r#"<path fill="currentColor" d="M3 21v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM17.6 7.8L19 6.4L17.6 5l-1.4 1.4z"/>"#
Self::Open => {
r#"<path fill="currentColor" d="M3 21v-8h2v4.6L17.6 5H13V3h8v8h-2V6.4L6.4 19H11v2z"/>"#
}
Self::Rename => {
r#"<path fill="currentColor" d="M11.225 17H18v-2h-4.775zM6 17h3.075l6.5-6.5q.225-.225.325-.513t.1-.562t-.112-.55t-.313-.5l-.925-.925q-.225-.225-.5-.337T13.575 7q-.275 0-.55.1t-.5.325L6 13.925zm7.575-6.6l-.95-.95l.95-.95l.925.925zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z"/>"#
Mweb/src/preview.rs
@@ -1,14 +1,11 @@
//! File previews (milestone 4): browser-native media (image/PDF/video/audio)
//! and read-only CodeMirror for text/code.
//! File previews (milestone 4): browser-native media (image/PDF/video/audio).
//! Text files open in the editor instead.
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api;
use crate::cm;
use crate::components::icon::Icon;
use crate::icons::IconName;
@@ -19,19 +16,18 @@ pub enum PreviewKind {
Pdf,
Video,
Audio,
Text,
}
/// Which viewer opens this entry, from the server's sniffed [`FileKind`].
/// `None` = nothing we can show in the browser (download only).
/// `None` = nothing we can preview here: text goes to the editor, archives
/// and binaries are download-only.
pub fn preview_kind(kind: FileKind) -> Option<PreviewKind> {
match kind {
FileKind::Image => Some(PreviewKind::Image),
FileKind::Pdf => Some(PreviewKind::Pdf),
FileKind::Video => Some(PreviewKind::Video),
FileKind::Audio => Some(PreviewKind::Audio),
FileKind::Text => Some(PreviewKind::Text),
FileKind::Dir | FileKind::Archive | FileKind::Binary => None,
FileKind::Dir | FileKind::Archive | FileKind::Binary | FileKind::Text => None,
}
}
@@ -53,10 +49,7 @@ pub fn PreviewModal(
close: Callback<()>,
) -> impl IntoView {
let t = target;
let url = match kind {
PreviewKind::Text => String::new(),
_ => api::preview_url(t.root_id, &t.path),
};
let url = api::preview_url(t.root_id, &t.path);
let name = t.name.clone();
// Escape closes the preview.
{
@@ -105,68 +98,9 @@ pub fn PreviewModal(
.into_view()
.into_any()
}
PreviewKind::Text => view! {
<TextPreview target=t.clone()/>
}
.into_view()
.into_any()
}}
</div>
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Text preview (read-only CodeMirror)
// ---------------------------------------------------------------------------
#[component]
fn TextPreview(target: PreviewTarget) -> impl IntoView {
let node = NodeRef::<leptos::html::Div>::new();
// Some(err) when the file couldn't be loaded.
let (status, set_status) = signal(Option::<String>::None);
// Holds the live CodeMirror view so we can destroy it on close.
let holder = StoredValue::new_local(Option::<JsValue>::None);
{
let t = target.clone();
let st = set_status;
node.on_load(move |el: web_sys::HtmlDivElement| {
let t = t.clone();
spawn_local(async move {
// The mtime only matters for the editor's conflict check.
match api::fetch_content_meta(t.root_id, &t.path).await {
Ok((text, _)) => match cm::create(&el, &text, &t.name, false, None) {
Ok(c) => holder.set_value(Some(c.view.clone())),
Err(e) => st.set(Some(e)),
},
Err(e) => st.set(Some(e.to_string())),
}
});
});
}
on_cleanup(move || {
if let Some(v) = holder.try_update_value(Option::take).flatten() {
cm::destroy(&v);
}
});
view! {
<div class="cm-wrap">
<div node_ref=node class="cm-container"></div>
{move || {
let err = status.get();
match err {
Some(e) => view! { <div class="cm-error muted">{e}</div> }
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
}
}
Mweb/src/views/admin.rs
@@ -15,7 +15,7 @@ use crate::icons::IconName;
// ---------------------------------------------------------------------------
#[component]
pub fn SettingsView() -> impl IntoView {
pub fn SettingsView(set_me: WriteSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (value, set_value) = signal(Option::<bool>::None);
let (busy, set_busy) = signal(false);
@@ -36,9 +36,17 @@ pub fn SettingsView() -> impl IntoView {
let Some(v) = value.get() else { return };
set_busy.set(true);
let toast2 = toast;
let set_me2 = set_me;
spawn_local(async move {
match api::update_admin_settings(v).await {
Ok(_) => show(toast2, "Settings saved".to_string()),
Ok(_) => {
show(toast2, "Settings saved".to_string());
// Re-fetch this session's /me so the share dialog (and
// anything else) sees the new setting without a reload.
if let Ok(m) = api::me().await {
set_me2.set(Some(m));
}
}
Err(e) => show(toast2, format!("Could not save settings: {e}")),
}
set_busy.set(false);
Mweb/src/views/browser.rs
@@ -94,6 +94,7 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
set_ctx,
fetch,
set_preview,
set_editor,
)
.into_any(),
None => no_folder_view(&me.roots, &loc_now).into_any(),
@@ -107,6 +108,7 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
refresh=fetch
toast=toast
set_dialog=set_dialog
set_preview=set_preview
set_editor=set_editor
owner=me_owner
/>
@@ -197,9 +199,11 @@ fn file_browser(
set_ctx: WriteSignal<Option<CtxMenu>>,
fetch: Callback<()>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
) -> impl IntoView {
let root_id = root.id;
let root_name = root.name.clone();
let is_rw = root.mode.is_writable();
let loc_now = loc.get();
// Breadcrumbs
@@ -329,9 +333,9 @@ fn file_browser(
.into_view()
.into_any()
} else if view_mode.get() == ViewMode::Grid {
grid_view(&entries, root_id, loc, set_ctx, set_preview).into_any()
grid_view(&entries, root_id, loc, is_rw, set_ctx, set_preview, set_editor).into_any()
} else {
list_view(&entries, root_id, loc, set_ctx, set_preview).into_any()
list_view(&entries, root_id, loc, is_rw, set_ctx, set_preview, set_editor).into_any()
}
}
}
@@ -346,57 +350,60 @@ fn file_browser(
}
}
fn entry_actions(
/// Right-click on an entry: open the context menu on it.
fn ctx_menu_handler(
entry: Entry,
root_id: i64,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) {
let entry2 = entry.clone();
let nav = move || {
let cur = loc.get();
let mut path = cur.path;
path.push(entry.name.clone());
navigate(&Location {
root_id: Some(root_id),
path,
share_token: cur.share_token.clone(),
});
};
let ctx_handler = move |ev: MouseEvent| {
) -> impl Fn(web_sys::MouseEvent) + 'static {
move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
entry: Some(entry2.clone()),
entry: Some(entry.clone()),
}));
};
(nav, ctx_handler)
}
}
/// Click/Enter on an entry: descend into a folder, or open the right viewer for
/// a file. Shared by the grid and the list, which differ only in markup.
fn open_callback(
/// 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
/// context menu's "Open" item.
fn open_entry(
entry: &Entry,
root_id: i64,
loc: ReadSignal<Location>,
set_ctx: WriteSignal<Option<CtxMenu>>,
is_rw: bool,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
let (nav, on_ctx) = entry_actions(entry.clone(), root_id, loc, set_ctx);
// Resolved here, during the caller's render: the callback runs later, when
// this reactive owner may already be gone.
let toast = use_context::<ToastMsg>().expect("toast context");
set_editor: WriteSignal<Option<EditTarget>>,
toast: ToastMsg,
) -> Callback<()> {
let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
let cb = Callback::new(move |_| {
Callback::new(move |_| {
if is_dir {
nav();
let cur = loc.get();
let mut path = cur.path;
path.push(name.clone());
navigate(&Location {
root_id: Some(root_id),
path,
share_token: cur.share_token.clone(),
});
return;
}
let full = join_path(&loc.get().path, &name);
if kind == FileKind::Text {
set_editor.set(Some(EditTarget {
root_id,
path: full,
name: name.clone(),
readonly: !is_rw,
}));
return;
}
match preview_kind(kind) {
Some(k) => {
let full = join_path(&loc.get().path, &name);
set_preview.set(Some((
PreviewTarget {
root_id,
@@ -408,16 +415,37 @@ fn open_callback(
}
None => show(toast, "No preview available — right-click to download"),
}
});
(cb, on_ctx)
})
}
/// 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(
entry: &Entry,
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
) -> (Callback<()>, impl Fn(web_sys::MouseEvent) + 'static) {
// Resolved here, during the caller's render: the callback runs later, when
// this reactive owner may already be gone.
let toast = use_context::<ToastMsg>().expect("toast context");
(
open_entry(entry, root_id, loc, is_rw, set_preview, set_editor, toast),
ctx_menu_handler(entry.clone(), set_ctx),
)
}
fn grid_view(
entries: &[Entry],
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
) -> impl IntoView {
view! {
<div class="entries-grid">
@@ -425,7 +453,7 @@ 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, set_ctx, set_preview);
let (open_cb, on_ctx) = open_callback(e, root_id, loc, is_rw, set_ctx, set_preview, set_editor);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -455,8 +483,10 @@ fn list_view(
entries: &[Entry],
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
) -> impl IntoView {
view! {
<div class="entries-list">
@@ -467,7 +497,7 @@ 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, set_ctx, set_preview);
let (open_cb, on_ctx) = open_callback(e, root_id, loc, is_rw, set_ctx, set_preview, set_editor);
let oc1 = open_cb;
let oc2 = open_cb;
view! {
@@ -508,6 +538,7 @@ fn CtxMenuView(
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
owner: Owner,
) -> impl IntoView {
@@ -563,13 +594,21 @@ fn CtxMenuView(
let items: Vec<MenuItem> = match &m.entry {
Some(e) => {
let mut v = vec![];
if e.is_dir {
// 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(
IconName::FolderOpen,
if e.is_dir {
IconName::FolderOpen
} else {
IconName::Open
},
"Open",
action_open_dir(e, loc, own()),
open_entry(e, rid, loc, is_rw, set_preview, set_editor, toast),
));
} else {
}
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.
@@ -584,15 +623,6 @@ fn CtxMenuView(
"Download",
action_download(e, root_id, loc, set_dialog, toast, own()),
));
// Editing is only offered for files that decode as text.
if e.kind == FileKind::Text {
v.push(MenuItem::rw(
IconName::Edit,
"Edit",
is_rw,
action_edit(e, root_id, loc, set_editor, own()),
));
}
v.push(MenuItem::rw(
IconName::Rename,
"Rename",
@@ -616,7 +646,7 @@ fn CtxMenuView(
v.push(MenuItem::new(
IconName::Share,
"Share",
action_share(e, root_id, loc, allow_writable_shares, set_dialog, own()),
action_share(e, root_id, loc, allow_writable_shares, is_rw, set_dialog, own()),
));
}
v.push(MenuItem::new(
@@ -1067,23 +1097,6 @@ fn action_download(
})
}
/// Descend into a folder.
fn action_open_dir(entry: &Entry, loc: ReadSignal<Location>, owner: Owner) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let cur = loc.get();
let mut path = cur.path;
path.push(name.clone());
navigate(&Location {
root_id: cur.root_id,
path,
share_token: cur.share_token,
});
})
})
}
/// Open the file itself in a new tab, via the `?action=preview` endpoint —
/// the browser renders it natively (HTML/SVG under the server's sandbox CSP).
fn action_open_tab(
@@ -1102,35 +1115,13 @@ fn action_open_tab(
})
}
/// Open the text editor for a file (milestone 5).
fn action_edit(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
set_editor: WriteSignal<Option<EditTarget>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
set_editor.set(Some(EditTarget {
root_id,
path: full,
name: name.clone(),
}));
})
})
}
/// Open the share dialog for an item (milestone 6).
fn action_share(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
allow_writable: bool,
root_writable: bool,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
@@ -1145,6 +1136,7 @@ fn action_share(
root_id,
path: full,
allow_writable,
root_writable,
}));
})
})
Mweb/src/views/dialogs.rs
@@ -77,7 +77,11 @@ pub enum Dialog {
name: String,
root_id: i64,
path: String,
/// The server's global "allow writable shares" setting.
allow_writable: bool,
/// Whether the item's root is writable — a writable share is
/// impossible from a read-only root, so the checkbox hides.
root_writable: bool,
},
}
@@ -272,12 +276,14 @@ pub fn DialogView(
root_id,
path,
allow_writable,
root_writable,
} => {
let (name, root_id, path, allow_writable) = (
let (name, root_id, path, allow_writable, root_writable) = (
name.clone(),
*root_id,
path.clone(),
*allow_writable,
*root_writable,
);
view! {
<crate::views::shares::ShareDialog
@@ -285,6 +291,7 @@ pub fn DialogView(
root_id=root_id
path=path
allow_writable=allow_writable
root_writable=root_writable
close=close
/>
}
Mweb/src/views/shell.rs
@@ -241,7 +241,7 @@ pub fn ShellView(
view! { <UsersView me=me/> }.into_view().into_any()
}
Panel::Settings if admin => {
view! { <SettingsView/> }.into_view().into_any()
view! { <SettingsView set_me=set_me/> }.into_view().into_any()
}
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.