UI: file-view topbar (logo, download), selection on Esc, share URLs

- Icon-only logo in the file-view topbar (app + shares), linking to /
- Download button in the file-view topbar for all viewers
- Esc closing a dialog (rename/move/copy/...) now keeps the selection,
  like closing a preview does
- #/ is now always the no-root empty state for signed-in users (the
  single-root shortcut is shares-only), so files are only ever opened
  from an explicit #/files/{id} URL and the logo click always navigates
- Share links move from #/shares/{token} to #/share/{token} (clean
  break); #/shares is now unambiguously the manage view
- Share topbar: file title left-aligned next to the logo
- No-root page: hide the topbar selection actions instead of showing
  a row of disabled buttons
AuthorKonata <konata@posteo.jp>
Date
Commit61a2d892fe261586270695989984ea657edd195c
Parentd85a613
7 files changed, 128 insertions(+), 34 deletions(-)
▾Mweb/app.css
@@ -1047,14 +1047,16 @@ button:disabled {
flex: none;
}
.file-close {
.file-close,
.file-download {
border: none;
background: transparent;
color: var(--muted);
padding: 6px 8px;
}
.file-close:hover {
.file-close:hover,
.file-download:hover {
color: var(--text);
background: var(--bg);
}
▾Mweb/src/app.rs
@@ -68,7 +68,7 @@ pub fn App() -> impl IntoView {
view! {
{move || {
// Public share links (`#/shares/{token}/...`) bypass the auth flow.
// Public share links (`#/share/{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
@@ -2,12 +2,9 @@
//!
//! - `#/` — root picker / single-root browser
//! - `#/files/{root_id}/{segment/...}` — a user's folder
//! - `#/shares/{token}/{segment/...}` — a public share (no login)
//! - `#/share/{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.
@@ -43,7 +40,7 @@ pub struct Location {
pub root_id: Option<i64>,
/// Path segments relative to the current root (or share target).
pub path: Vec<String>,
/// Present when the location is a public share (`#/s/{token}/...`).
/// Present when the location is a public share (`#/share/{token}/...`).
pub share_token: Option<String>,
/// Which shell section the location points at.
pub section: Section,
@@ -99,21 +96,23 @@ pub fn parse_location() -> Location {
share_token: None,
section: Section::Search,
},
Some("shares") => match parts.next() {
// `#/shares/{token}/...` — public share page.
// `#/share/{token}/...` — public share page.
Some("share") => match parts.next() {
Some(token) => Location {
root_id: None,
path: parts.map(decode_segment).collect(),
share_token: Some(token.to_string()),
section: Section::Files,
},
// `#/shares` — the signed-in shares manage view.
None => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Shares,
},
// `#/share` without a token is not a share; treat it as home.
None => Location::root(),
},
// `#/shares` — the signed-in shares manage view.
Some("shares") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Shares,
},
Some("users") => Location {
root_id: None,
@@ -133,7 +132,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!("#/shares/{token}");
let mut s = format!("#/share/{token}");
for seg in &loc.path {
s.push('/');
s.push_str(&encode_segment(seg));
@@ -170,5 +169,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!("{}#/shares/{token}", &base[..cut])
format!("{}#/share/{token}", &base[..cut])
}
▾Mweb/src/views/browser.rs
@@ -25,12 +25,17 @@ enum ListState {
Entries(Vec<Entry>),
}
/// The root that is actually shown: the one in the location, or — when the
/// user has exactly one root — that root directly.
/// The root that is actually shown: the one in the location. The one-root
/// shortcut is shares-only: a public share's location (`#/share/{token}`)
/// cannot carry its synthetic root's id, which only exists once the share is
/// resolved. Signed-in users always get an explicit `#/files/{root_id}` URL
/// (or the no-root empty state on `#/`) — that keeps every file open from a
/// browser listing on an explicit URL, so navigating away always changes the
/// hash.
pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
match loc.root_id {
Some(id) => roots.iter().find(|r| r.id == id),
None if roots.len() == 1 => Some(&roots[0]),
None if loc.share_token.is_some() && roots.len() == 1 => Some(&roots[0]),
None => None,
}
}
@@ -59,6 +64,10 @@ pub fn Browser(
set_selected: WriteSignal<Option<Entry>>,
/// Dialogs are rendered at the shell level.
set_dialog: WriteSignal<Option<Dialog>>,
/// The open dialog, if any. Escape must not clear the selection while
/// one is open (it closes the dialog instead) — checked in the capture
/// phase, before the dialog's own bubble-phase listener closes it.
dialog: ReadSignal<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.
@@ -88,16 +97,22 @@ pub fn Browser(
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.
// Escape clears the selection — but only when nothing that also reacts
// to Escape is open: the context menu (it has its own Escape handler),
// a modal dialog (Esc closes it; the selection must survive, same as
// for the file view), or a full-page file view (the shell closes it on
// the same keypress). Registered in the capture phase so it sees the
// dialog / file view *before* their bubble listeners close them; 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() {
if ev.key() != "Escape"
|| ctx.get().is_some()
|| dialog.get().is_some()
|| file_view.get().is_some()
{
return;
}
if let Some(active) = web_sys::window()
@@ -1064,6 +1079,19 @@ pub fn SelectionActions(
// The visible buttons and the overflow rows are one item list
// split at the measured count, so the actions are computed once.
{move || {
// No resolvable root (e.g. the `#/` "select a folder" empty
// state): there is nowhere to act, so hide the whole group
// rather than show a row of disabled buttons.
let Some(me_now) = me.get() else {
return {
view! {}.into_any()
};
};
if effective_root(&me_now.roots, &loc.get()).is_none() {
return {
view! {}.into_any()
};
}
let items = menu_items(
selected.get().as_ref(),
me,
@@ -1122,6 +1150,8 @@ pub fn SelectionActions(
{visible_view}
{more_block}
}
.into_view()
.into_any()
}}
</div>
}
▾Mweb/src/views/file_view.rs
@@ -10,7 +10,7 @@ use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::components::toast::ToastMsg;
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, Editor};
use crate::icons::{IconName, icon_for};
use crate::preview::{Preview, PreviewKind, PreviewTarget};
@@ -61,6 +61,24 @@ impl FileView {
}
}
/// Root the file lives in (for the download link).
pub fn root_id(&self) -> i64 {
match self {
FileView::Preview(t, _) => t.root_id,
FileView::Editor(t) => t.root_id,
FileView::Unsupported(t) => t.root_id,
}
}
/// Path relative to the root (for the download link).
pub fn path(&self) -> &str {
match self {
FileView::Preview(t, _) => &t.path,
FileView::Editor(t) => &t.path,
FileView::Unsupported(t) => &t.path,
}
}
/// True when the file is shown read-only (a text file in a read-only
/// root). Media is always "read-only" in the sense that there is no
/// save action; only the editor exposes the badge.
@@ -97,9 +115,11 @@ pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
}
/// Top-bar actions: Save (only present while an editable file has
/// registered its save callback) and close.
/// registered its save callback), Download and close.
#[component]
pub fn FileViewActions(
/// The open file — the download button targets it.
view: FileView,
saving: ReadSignal<bool>,
dirty: ReadSignal<bool>,
save_cb: ReadSignal<Option<Callback<()>>>,
@@ -109,7 +129,28 @@ pub fn FileViewActions(
/// False for file shares, where there is nowhere to close back to.
show_close: bool,
on_close: Callback<()>,
toast: ToastMsg,
) -> impl IntoView {
// Serves the file from disk (for an open editor: the saved version,
// not the working copy). Built once per open file — nothing about it
// is reactive.
let download_btn = {
let dl = view;
view! {
<button
class="icon-btn file-download"
title={i18n::tr(i18n::k::DOWNLOAD)}
aria-label={i18n::tr(i18n::k::DOWNLOAD)}
on:click=move |_| {
let url = api::download_url(dl.root_id(), dl.path(), None);
api::trigger_download(&url, dl.name());
show(toast, i18n::t(i18n::k::DOWNLOAD_STARTED));
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
</button>
}
};
view! {
<div class="file-actions">
{move || match save_cb.get() {
@@ -132,6 +173,7 @@ pub fn FileViewActions(
.into_any(),
None => view! {}.into_any(),
}}
{download_btn}
<Show when=move || show_close>
<button
class="icon-btn file-close"
▾Mweb/src/views/share_page.rs
@@ -195,7 +195,16 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
<header class="topbar" node_ref=topbar_ref>
{move || match file_view.get() {
Some(ref v) => view! {
<FileViewTitle view=v.clone() dirty=dirty/>
// Wrapped like the signed-in app's topbar-left so the
// logo + title stay together at the left edge.
<div class="topbar-left">
// Icon-only brand in file mode, like the
// signed-in app: back to `/`.
<a class="brand brand-icon" href="#/">
<Logo/>
</a>
<FileViewTitle view=v.clone() dirty=dirty/>
</div>
}
.into_view()
.into_any(),
@@ -207,19 +216,21 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
}}
<div class="topbar-right">
{move || {
if file_view.get().is_some() {
if let Some(v) = file_view.get() {
let show_close = info
.get()
.map(|i| !i.is_file)
.unwrap_or(true);
view! {
<FileViewActions
view=v
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=show_close
on_close=close_file
toast=toast
/>
}
.into_view()
@@ -307,6 +318,7 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
selected=selected
set_selected=set_selected
set_dialog=set_dialog
dialog=dialog
file_view=file_view
/>
}
▾Mweb/src/views/shell.rs
@@ -170,6 +170,11 @@ pub fn ShellView(
</button>
{move || match file_view.get() {
Some(ref v) => view! {
// The brand stays in file mode as an icon-only
// link to the root folder, left of the file title.
<a class="brand brand-icon" href="#/">
<Logo/>
</a>
<FileViewTitle view=v.clone() dirty=dirty/>
}
.into_view()
@@ -182,15 +187,17 @@ pub fn ShellView(
}}
</div>
{move || {
if file_view.get().is_some() {
if let Some(v) = file_view.get() {
view! {
<FileViewActions
view=v
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=true
on_close=close_file
toast=toast
/>
}
.into_view()
@@ -409,6 +416,7 @@ pub fn ShellView(
selected=selected
set_selected=set_selected
set_dialog=set_dialog
dialog=dialog
file_view=file_view
/>
}
@@ -446,6 +454,7 @@ pub fn ShellView(
selected=selected
set_selected=set_selected
set_dialog=set_dialog
dialog=dialog
file_view=file_view
/>
}