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
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/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>
}
}