UI: full-page file view with topbar header
Opened files no longer open in a modal. The top bar becomes the file header (name + dirty dot, Save for editable files, X to close) and the media/editor fills the whole content area; the sidebar is hidden while a file is open and the browser stays mounted underneath so its state and refresh callback survive. - preview.rs / editor.rs: PreviewModal -> Preview, EditorModal -> Editor (full page); the editor registers its save callback and its guarded close request (unsaved-changes prompt) with the top bar - new views/file_view.rs: FileView (Preview/Editor/Unsupported) plus the top-bar title/actions and content components - binary/unsupported files open the same full-page view with a "No preview available" state and a Download button instead of a toast - shell + share page own the file-view state and render it; layout gets a fixed-height "file-mode" so tall files scroll inside, not the page - CodeMirror now follows the app's forced theme (light/dark class on <html>), not just the OS setting Co-Authored-By: Qwen3.8 27b
Mweb/app.css
@@ -403,11 +403,23 @@ button:disabled {
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 20px;
}
/* While a file is open the app is a fixed-height frame: the file view
* (editor, image, …) scrolls inside, the page itself never grows. */
.layout.file-mode {
height: 100vh;
overflow: hidden;
}
.layout.file-mode .body-row {
min-height: 0;
}
/* Narrow screens: the sidebar becomes an off-canvas drawer that only
covers the content, below the (always visible) topbar. */
@media (max-width: 860px) {
@@ -836,51 +848,65 @@ button:disabled {
}
/* ---------------------------------------------------------------------------
* Preview modal (image / PDF / video / audio / text)
* Full-page file view (preview / editor / "no preview"). The top bar carries
* the title + actions; the content area below is edge to edge.
* ------------------------------------------------------------------------- */
.preview-modal {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
.content.file-open {
padding: 0;
overflow: hidden;
}
.preview-head {
/* The browser stays mounted (state, fetch callback) but hidden. */
.content.file-open .browser {
display: none;
}
.file-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.file-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
gap: 8px;
min-width: 0;
}
.file-title-icon {
width: 18px;
height: 18px;
flex: none;
color: var(--muted);
}
.preview-title {
.file-title-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-close {
flex: none;
display: inline-flex;
.file-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex: none;
}
.file-close {
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 4px;
padding: 6px 8px;
}
.preview-close:hover {
background: var(--bg);
.file-close:hover {
color: var(--text);
}
.ic-close {
width: 20px;
height: 20px;
background: var(--bg);
}
.preview-body {
flex: 1;
@@ -936,49 +962,31 @@ button:disabled {
}
/* ---------------------------------------------------------------------------
* Text editor (milestone 5)
* Text editor (full page; title/save/close live in the top bar)
* ------------------------------------------------------------------------- */
.editor-modal {
width: min(960px, calc(100vw - 48px));
height: min(720px, calc(100vh - 48px));
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.editor-title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex: none;
}
.editor-body {
.file-editor {
flex: 1;
min-height: 0;
display: flex;
position: relative;
background: var(--bg);
}
.editor-body .cm-container {
.file-editor .cm-container {
width: 100%;
height: 100%;
}
/* Conflict / discard dialogs render above the editor overlay. */
/* "No preview" fallback (binaries, archives). */
.file-nopreview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.file-nopreview .btn-primary {
width: auto;
margin-top: 14px;
}
/* Conflict / discard dialogs render above the file view. */
.editor-sub-overlay {
z-index: 101;
background: rgb(0 0 0 / 25%);
Mweb/cm/wrapper.js
@@ -72,9 +72,15 @@ function langExtension(langId) {
}
function isDark() {
return typeof window !== "undefined" &&
if (typeof window === "undefined") return false;
// The app's forced theme (class on <html>) wins over the OS setting.
const cls = document.documentElement.className;
if (cls.includes("dark")) return true;
if (cls.includes("light")) return false;
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
// A subtle theme that follows the app's light/dark look for plain text.
Mweb/src/editor.rs
@@ -1,18 +1,20 @@
//! Text editor (milestone 5): an editable CodeMirror with save, a dirty-state
//! indicator, Ctrl/Cmd+S, and save-time conflict handling (the file changed on
//! disk since it was opened).
//! Text editor: an editable CodeMirror with save, a dirty-state indicator,
//! Ctrl/Cmd+S, and save-time conflict handling (the file changed on disk
//! since it was opened).
//!
//! The editor fills the whole content area; its title, Save button and close
//! action live in the top bar (see `views::file_view`). The editor
//! registers its save callback via `register_save` and reports its
//! dirty/saving state through the shared signals.
use leptos::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
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)]
@@ -68,18 +70,27 @@ async fn do_save(
set_saving.set(false);
}
/// The editor overlay.
/// The full-page editor (no window chrome: the top bar carries the title,
/// save and close).
#[component]
pub fn EditorModal(
pub fn Editor(
target: EditTarget,
/// The editor stores its save callback here (top-bar Save button);
/// cleared on unmount. Absent in read-only mode.
register_save: WriteSignal<Option<Callback<()>>>,
/// The top bar's X button calls the registered close request (which
/// asks about unsaved changes first); cleared on unmount.
register_close: WriteSignal<Option<Callback<()>>>,
dirty: ReadSignal<bool>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
close: Callback<()>,
toast: ToastMsg,
/// Re-fetch the file list after a successful save.
refresh: Callback<()>,
) -> impl IntoView {
let node = NodeRef::<leptos::html::Div>::new();
let (status, set_status) = signal(Option::<String>::None);
let (dirty, set_dirty) = signal(false);
let (saving, set_saving) = signal(false);
let (conflict, set_conflict) = signal(false);
let (confirm_discard, set_confirm_discard) = signal(false);
// The mtime the loaded content was anchored to (for the conflict check).
@@ -90,7 +101,6 @@ pub fn EditorModal(
// counted as a user edit (keeps the dirty state honest).
let suppress = StoredValue::new(false);
let name = target.name.clone();
let ro = target.readonly;
// Load the file and create an editable CodeMirror.
@@ -173,6 +183,16 @@ pub fn EditorModal(
let save_cb: Callback<()> = make_save(false);
let overwrite_cb: Callback<()> = make_save(true);
// Expose the save callback to the top bar (read-only mode has none).
{
let rs = register_save;
let cb = save_cb;
Effect::new(move |_| {
rs.set(if ro { None } else { Some(cb) });
});
on_cleanup(move || rs.set(None));
}
// Load the latest on-disk version (discards local edits).
let load_latest_cb: Callback<()> = {
let t = target.clone();
@@ -212,6 +232,16 @@ pub fn EditorModal(
})
};
// Expose the guarded close request to the top bar's X button.
{
let rc = request_close;
let sig = register_close;
Effect::new(move |_| {
sig.set(Some(rc));
});
on_cleanup(move || sig.set(None));
}
// Ctrl/Cmd+S saves (scoped to the editor's lifetime); nothing to save
// in read-only mode.
if !ro {
@@ -251,65 +281,26 @@ pub fn EditorModal(
}
view! {
<div class="modal-overlay editor-overlay" on:click=move |_| request_close.run(())>
<div class="modal editor-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="editor-head">
<span class="editor-title">
{name}
{move || if dirty.get() { " •" } else { " " }}
</span>
<div class="editor-actions">
{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"
aria-label="Close"
on:click=move |_| request_close.run(())
>
<Icon name=IconName::Close class="ic-close".to_string()/>
</button>
</div>
</div>
<div class="editor-body">
<div node_ref=node class="cm-container cm-edit"></div>
{move || {
let err = status.get();
match err {
Some(e) => view! { <div class="cm-error">{e}</div> }
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
</div>
<div class="file-editor">
<div node_ref=node class="cm-container cm-edit"></div>
{move || {
let err = status.get();
match err {
Some(e) => view! { <div class="cm-error">{e}</div> }
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
}}
// Conflict: the file changed on disk since it was opened.
{move || {
if conflict.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="modal-overlay editor-sub-overlay" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"File changed on disk"</h2>
<p class="modal-message">
"This file was modified after you opened it. “Load latest” discards your unsaved changes; “Overwrite” keeps your version."
@@ -337,7 +328,7 @@ pub fn EditorModal(
if confirm_discard.get() {
view! {
<div class="modal-overlay editor-sub-overlay" on:click=move |_| set_confirm_discard.set(false)>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
<h2 class="modal-title">"Unsaved changes"</h2>
<p class="modal-message">"Discard your changes and close the editor?"</p>
<div class="modal-actions">
Mweb/src/preview.rs
@@ -1,13 +1,10 @@
//! File previews (milestone 4): browser-native media (image/PDF/video/audio).
//! File previews: browser-native media (image/PDF/video/audio), full page.
//! Text files open in the editor instead.
use api_types::FileKind;
use leptos::prelude::*;
use web_sys::MouseEvent;
use crate::api;
use crate::components::icon::Icon;
use crate::icons::IconName;
/// What kind of preview a file gets.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -20,7 +17,7 @@ pub enum PreviewKind {
/// Which viewer opens this entry, from the server's sniffed [`FileKind`].
/// `None` = nothing we can preview here: text goes to the editor, archives
/// and binaries are download-only.
/// and binaries get the "no preview" view.
pub fn preview_kind(kind: FileKind) -> Option<PreviewKind> {
match kind {
FileKind::Image => Some(PreviewKind::Image),
@@ -41,66 +38,39 @@ pub struct PreviewTarget {
pub name: String,
}
/// The preview overlay.
/// The full-page media preview (closing lives in the top bar).
#[component]
pub fn PreviewModal(
target: PreviewTarget,
kind: PreviewKind,
close: Callback<()>,
) -> impl IntoView {
pub fn Preview(target: PreviewTarget, kind: PreviewKind) -> impl IntoView {
let t = target;
let url = api::preview_url(t.root_id, &t.path);
let name = t.name.clone();
// Escape closes the preview.
{
let c = close;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
c.run(());
}
},
);
}
view! {
<div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
<div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="preview-head">
<span class="preview-title">{name}</span>
<button class="preview-close" title="Close" aria-label="Close" on:click=move |_| close.run(())>
<Icon name=IconName::Close class="ic-close".to_string()/>
</button>
</div>
<div class="preview-body">
{move || match kind {
PreviewKind::Image => {
view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Pdf => {
view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Video => {
view! { <video class="preview-video" src=url.clone() controls=true/> }
.into_view()
.into_any()
}
PreviewKind::Audio => {
view! {
<div class="preview-audio">
<audio src=url.clone() controls=true/>
</div>
}
.into_view()
.into_any()
}
}}
</div>
</div>
<div class="preview-body file-preview">
{move || match kind {
PreviewKind::Image => {
view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Pdf => {
view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
.into_view()
.into_any()
}
PreviewKind::Video => {
view! { <video class="preview-video" src=url.clone() controls=true/> }
.into_view()
.into_any()
}
PreviewKind::Audio => {
view! {
<div class="preview-audio">
<audio src=url.clone() controls=true/>
</div>
}
.into_view()
.into_any()
}
}}
</div>
}
}
Mweb/src/views/browser.rs
@@ -8,12 +8,13 @@ use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, EditorModal};
use crate::editor::EditTarget;
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind};
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::file_view::{FileView, UnsupportedTarget};
#[derive(Clone, Debug, PartialEq)]
enum ListState {
@@ -45,14 +46,17 @@ pub fn Browser(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
loc: ReadSignal<Location>,
/// Opens a file in the full-page view (owned by the shell / share page).
open_file: WriteSignal<Option<FileView>>,
/// 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<()>>>,
) -> 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);
let (preview, set_preview) = signal(Option::<(PreviewTarget, PreviewKind)>::None);
let (editor, set_editor) = signal(Option::<EditTarget>::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.
@@ -102,6 +106,13 @@ pub fn Browser(
});
});
Effect::new(move |_| fetch.run(()));
{
let w = set_browser_refresh;
let f = fetch;
Effect::new(move |_| {
w.set(Some(f));
});
}
view! {
<div class="browser">
@@ -121,8 +132,7 @@ pub fn Browser(
set_view_mode,
set_ctx,
fetch,
set_preview,
set_editor,
open_file,
)
.into_any(),
None => no_folder_view(&me.roots, &loc_now).into_any(),
@@ -136,46 +146,10 @@ pub fn Browser(
refresh=fetch
toast=toast
set_dialog=set_dialog
set_preview=set_preview
set_editor=set_editor
open_file=open_file
owner=me_owner
/>
<DialogView dialog=dialog set_dialog=set_dialog/>
{move || {
let p = preview.get();
match p {
Some((target, kind)) => view! {
<PreviewModal
target=target
kind=kind
close=Callback::new(move |_| set_preview.set(None))
/>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
}}
{move || {
let ed = editor.get();
match ed {
Some(t) => view! {
<EditorModal
target=t
close=Callback::new(move |_| set_editor.set(None))
toast=toast
refresh=fetch
/>
}
.into_view()
.into_any(),
None => {
view! {}.into_any()
},
}
}}
</div>
}
}
@@ -226,8 +200,7 @@ fn file_browser(
set_view_mode: WriteSignal<ViewMode>,
set_ctx: WriteSignal<Option<CtxMenu>>,
fetch: Callback<()>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
let root_id = root.id;
let root_name = root.name.clone();
@@ -363,9 +336,9 @@ fn file_browser(
.into_view()
.into_any()
} else if view_mode.get() == ViewMode::Grid {
grid_view(&entries, root_id, loc, is_rw, set_ctx, set_preview, set_editor).into_any()
grid_view(&entries, root_id, loc, is_rw, set_ctx, open_file).into_any()
} else {
list_view(&entries, root_id, loc, is_rw, set_ctx, set_preview, set_editor).into_any()
list_view(&entries, root_id, loc, is_rw, set_ctx, open_file).into_any()
}
}
}
@@ -405,9 +378,7 @@ fn open_entry(
root_id: i64,
loc: ReadSignal<Location>,
is_rw: bool,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
toast: ToastMsg,
open_file: WriteSignal<Option<FileView>>,
) -> Callback<()> {
let (name, is_dir, kind) = (entry.name.clone(), entry.is_dir, entry.kind);
Callback::new(move |_| {
@@ -425,26 +396,29 @@ fn open_entry(
}
let full = join_path(&loc.get().path, &name);
if kind == FileKind::Text {
set_editor.set(Some(EditTarget {
open_file.set(Some(FileView::Editor(EditTarget {
root_id,
path: full,
name: name.clone(),
readonly: !is_rw,
}));
})));
return;
}
match preview_kind(kind) {
Some(k) => {
set_preview.set(Some((
PreviewTarget {
root_id,
path: full,
name: name.clone(),
},
k,
)));
}
None => show(toast, "No preview available — right-click to download"),
Some(k) => open_file.set(Some(FileView::Preview(
PreviewTarget {
root_id,
path: full,
name: name.clone(),
},
k,
))),
None => open_file.set(Some(FileView::Unsupported(UnsupportedTarget {
root_id,
path: full,
name: name.clone(),
kind,
}))),
}
})
}
@@ -457,14 +431,10 @@ fn open_callback(
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
open_file: WriteSignal<Option<FileView>>,
) -> (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),
open_entry(entry, root_id, loc, is_rw, open_file),
ctx_menu_handler(entry.clone(), set_ctx),
)
}
@@ -475,8 +445,7 @@ fn grid_view(
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
view! {
<div class="entries-grid">
@@ -484,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, is_rw, set_ctx, set_preview, set_editor);
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;
view! {
@@ -516,8 +485,7 @@ fn list_view(
loc: ReadSignal<Location>,
is_rw: bool,
set_ctx: WriteSignal<Option<CtxMenu>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
open_file: WriteSignal<Option<FileView>>,
) -> impl IntoView {
view! {
<div class="entries-list">
@@ -528,7 +496,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, is_rw, set_ctx, set_preview, set_editor);
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;
view! {
@@ -569,8 +537,7 @@ fn CtxMenuView(
refresh: Callback<()>,
toast: ToastMsg,
set_dialog: WriteSignal<Option<Dialog>>,
set_preview: WriteSignal<Option<(PreviewTarget, PreviewKind)>>,
set_editor: WriteSignal<Option<EditTarget>>,
open_file: WriteSignal<Option<FileView>>,
owner: Owner,
) -> impl IntoView {
// Close on any click or Escape.
@@ -636,7 +603,7 @@ fn CtxMenuView(
IconName::Open
},
"Open",
open_entry(e, rid, loc, is_rw, set_preview, set_editor, toast),
open_entry(e, rid, loc, is_rw, open_file),
));
}
if !e.is_dir {
Aweb/src/views/file_view.rs
@@ -0,0 +1,218 @@
//! Full-page file view (opened files take over the whole content area).
//!
//! The top bar doubles as the file's header: the file name where the brand
//! was, with save/close on the right. Used by both the signed-in shell and
//! the public share page.
use api_types::FileKind;
use leptos::prelude::*;
use crate::api;
use crate::components::icon::Icon;
use crate::components::toast::ToastMsg;
use crate::editor::{EditTarget, Editor};
use crate::icons::{IconName, icon_for};
use crate::preview::{Preview, PreviewKind, PreviewTarget};
/// A file that is open in the full-page view.
#[derive(Clone)]
pub enum FileView {
/// Image / PDF / video / audio.
Preview(PreviewTarget, PreviewKind),
/// Text file; `EditTarget.readonly` decides whether it is editable.
Editor(EditTarget),
/// No built-in viewer for this kind: show a message + download button.
Unsupported(UnsupportedTarget),
}
/// A file the app cannot preview (binary, archive, ...).
#[derive(Clone)]
pub struct UnsupportedTarget {
pub root_id: i64,
/// Path relative to the root.
pub path: String,
/// Display name.
pub name: String,
/// Sniffed kind, for the icon.
pub kind: FileKind,
}
impl FileView {
pub fn name(&self) -> &str {
match self {
FileView::Preview(t, _) => &t.name,
FileView::Editor(t) => &t.name,
FileView::Unsupported(t) => &t.name,
}
}
/// The glyph shown next to the name in the top bar.
pub fn icon(&self) -> IconName {
match self {
FileView::Preview(_t, kind) => match kind {
PreviewKind::Image => IconName::Image,
PreviewKind::Pdf => IconName::Pdf,
PreviewKind::Video => IconName::Video,
PreviewKind::Audio => IconName::Audio,
},
FileView::Editor(t) => icon_for(FileKind::Text, &t.name),
FileView::Unsupported(t) => icon_for(t.kind, &t.name),
}
}
/// 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.
pub fn readonly(&self) -> bool {
matches!(self, FileView::Editor(t) if t.readonly)
}
}
/// Top-bar replacement for the brand: file icon + name (with a dirty dot)
/// and, for read-only text files, a badge.
#[component]
pub fn FileViewTitle(view: FileView, dirty: ReadSignal<bool>) -> impl IntoView {
let name = view.name().to_string();
let icon = view.icon();
let readonly = view.readonly();
view! {
<div class="file-title">
<Icon name=icon class="file-title-icon".to_string()/>
<span class="file-title-name">
{move || {
let dot = if dirty.get() { " •" } else { "" };
format!("{name}{dot}")
}}
</span>
{move || {
if readonly {
view! { <span class="badge">"Read-only"</span> }.into_view().into_any()
} else {
view! {}.into_any()
}
}}
</div>
}
}
/// Top-bar actions: Save (only present while an editable file has
/// registered its save callback) and close.
#[component]
pub fn FileViewActions(
saving: ReadSignal<bool>,
dirty: ReadSignal<bool>,
save_cb: ReadSignal<Option<Callback<()>>>,
/// The editor registers its close request here (it asks about unsaved
/// changes first); other views close directly.
close_cb: ReadSignal<Option<Callback<()>>>,
on_close: Callback<()>,
) -> impl IntoView {
view! {
<div class="file-actions">
{move || match save_cb.get() {
Some(cb) => view! {
<button
class="btn btn-sm btn-primary"
disabled=move || !dirty.get() || saving.get()
on:click=move |_| cb.run(())
>
{move || if saving.get() { "Saving…".to_string() } else { "Save".to_string() }}
</button>
}
.into_view()
.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(());
}
}
>
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
}
/// The full-page content: preview, editor, or the "no preview" fallback.
#[component]
pub fn FileViewContent(
view: FileView,
register_save: WriteSignal<Option<Callback<()>>>,
register_close: WriteSignal<Option<Callback<()>>>,
dirty: ReadSignal<bool>,
set_dirty: WriteSignal<bool>,
set_saving: WriteSignal<bool>,
close: Callback<()>,
toast: ToastMsg,
/// Re-fetch the (hidden, still mounted) browser after a save.
refresh: Callback<()>,
) -> impl IntoView {
match view {
FileView::Preview(target, kind) => view! {
<div class="file-view">
<Preview target=target kind=kind/>
</div>
}
.into_view()
.into_any(),
FileView::Editor(target) => view! {
<div class="file-view">
<Editor
target=target
register_save=register_save
register_close=register_close
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close
toast=toast
refresh=refresh
/>
</div>
}
.into_view()
.into_any(),
FileView::Unsupported(target) => view! {
<div class="file-view">
<NoPreview target=target/>
</div>
}
.into_view()
.into_any(),
}
}
/// Shown for files with no built-in viewer: a message and a download button.
#[component]
fn NoPreview(target: UnsupportedTarget) -> impl IntoView {
let icon = icon_for(target.kind, &target.name);
let name = target.name.clone();
let t = target;
view! {
<div class="file-nopreview">
<div class="empty-state">
<Icon name=icon class="empty-glyph".to_string()/>
<h3>"No preview available"</h3>
<p class="muted">{format!("The app can't display “{name}” here.")}</p>
<button
class="btn btn-primary"
on:click=move |_| {
let url = api::download_url(t.root_id, &t.path, None);
api::trigger_download(&url, &name);
}
>
<Icon name=IconName::Download class="ic-btn".to_string()/>
"Download"
</button>
</div>
</div>
}
}
Mweb/src/views/mod.rs
@@ -1,6 +1,7 @@
pub mod admin;
pub mod browser;
pub mod dialogs;
pub mod file_view;
pub mod login;
pub mod setup;
pub mod share_page;
Mweb/src/views/shell.rs
@@ -11,6 +11,7 @@ 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::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::shares::SharesView;
/// Whether the signed-in user has admin rights.
@@ -44,6 +45,47 @@ pub fn ShellView(
let toast = provide_toast();
// True while the off-canvas sidebar is open (narrow screens only).
let (nav_open, set_nav_open) = signal(false);
// A file opened in the full-page view (preview / editor / "no preview");
// the top bar doubles as its header.
let (file_view, set_file_view) = signal(Option::<FileView>::None);
// The open editor stores its save callback here for the top-bar button.
let (save_cb, set_save_cb) = signal(Option::<Callback<()>>::None);
// The open editor's guarded close request (unsaved-changes prompt).
let (close_cb, set_close_cb) = signal(Option::<Callback<()>>::None);
// The editor's state, shared with the top bar (dirty dot, Save button).
let (dirty, set_dirty) = signal(false);
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 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);
});
// Escape closes previews and the "no preview" view. The editor handles
// Escape itself (it asks first when there are unsaved changes).
{
let fv = file_view;
let cf = close_file;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() != "Escape" {
return;
}
if fv
.get()
.is_some_and(|v| matches!(v, FileView::Preview(..) | FileView::Unsupported(_)))
{
cf.run(());
}
},
);
}
// A non-admin cannot open the admin sections. If the URL points at one
// (deep link, hand-typed, or left over from a demotion), redirect to the
@@ -88,7 +130,15 @@ pub fn ShellView(
};
view! {
<div class="layout">
<div
class=move || {
if file_view.get().is_some() {
"layout file-mode".to_string()
} else {
"layout".to_string()
}
}
>
<header class="topbar">
<div class="topbar-left">
<button
@@ -99,11 +149,39 @@ pub fn ShellView(
>
<Icon name=IconName::Menu class="ic-btn".to_string()/>
</button>
<div class="brand">
<Logo/>
<span class="brand-name">"filebrowser-ng"</span>
</div>
{move || match file_view.get() {
Some(ref v) => view! {
<FileViewTitle view=v.clone() dirty=dirty/>
}
.into_view()
.into_any(),
None => view! {
<div class="brand">
<Logo/>
<span class="brand-name">"filebrowser-ng"</span>
</div>
}
.into_view()
.into_any(),
}}
</div>
{move || {
if file_view.get().is_some() {
view! {
<FileViewActions
saving=saving
dirty=dirty
save_cb=save_cb
close_cb=close_cb
on_close=close_file
/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</header>
{move || {
if nav_open.get() {
@@ -117,15 +195,18 @@ pub fn ShellView(
}
}}
<div class="body-row">
<aside
class=move || {
if nav_open.get() {
"sidebar open".to_string()
} else {
"sidebar".to_string()
}
}
>
{move || {
if file_view.get().is_none() {
view! {
<aside
class=move || {
if nav_open.get() {
"sidebar open".to_string()
} else {
"sidebar".to_string()
}
}
>
<nav class="nav">
{move || {
let Some(u) = me.get().and_then(|m| m.user) else {
@@ -236,45 +317,60 @@ pub fn ShellView(
</button>
</Show>
</div>
</nav>
<div class="sidebar-footer">
<button
class="nav-tab sidebar-action"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
next.store();
next.apply();
}
>
{move || {
view! {
<Icon name=theme.get().icon() class="ic-tab".to_string()/>
</nav>
<div class="sidebar-footer">
<button
class="nav-tab sidebar-action"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
next.store();
next.apply();
}
}}
<span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
</button>
<button
class="nav-tab sidebar-action logout"
title="Log out"
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-tab".to_string()/>
<span class="nav-tab-name">"Log out"</span>
</button>
<div class="sidebar-version">
{format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
>
{move || {
view! {
<Icon name=theme.get().icon() class="ic-tab".to_string()/>
}
}}
<span class="nav-tab-name">{move || theme.get().name().to_string()}</span>
</button>
<button
class="nav-tab sidebar-action logout"
title="Log out"
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-tab".to_string()/>
<span class="nav-tab-name">"Log out"</span>
</button>
<div class="sidebar-version">
{format!("filebrowser-ng v{}", env!("CARGO_PKG_VERSION"))}
</div>
</div>
</div>
</aside>
<main class="content">
</aside>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
<main class="content" class:file-open=move || file_view.get().is_some()>
{move || {
let admin = is_admin_of(&me.get());
match loc.get().section {
Section::Files => view! { <Browser me=me set_me=set_me loc=loc/> }
.into_view()
.into_any(),
Section::Files => view! {
<Browser
me=me
set_me=set_me
loc=loc
open_file=set_file_view
set_browser_refresh=set_browser_refresh
/>
}
.into_view()
.into_any(),
Section::Shares => view! { <SharesView/> }
.into_view()
.into_any(),
@@ -286,10 +382,41 @@ pub fn ShellView(
}
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! { <Browser me=me set_me=set_me loc=loc/> }
.into_view()
.into_any(),
_ => view! {
<Browser
me=me
set_me=set_me
loc=loc
open_file=set_file_view
set_browser_refresh=set_browser_refresh
/>
}
.into_view()
.into_any(),
}
}}
{move || match file_view.get() {
Some(v) => {
let refresh = browser_refresh
.get()
.unwrap_or_else(|| Callback::new(move |_| {}));
view! {
<FileViewContent
view=v
register_save=set_save_cb
register_close=set_close_cb
dirty=dirty
set_dirty=set_dirty
set_saving=set_saving
close=close_file
toast=toast
refresh=refresh
/>
}
.into_view()
.into_any()
}
None => view! {}.into_any(),
}}
</main>
</div>