Share page: drop badge/footer; file shares open straight into the viewer

- remove the "Shared: …" badge and the "Powered by" footer from the
  public share page
- a share of a single file no longer shows a download stub: it opens
  directly in the full-page view (editor / media preview / "no preview"
  + Download), with the file name in the top bar and no X button
  (there is nowhere to close back to); Escape does not leave the page
- "closing" such a file re-opens it, so an editor's discard prompt is
  still a real discard; folder shares keep their X (back to the listing)
- the resolve endpoint now reports the shared file's sniffed kind
  (ShareInfo.kind) so the client can pick the viewer without a round trip
  it cannot make
- make saving the shared file work: PUT on the bare root (empty path) is
  routed, and fs::save_file_at resolves a file share's target directly —
  its synthetic root is the file, not a directory
- editor load/save errors render as a card over a dimmed editor and can
  be dismissed with its X button or Escape

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commit34ecc18b2599c7f5490c539d84317932529b0d8c
Parent1ef7eb1
10 files changed, 246 insertions(+), 131 deletions(-)
▾Mapi-types/src/lib.rs
@@ -255,6 +255,9 @@ pub struct ShareInfo {
pub expires_at: Option<String>,
/// Synthetic root id to use in file API calls.
pub root_id: i64,
/// The file's kind for file shares (None for folder shares, and when
/// not sniffed — the public resolve endpoint fills it in).
pub kind: Option<FileKind>,
}
/// GET/POST `{ADMIN_USERS}`, PUT `{ADMIN_USERS}/{id}`.
▾Mserver/src/api/files.rs
@@ -277,6 +277,32 @@ pub async fn file_put(
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
let (root_id, req_rel) = path.0;
put_inner(state, auth, root_id, req_rel, query, headers, body).await
}
/// `PUT .../{root_id}?action=content` — the root item itself. Only reachable
/// for a *file* share (its root is the file); for folders it resolves to a
/// directory and is rejected below.
pub async fn file_put_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
put_inner(state, auth, path.0, String::new(), query, headers, body).await
}
async fn put_inner(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
query: AxumQuery<FileQuery>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Json<SaveResp>, ApiError> {
if query.action.as_deref() != Some(api_types::ACTION_CONTENT) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
@@ -294,10 +320,17 @@ pub async fn file_put(
.get("x-expected-mtime")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
// A file share's synthetic root *is* the file, so resolve it directly.
let share_target = auth
.share
.as_ref()
.filter(|s| s.is_file)
.map(|s| s.target.clone());
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel);
let content = body.to_vec();
let mtime = tokio::task::spawn_blocking(move || {
fs::save_file(&server_root, &root_rel, &rel, &content, expected)
let mtime = tokio::task::spawn_blocking(move || match share_target {
Some(target) => fs::save_file_at(&server_root, &target, &content, expected),
None => fs::save_file(&server_root, &root_rel, &rel, &content, expected),
})
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
▾Mserver/src/api/mod.rs
@@ -84,7 +84,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(AUTH_LOGOUT, post(auth::logout))
.route(AUTH_ME, get(auth::me))
.route(AUTH_SETUP, post(auth::setup))
.route(&files_root, get(files::list_root))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
.route(&files_item, put(files::file_put))
.route(&files_root, post(files::dispatch_root))
▾Mserver/src/api/shares.rs
@@ -36,6 +36,7 @@ fn share_info(row: &ShareRow, server_root: &Path) -> ShareInfo {
expires_at: row.expires_at.clone(),
// The synthetic root id to use in file API calls.
root_id: row.id,
kind: None,
}
}
@@ -134,5 +135,19 @@ pub async fn resolve(
if row.is_expired() {
return Err(ApiError::new(StatusCode::GONE, "this share has expired"));
}
Ok(Json(share_info(&row, &state.root)))
let mut info = share_info(&row, &state.root);
// A file share opens straight into the viewer, so the client needs the
// file's kind up front (it cannot list a file's "contents").
if row.is_file {
let (server_root, target) = (state.root.clone(), row.target.clone());
info.kind = tokio::task::spawn_blocking(move || {
fs::resolve_file(&server_root, &target)
.ok()
.map(|p| fs::detect_kind(&p, false))
})
.await
.ok()
.flatten();
}
Ok(Json(info))
}
▾Mserver/src/fs.rs
@@ -446,7 +446,24 @@ pub fn save_file(
expected_mtime: Option<i64>,
) -> Result<i64, FsError> {
let full = resolve_path(server_root, root_rel, req_rel)?; // must exist
let meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
write_checked(&full, content, expected_mtime)
}
/// The share-aware variant of [`save_file`]: for a *file* share the root is
/// the file itself, so `target` (relative to `server_root`) points at the
/// file — there is no directory root beneath it.
pub fn save_file_at(
server_root: &Path,
target: &str,
content: &[u8],
expected_mtime: Option<i64>,
) -> Result<i64, FsError> {
let full = resolve_file(server_root, target)?; // must exist
write_checked(&full, content, expected_mtime)
}
fn write_checked(full: &Path, content: &[u8], expected_mtime: Option<i64>) -> Result<i64, FsError> {
let meta = std::fs::metadata(full).map_err(|_| FsError::NotFound)?;
if meta.is_dir() {
return Err(FsError::NotADirectory);
}
@@ -456,9 +473,9 @@ pub fn save_file(
return Err(FsError::Conflict);
}
}
std::fs::write(&full, content).map_err(|e| io_err(e, &full))?;
std::fs::write(full, content).map_err(|e| io_err(e, full))?;
// Read the new mtime so the client can anchor the next conflict check.
let new_meta = std::fs::metadata(&full).map_err(|_| FsError::NotFound)?;
let new_meta = std::fs::metadata(full).map_err(|_| FsError::NotFound)?;
Ok(mtime_secs(&new_meta).unwrap_or(0))
}
▾Mweb/app.css
@@ -951,14 +951,56 @@ button:disabled {
.cm-container .cm-editor {
height: 100%;
}
/* Load/save failure: dims the editor and shows the message in a card
* (the top-bar Save button stays reachable to retry). */
.cm-error {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgb(0 0 0 / 45%);
}
.cm-error-card {
position: relative;
max-width: 440px;
min-width: 200px;
background: var(--panel);
border: 1px solid var(--border);
box-shadow: 0 12px 44px rgb(0 0 0 / 35%);
}
.cm-error-close {
position: absolute;
top: 7px;
right: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--muted);
padding: 3px;
}
.cm-error-close:hover {
color: var(--text);
background: var(--bg);
}
.cm-error-close .ic-btn {
width: 16px;
height: 16px;
}
.cm-error-text {
padding: 16px 40px 16px 20px;
font-size: 14px;
text-align: center;
overflow-wrap: anywhere;
}
/* ---------------------------------------------------------------------------
@@ -1171,57 +1213,6 @@ button:disabled {
flex-shrink: 0;
}
/* Public share page */
.share-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
padding: 4px 10px;
}
.ic-badge {
width: 14px;
height: 14px;
flex: none;
}
.share-footer {
padding: 16px;
text-align: center;
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.share-file-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 80px 20px;
text-align: center;
}
.share-file-glyph {
width: 64px;
height: 64px;
}
.share-file-page h2 {
margin: 0;
word-break: break-word;
}
.share-file-actions .btn-primary {
width: auto;
min-width: 140px;
}
/* ------------------------------------------------------------------ */
/* Admin (milestone 7) */
/* ------------------------------------------------------------------ */
▾Mweb/src/editor.rs
@@ -14,7 +14,9 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::icons::IconName;
/// A text file the user wants to edit.
#[derive(Clone)]
@@ -257,13 +259,15 @@ pub fn Editor(
);
}
// Escape: closes the unsaved-changes prompt if open, otherwise closes the
// editor (with the dirty guard). The conflict dialog is left alone — it
// requires an explicit choice.
// Escape: closes the unsaved-changes prompt if open, otherwise the error
// card if shown, otherwise closes the editor (with the dirty guard). The
// conflict dialog is left alone — it requires an explicit choice.
{
let cd = confirm_discard;
let scd = set_confirm_discard;
let cf = conflict;
let st = status;
let stx = set_status;
let rc = request_close;
crate::util::owned_window_listener(
leptos::ev::keydown,
@@ -273,6 +277,8 @@ pub fn Editor(
}
if cd.get() {
scd.set(false);
} else if st.get().is_some() {
stx.set(None);
} else if !cf.get() {
rc.run(());
}
@@ -286,9 +292,23 @@ pub fn Editor(
{move || {
let err = status.get();
match err {
Some(e) => view! { <div class="cm-error">{e}</div> }
.into_view()
.into_any(),
Some(e) => view! {
<div class="cm-error">
<div class="cm-error-card">
<button
class="cm-error-close"
title="Dismiss"
aria-label="Dismiss error"
on:click=move |_| set_status.set(None)
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
<div class="cm-error-text">{e}</div>
</div>
</div>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
▾Mweb/src/views/file_view.rs
@@ -105,6 +105,8 @@ pub fn FileViewActions(
/// The editor registers its close request here (it asks about unsaved
/// changes first); other views close directly.
close_cb: ReadSignal<Option<Callback<()>>>,
/// False for file shares, where there is nowhere to close back to.
show_close: bool,
on_close: Callback<()>,
) -> impl IntoView {
view! {
@@ -123,20 +125,22 @@ pub fn FileViewActions(
.into_any(),
None => view! {}.into_any(),
}}
<button
class="icon-btn file-close"
title="Close (Esc)"
aria-label="Close"
on:click=move |_| {
if let Some(cb) = close_cb.get() {
cb.run(());
} else {
on_close.run(());
<Show when=move || show_close>
<button
class="icon-btn file-close"
title="Close (Esc)"
aria-label="Close"
on:click=move |_| {
if let Some(cb) = close_cb.get() {
cb.run(());
} else {
on_close.run(());
}
}
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</Show>
</div>
}
}
▾Mweb/src/views/share_page.rs
@@ -1,16 +1,19 @@
//! Public share page (milestone 6): renders a shared item without login.
use api_types::FileKind;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, Mode, RootInfo, ShareInfo, UserInfo};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastView, provide_toast};
use crate::icons::IconName;
use crate::editor::EditTarget;
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::Location;
use crate::views::browser::Browser;
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::file_view::{
FileView, FileViewActions, FileViewContent, FileViewTitle, UnsupportedTarget,
};
#[derive(Clone, PartialEq)]
enum SharePageState {
@@ -20,6 +23,37 @@ enum SharePageState {
Active,
}
/// The full-page view for a shared *file*: the share page *is* the file.
/// The file sits at the (empty) path of its synthetic root.
fn file_view_for_share(i: &ShareInfo) -> Option<FileView> {
let kind = i.kind?;
let root_id = i.root_id;
let name = i.name.clone();
let path = String::new();
match kind {
FileKind::Text => Some(FileView::Editor(EditTarget {
root_id,
path,
name,
readonly: !i.writable,
})),
k if preview_kind(k).is_some() => Some(FileView::Preview(
PreviewTarget {
root_id,
path,
name,
},
preview_kind(k).unwrap(),
)),
k => Some(FileView::Unsupported(UnsupportedTarget {
root_id,
path,
name,
kind: k,
})),
}
}
#[component]
pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
let toast = provide_toast();
@@ -82,24 +116,44 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
let (browser_refresh, set_browser_refresh) = signal(Option::<Callback<()>>::None);
let close_file = Callback::new(move |_| {
set_file_view.set(None);
set_save_cb.set(None);
set_close_cb.set(None);
set_dirty.set(false);
set_saving.set(false);
// A folder share returns to the listing; a file share re-opens the
// file itself (the editor remounts, so a "discard" is a real one).
set_file_view.set(info.get().and_then(|i| file_view_for_share(&i)));
});
// Escape closes previews and the "no preview" view. The editor handles
// Escape itself (it asks first when there are unsaved changes).
// A file share opens straight into its viewer.
{
let info2 = info;
let set_fv = set_file_view;
Effect::new(move |_| {
if let Some(i) = info2.get()
&& i.is_file
{
set_fv.set(file_view_for_share(&i));
}
});
}
// Escape closes previews and the "no preview" view of a *folder* share.
// File shares have nothing to close back to; the editor handles Escape
// itself (it asks first when there are unsaved changes).
{
let fv = file_view;
let cf = close_file;
let info2 = info;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if info2.get().is_some_and(|i| i.is_file) {
return;
}
if fv
.get()
.is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
@@ -137,33 +191,26 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
.into_any(),
}}
<div class="topbar-right">
{move || match file_view.get() {
Some(_) => view! {
<FileViewActions
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
on_close=close_file
/>
}
.into_view()
.into_any(),
None => {
let name = info.get().map(|i| i.name.clone());
match name {
Some(n) => view! {
<span class="share-badge">
<Icon name=IconName::Share class="ic-badge".to_string()/>
{format!("Shared: {n}")}
</span>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
{move || {
if file_view.get().is_some() {
let show_close = info
.get()
.map(|i| !i.is_file)
.unwrap_or(true);
view! {
<FileViewActions
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=show_close
on_close=close_file
/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</div>
@@ -199,28 +246,13 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
view! {}.into_any()
};
};
// File shares render through the full-page file view
// (opened by the effect above); only folder shares
// get a browser.
if i.is_file {
let name = i.name.clone();
let rid = i.root_id;
view! {
<div class="share-file-page">
<Icon name=IconName::File class="share-file-glyph".to_string()/>
<h2>{name.clone()}</h2>
<div class="share-file-actions">
<button
class="btn btn-primary"
on:click=move |_| {
let url = api::download_url(rid, "", None);
api::trigger_download(&url, &name);
}
>
"Download"
</button>
</div>
</div>
}
.into_view()
.into_any()
view! {}
.into_view()
.into_any()
} else {
if me.get().is_none() {
return view! {
@@ -269,7 +301,6 @@ pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
None => view! {}.into_any(),
}}
</main>
<footer class="share-footer muted">"Powered by filebrowser-ng"</footer>
<ToastView toast=toast/>
</div>
}
▾Mweb/src/views/shell.rs
@@ -173,6 +173,7 @@ pub fn ShellView(
dirty=dirty
save_cb=save_cb
close_cb=close_cb
show_close=true
on_close=close_file
/>
}