Milestone 2: file browsing
Server:
- fs.rs: safe path resolution (canonicalize + containment check blocks
'..' and symlink escapes), directory listing with mtime, typed errors
- api/common.rs: AuthUser extractor (session cookie -> user + roots)
- api/files.rs: GET /api/files/{root_id} and /{root_id}/{*path} (dirs first)
Web (Leptos 0.8, hash routing):
- Browser view: breadcrumbs, grid/list toggle (persisted), file-type icons,
sizes and dates, empty-folder note, error card with retry
- Right-click context menu shell (entry + empty area); Open and Refresh
work, remaining items land in milestone 3+
- Router: #/r/{root_id}/{path...}, auto-enter for single-root users,
root picker when multiple roots exist
- Toast infrastructure for later milestones
Co-Authored-By: Qwen3.8 27bMCargo.lock
@@ -799,6 +799,18 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
@@ -2650,7 +2662,9 @@ dependencies = [
name = "web"
version = "0.1.0"
dependencies = [
"chrono",
"console_error_panic_hook",
"gloo-timers",
"js-sys",
"leptos",
"serde",
Aserver/src/api/common.rs
@@ -0,0 +1,59 @@
//! Authenticated-user extractor shared by all protected API routes.
use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use crate::auth::parse_session_cookie;
use crate::db::{RootRow, User};
use crate::error::{ApiError, AppState};
pub struct AuthUser {
#[allow(dead_code)] // used from milestone 3 onwards
pub user: User,
pub roots: Vec<RootRow>,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: HasState + Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = state.state();
let Some(token) = parse_session_cookie(&parts.headers) else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"not signed in",
));
};
let Some(user) = state.db.session_user(&token).await else {
return Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
));
};
let roots = state.db.user_roots(user.id).await;
Ok(AuthUser { user, roots })
}
}
/// Lets the extractor pull the concrete state type out of a generic `S`.
pub trait HasState {
fn state(&self) -> &AppState;
}
impl HasState for Arc<AppState> {
fn state(&self) -> &AppState {
self
}
}
impl HasState for AppState {
fn state(&self) -> &AppState {
self
}
}
Aserver/src/api/files.rs
@@ -0,0 +1,60 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use crate::api::common::AuthUser;
use crate::error::{ApiError, AppState};
use crate::fs;
/// GET /api/files/{root_id}/{*path}
///
/// List a directory inside one of the user's roots. The wildcard part is the
/// path relative to that root (percent-decoded by axum).
pub async fn list(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: Path<(i64, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (root_id, req_rel) = path.0;
list_inner(state, auth, root_id, req_rel).await
}
/// GET /api/files/{root_id} — list the root directory itself.
pub async fn list_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: Path<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
list_inner(state, auth, path.0, String::new()).await
}
async fn list_inner(
state: Arc<AppState>,
auth: AuthUser,
root_id: i64,
req_rel: String,
) -> Result<Json<serde_json::Value>, ApiError> {
let root = auth
.roots
.iter()
.find(|r| r.id == root_id)
.ok_or_else(|| {
crate::error::ApiError::new(
axum::http::StatusCode::FORBIDDEN,
"no such folder",
)
})?;
let server_root = state.root.clone();
let root_rel = root.path.clone();
let full = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_rel, &req_rel))
.await
.map_err(|_| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let entries = tokio::task::spawn_blocking(move || fs::list_dir(&full))
.await
.map_err(|_| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
Ok(Json(serde_json::json!({ "entries": entries })))
}
Mserver/src/api/mod.rs
@@ -6,6 +6,8 @@ use axum::Router;
use crate::error::AppState;
mod auth;
mod common;
mod files;
mod spa;
pub fn router(state: Arc<AppState>) -> Router {
@@ -14,6 +16,8 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me))
.route("/api/auth/setup", post(auth::setup))
.route("/api/files/{root_id}", get(files::list_root))
.route("/api/files/{root_id}/{*path}", get(files::list))
.fallback(spa::fallback)
.with_state(state)
}
Aserver/src/fs.rs
@@ -0,0 +1,130 @@
//! Safe filesystem access: every operation resolves
//! `<server-root>/<user-root>/<requested-path>`, canonicalizes it and verifies
//! the result is still inside the user's root (blocks `..` and symlink escapes).
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use chrono::DateTime;
use crate::error::ApiError;
#[derive(Debug, thiserror::Error)]
pub enum FsError {
#[error("folder not found")]
NotFound,
#[error("not a folder")]
NotADirectory,
#[error("access denied")]
Forbidden,
#[error("the configured folder no longer exists")]
RootMissing,
}
impl From<FsError> for ApiError {
fn from(e: FsError) -> Self {
use axum::http::StatusCode as S;
let status = match &e {
FsError::NotFound => S::NOT_FOUND,
FsError::NotADirectory => S::BAD_REQUEST,
FsError::Forbidden => S::FORBIDDEN,
FsError::RootMissing => S::NOT_FOUND,
};
ApiError::new(status, e.to_string())
}
}
/// Resolve a user root (path relative to the server root) to a canonical
/// absolute path, verified to be inside the server root.
pub fn resolve_root(server_root: &Path, root_rel: &str) -> Result<PathBuf, FsError> {
let candidate = server_root.join(root_rel);
let canonical = candidate
.canonicalize()
.map_err(|_| FsError::RootMissing)?;
ensure_within(server_root, &canonical)?;
if !canonical.is_dir() {
return Err(FsError::RootMissing);
}
Ok(canonical)
}
/// Resolve a requested path (relative to a user root) safely.
pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result<PathBuf, FsError> {
let root_abs = resolve_root(server_root, root_rel)?;
let req = Path::new(req_rel);
for c in req.components() {
if matches!(c, Component::ParentDir) {
return Err(FsError::Forbidden);
}
}
let full = root_abs.join(req);
let full = full
.canonicalize()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
ensure_within(&root_abs, &full)?;
Ok(full)
}
fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
if p == base || p.starts_with(base) {
Ok(())
} else {
Err(FsError::Forbidden)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: String,
}
/// List a directory (blocking — call via spawn_blocking).
pub fn list_dir(dir: &Path) -> Result<Vec<Entry>, FsError> {
let rd = std::fs::read_dir(dir).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
std::io::ErrorKind::NotADirectory => FsError::NotADirectory,
_ => FsError::Forbidden,
})?;
let mut entries = Vec::new();
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
// Follows symlinks; a broken link shows up as an empty file.
let meta = std::fs::metadata(e.path());
let (is_dir, size, mtime) = match meta {
Ok(m) => (m.is_dir(), m.len(), mtime_str(&m)),
Err(_) => (false, 0, "1970-01-01T00:00:00Z".to_string()),
};
entries.push(Entry {
name,
is_dir,
size,
mtime,
});
}
// Folders first, then case-insensitive name.
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
fn mtime_str(m: &std::fs::Metadata) -> String {
let dt: Option<DateTime<chrono::Utc>> = m
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, 0));
dt.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
}
Mserver/src/main.rs
@@ -4,6 +4,7 @@ mod auth;
mod cli;
mod db;
mod error;
mod fs;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
Mweb/Cargo.toml
@@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
chrono = "0.4"
console_error_panic_hook = "0.1"
gloo-timers = { version = "0.3", features = ["futures"] }
js-sys = "0.3"
leptos = { version = "0.8", features = ["csr"] }
serde = { version = "1", features = ["derive"] }
@@ -14,14 +16,18 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Document",
"HashChangeEvent",
"Headers",
"HtmlInputElement",
"KeyboardEvent",
"Location",
"MouseEvent",
"Navigator",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Storage",
"SubmitEvent",
"Window",
] }
Mweb/app.css
@@ -182,49 +182,296 @@ button:disabled {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
padding: 20px;
}
.welcome-card {
/* ---- file browser ---- */
.browser {
width: 100%;
max-width: 640px;
max-width: 1200px;
margin: 0 auto;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.welcome-card h1 {
margin: 0 0 8px;
font-size: 22px;
.icon {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
}
.welcome-card p {
margin: 0 0 16px;
font-size: 14px;
.ic-folder {
color: var(--accent);
}
.section-title {
.ic-muted {
color: var(--muted);
}
.crumbs {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 0 12px;
font-size: 15px;
margin: 0 0 4px;
flex-wrap: wrap;
}
.roots {
list-style: none;
margin: 0;
padding: 0;
.crumb {
cursor: pointer;
color: var(--muted);
padding: 3px 6px;
border-radius: 6px;
}
.roots li {
.crumb:hover {
color: var(--text);
background: var(--panel);
}
.crumb.current {
color: var(--text);
font-weight: 600;
cursor: default;
background: none;
}
.crumb-sep {
color: var(--muted);
padding: 0 1px;
}
.toolbar {
display: flex;
justify-content: flex-end;
gap: 6px;
padding-bottom: 12px;
}
.icon-btn {
padding: 7px 9px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-top: 1px solid var(--border);
}
.roots .root-name {
.icon-btn:hover {
border-color: var(--muted);
}
.icon-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.ic-btn {
width: 18px;
height: 18px;
color: inherit;
}
.entries-area {
flex: 1;
min-height: 240px;
}
.center-note {
text-align: center;
padding: 48px 0;
}
.error-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--danger);
font-size: 14px;
}
/* root picker */
.root-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.root-tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.root-tile:hover {
border-color: var(--muted);
}
.icon-big {
width: 44px;
height: 44px;
}
.root-tile-name {
font-weight: 600;
font-size: 15px;
}
.roots .muted {
.root-tile-path {
font-size: 13px;
flex: 1;
}
/* grid view */
.entries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 10px 12px;
text-align: center;
cursor: pointer;
}
.tile:hover {
border-color: var(--muted);
}
.tile-icon {
width: 42px;
height: 42px;
margin: 0 auto 10px;
display: block;
}
.tile-name {
font-size: 13px;
line-height: 1.3;
word-break: break-word;
}
/* list view */
.entries-list {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 90px 150px;
gap: 12px;
align-items: center;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 14px;
}
.row:last-child {
border-bottom: none;
}
.row:hover {
background: var(--bg);
}
.row-icon {
width: 20px;
height: 20px;
}
.row-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-size,
.row-date {
font-size: 13px;
text-align: right;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 28px minmax(0, 1fr) 90px;
}
.row-date {
display: none;
}
}
/* context menu */
.ctx-menu {
position: fixed;
z-index: 100;
min-width: 190px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.ctx-item {
padding: 8px 12px;
border-radius: 7px;
font-size: 14px;
cursor: pointer;
}
.ctx-item:hover {
background: var(--bg);
}
.ctx-item.disabled {
opacity: 0.45;
cursor: default;
}
.ctx-item.disabled:hover {
background: none;
}
/* toast */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 200;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 11px 16px;
font-size: 14px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
max-width: 320px;
}
Mweb/src/api.rs
@@ -38,6 +38,19 @@ pub struct RootInfo {
pub mode: String,
}
#[derive(Deserialize, Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub mtime: String,
}
#[derive(Deserialize)]
pub struct FilesResp {
pub entries: Vec<Entry>,
}
/// Acknowledges a successful 2xx response whose body we don't care about.
/// Accepts any JSON value (the server sends `{"ok": true}`).
pub struct OkResp;
@@ -56,7 +69,7 @@ struct CredentialsBody {
}
pub fn me() -> impl std::future::Future<Output = Result<Me, ApiError>> {
request("GET", "/api/auth/me", None::<()>)
request("GET", "/api/auth/me".to_string(), None::<()>)
}
pub fn login(
@@ -65,7 +78,7 @@ pub fn login(
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
"/api/auth/login",
"/api/auth/login".to_string(),
Some(CredentialsBody { name, password }),
)
}
@@ -76,18 +89,34 @@ pub fn setup(
) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request(
"POST",
"/api/auth/setup",
"/api/auth/setup".to_string(),
Some(CredentialsBody { name, password }),
)
}
pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request("POST", "/api/auth/logout", Some(()))
request("POST", "/api/auth/logout".to_string(), Some(()))
}
pub fn list_files(
root_id: i64,
path: &str,
) -> impl std::future::Future<Output = Result<FilesResp, ApiError>> {
let url = if path.is_empty() {
format!("/api/files/{root_id}")
} else {
let encoded: Vec<String> = path
.split('/')
.map(|s| js_sys::encode_uri_component(s).into())
.collect();
format!("/api/files/{root_id}/{}", encoded.join("/"))
};
request("GET", url, None::<()>)
}
async fn request<T: DeserializeOwned>(
method: &str,
url: &str,
url: String,
body: Option<impl Serialize>,
) -> Result<T, ApiError> {
let window =
@@ -107,7 +136,7 @@ async fn request<T: DeserializeOwned>(
}
let req =
web_sys::Request::new_with_str_and_init(url, &opts)
web_sys::Request::new_with_str_and_init(&url, &opts)
.map_err(|e| ApiError::Net(format!("{e:?}")))?;
let promise = window.fetch_with_request(&req);
let resp_val = JsFuture::from(promise)
Mweb/src/app.rs
@@ -2,6 +2,7 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::router::{parse_location, Location};
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum AuthPhase {
@@ -15,6 +16,17 @@ pub enum AuthPhase {
pub fn App() -> impl IntoView {
let (phase, set_phase) = signal(AuthPhase::Loading);
let (me, set_me) = signal(Option::<Me>::None);
let (loc, set_loc) = signal(Location::root());
// The URL hash is the source of truth for the location.
{
let set_loc = set_loc;
set_loc.set(parse_location());
let set_loc = set_loc;
let _h = window_event_listener(leptos::ev::hashchange, move |_| {
set_loc.set(parse_location());
});
}
// Initial /me probe: first boot, signed in, or needs login.
spawn_local(async move {
@@ -53,7 +65,12 @@ pub fn App() -> impl IntoView {
.into_view()
.into_any(),
AuthPhase::Authed => view! {
<crate::views::shell::ShellView me=me set_me=set_me set_phase=set_phase/>
<crate::views::shell::ShellView
me=me
set_me=set_me
set_phase=set_phase
loc=loc
/>
}
.into_view()
.into_any(),
Aweb/src/components/icon.rs
@@ -0,0 +1,115 @@
use leptos::prelude::*;
#[derive(Clone, Copy, PartialEq, Debug)]
// Several variants are used from later milestones (context menu icons).
#[allow(dead_code)]
pub enum IconName {
Folder,
File,
Image,
Video,
Audio,
Text,
Code,
Archive,
Pdf,
Grid,
List,
Refresh,
Upload,
FolderPlus,
Download,
Pencil,
Move,
Copy,
Share,
Info,
Trash,
}
/// Pick the icon for a file name based on its extension.
pub fn icon_for(name: &str, is_dir: bool) -> IconName {
if is_dir {
return IconName::Folder;
}
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => {
IconName::Image
}
"mp4" | "webm" | "mkv" | "mov" | "avi" => IconName::Video,
"mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" => IconName::Audio,
"pdf" => IconName::Pdf,
"zip" | "tar" | "gz" | "tgz" | "zst" | "bz2" | "xz" | "7z" | "rar" => {
IconName::Archive
}
"rs" | "js" | "ts" | "tsx" | "jsx" | "py" | "rb" | "go" | "c" | "cpp"
| "h" | "hpp" | "json" | "toml" | "yaml" | "yml" | "sh" | "html" | "css"
| "wasm" | "sql" => IconName::Code,
"txt" | "md" | "log" | "csv" | "ini" | "conf" | "xml" => IconName::Text,
_ => IconName::File,
}
}
fn icon_d(name: IconName) -> &'static str {
match name {
IconName::Folder => {
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
}
IconName::File => "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6",
IconName::Image => "M3 5h18v14H3z M10 10a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0z M21 15l-5-5-9 9",
IconName::Video => "M3 5h18v14H3z M10 9l5 3-5 3z",
IconName::Audio => {
"M9 18V6l10-2v10 M9 18a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z M19 15a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"
}
IconName::Text => "M5 3h14v18H5z M9 8h6 M9 12h6 M9 16h4",
IconName::Code => "M8 8l-4 4 4 4 M16 8l4 4-4 4",
IconName::Archive => "M4 4h16v16H4z M12 4v2 M12 8v2 M12 12v2 M9 16h6v4H9z",
IconName::Pdf => "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6 M8 13h8 M8 17h8",
IconName::Grid => "M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z",
IconName::List => "M4 6h2 M9 6h11 M4 12h2 M9 12h11 M4 18h2 M9 18h11",
IconName::Refresh => "M21 12a9 9 0 1 1-3-6.7 M21 3v6h-6",
IconName::Upload => "M12 16V4 M7 9l5-5 5 5 M4 20h16",
IconName::FolderPlus => {
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6"
}
IconName::Download => "M12 4v12 M7 11l5 5 5-5 M4 20h16",
IconName::Pencil => "M4 20l1-4L16 5l3 3L8 19z M13 8l3 3",
IconName::Move => "M20 7H6 M9 4L6 7l3 3 M4 17h14 M15 14l3 3-3 3",
IconName::Copy => "M8 8h12v12H8z M4 16V4h12",
IconName::Share => "M4 13v6a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-6 M12 3v11 M8 7l4-4 4 4",
IconName::Info => "M12 3a9 9 0 1 0 .01 0z M12 11v5 M12 8h.01",
IconName::Trash => "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v6 M14 11v6",
}
}
fn icon_class(name: IconName) -> &'static str {
match name {
IconName::Folder | IconName::FolderPlus => "ic-folder",
_ => "ic-muted",
}
}
#[component]
pub fn Icon(name: IconName, class: Option<String>) -> impl IntoView {
let mut cls = String::from("icon ");
cls.push_str(icon_class(name));
if let Some(extra) = class {
cls.push(' ');
cls.push_str(&extra);
}
view! {
<svg
class=cls
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke_width="1.8"
stroke_linecap="round"
stroke_linejoin="round"
aria-hidden="true"
>
<path d=icon_d(name)/>
</svg>
}
}
Aweb/src/components/toast.rs
@@ -0,0 +1,36 @@
use std::time::Duration;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
/// Context type: read/write access to the current toast message.
#[derive(Copy, Clone)]
pub struct ToastMsg(pub RwSignal<Option<String>>);
pub fn provide_toast() -> ToastMsg {
let toast = RwSignal::new(Option::<String>::None);
let t = ToastMsg(toast);
provide_context(t);
t
}
/// Show a toast that disappears after ~3 seconds.
pub fn show(toast: ToastMsg, msg: impl Into<String>) {
let msg = msg.into();
toast.0.set(Some(msg.clone()));
spawn_local(async move {
gloo_timers::future::sleep(Duration::from_secs(3)).await;
if toast.0.get().as_deref() == Some(msg.as_str()) {
toast.0.set(None);
}
});
}
#[component]
pub fn ToastView(toast: ToastMsg) -> impl IntoView {
view! {
<Show when=move || toast.0.get().is_some()>
<div class="toast">{move || toast.0.get().unwrap_or_default()}</div>
</Show>
}
}
Mweb/src/main.rs
@@ -1,5 +1,8 @@
mod api;
mod app;
mod components;
mod router;
mod util;
mod views;
fn main() {
Aweb/src/router.rs
@@ -0,0 +1,60 @@
//! Tiny hash-based router: `#/r/{root_id}/{segment/...}` (files) or `#/`
//! (root picker). The URL hash is the single source of truth; the
//! `Location` signal in the app mirrors it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Location {
/// None = the top-level "folders" view (root picker).
pub root_id: Option<i64>,
/// Path segments relative to the current root.
pub path: Vec<String>,
}
impl Location {
pub const fn root() -> Self {
Self {
root_id: None,
path: Vec::new(),
}
}
}
pub fn parse_location() -> Location {
let hash = web_sys::window()
.and_then(|w| w.location().hash().ok())
.unwrap_or_default();
let h = hash.trim_start_matches('#');
let h = h.trim_start_matches('/');
let mut parts = h.split('/').filter(|s| !s.is_empty());
match parts.next() {
Some("r") => match parts.next().and_then(|s| s.parse().ok()) {
Some(root_id) => Location {
root_id: Some(root_id),
path: parts.map(String::from).collect(),
},
None => Location::root(),
},
_ => Location::root(),
}
}
pub fn location_to_hash(loc: &Location) -> String {
match loc.root_id {
None => "#/".to_string(),
Some(id) => {
let mut s = format!("#/r/{id}");
for seg in &loc.path {
s.push('/');
s.push_str(seg);
}
s
}
}
}
/// Change the app location (updates the hash; the app listens for hashchange).
pub fn navigate(loc: &Location) {
if let Some(w) = web_sys::window() {
let _ = w.location().set_hash(&location_to_hash(loc));
}
}
Aweb/src/util.rs
@@ -0,0 +1,59 @@
//! Formatting + small browser helpers.
use chrono::{DateTime, Utc};
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = bytes as f64;
let mut i = 0;
while v >= 1024.0 && i < UNITS.len() - 1 {
v /= 1024.0;
i += 1;
}
if i == 0 {
format!("{bytes} B")
} else {
format!("{v:.1} {}", UNITS[i])
}
}
pub fn format_date(rfc3339: &str) -> String {
match DateTime::parse_from_rfc3339(rfc3339) {
Ok(dt) => dt.with_timezone(&Utc).format("%Y-%m-%d %H:%M").to_string(),
Err(_) => rfc3339.to_string(),
}
}
pub fn storage_get(key: &str) -> Option<String> {
let w = web_sys::window()?;
let storage_opt = w.local_storage().ok()?;
let storage = storage_opt.as_ref()?;
storage.get_item(key).ok().flatten()
}
pub fn storage_set(key: &str, value: &str) {
if let Some(s) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
let _ = s.set_item(key, value);
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ViewMode {
Grid,
List,
}
impl ViewMode {
const KEY: &'static str = "fbng.view";
pub fn load() -> Self {
match storage_get(Self::KEY).as_deref() {
Some("list") => ViewMode::List,
_ => ViewMode::Grid,
}
}
pub fn save(self) {
storage_set(Self::KEY, if self == ViewMode::List { "list" } else { "grid" });
}
}
Aweb/src/views/browser.rs
@@ -0,0 +1,533 @@
//! The file browser: breadcrumbs, grid/list views, root picker, context menu.
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::{icon_for, Icon, IconName};
use crate::components::toast::{show, ToastMsg};
use crate::router::{navigate, Location};
use crate::util::{format_date, format_size, ViewMode};
#[derive(Clone, Debug, PartialEq)]
enum ListState {
Loading,
Error(String),
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.
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 => None,
}
}
#[derive(Clone)]
struct CtxMenu {
x: i32,
y: i32,
/// None = empty area / root tile (no entry selected).
entry: Option<Entry>,
}
#[component]
pub fn Browser(
me: ReadSignal<Option<Me>>,
loc: ReadSignal<Location>,
) -> 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);
// Fetch the current directory whenever the location (or user) changes.
let fetch = Callback::new(move |_| {
let Some(me) = me.get() else {
return;
};
let loc = loc.get();
let Some(root) = effective_root(&me.roots, &loc) else {
return;
};
set_list_state.set(ListState::Loading);
let root_id = root.id;
let path_str = loc.path.join("/");
spawn_local(async move {
match api::list_files(root_id, &path_str).await {
Ok(r) => set_list_state.set(ListState::Entries(r.entries)),
Err(e) => set_list_state.set(ListState::Error(e.to_string())),
}
});
});
Effect::new(move |_| fetch.run(()));
view! {
<div class="browser">
{move || {
let Some(me) = me.get() else {
return view! {}.into_any();
};
let loc_now = loc.get();
match effective_root(&me.roots, &loc_now) {
None => root_picker(&me.roots, &set_ctx, &loc_now).into_any(),
Some(root) => file_browser(
root,
&loc,
&list_state,
&view_mode,
&set_view_mode,
&set_ctx,
&toast,
&fetch,
)
.into_any(),
}
}}
<CtxMenuView
ctx=ctx
set_ctx=set_ctx
loc=loc
refresh=fetch
/>
</div>
}
}
// ---------------------------------------------------------------------------
// Root picker (shown when the user has multiple roots and is at the top level)
// ---------------------------------------------------------------------------
fn root_picker(
roots: &[RootInfo],
set_ctx: &WriteSignal<Option<CtxMenu>>,
loc: &Location,
) -> impl IntoView {
view! {
<div class="crumbs">
<span class="crumb current">"Folders"</span>
</div>
<div class="root-picker">
{roots.iter().map(|r| {
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
let mode = r.mode.clone();
let set_ctx = *set_ctx;
let _loc = loc.clone();
view! {
<div
class="root-tile"
on:click=move |_| {
navigate(&Location {
root_id: Some(id),
path: vec![],
});
}
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
entry: None,
}));
}
>
<Icon name=IconName::Folder class=Some("icon-big".into())/>
<div class="root-tile-name">{name}</div>
<div class="root-tile-path muted">
{if path == "." { "/".to_string() } else { path.clone() }}
</div>
<span class="badge">
{if mode == "rw" { "read-write" } else { "read-only" }}
</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
}
// ---------------------------------------------------------------------------
// File browser (breadcrumbs + toolbar + entries)
// ---------------------------------------------------------------------------
fn file_browser(
root: &RootInfo,
loc: &ReadSignal<Location>,
list_state: &ReadSignal<ListState>,
view_mode: &ReadSignal<ViewMode>,
set_view_mode: &WriteSignal<ViewMode>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
fetch: &Callback<()>,
) -> impl IntoView {
let root_id = root.id;
let root_name = root.name.clone();
let loc_now = loc.get();
// Breadcrumbs
let crumbs = view! {
<div class="crumbs">
<span
class="crumb"
on:click=move |_| {
navigate(&Location {
root_id: Some(root_id),
path: vec![],
});
}
>
{root_name.clone()}
</span>
{loc_now.path.iter().enumerate().map(|(i, seg)| {
let seg = seg.clone();
let target: Vec<String> = loc_now.path[..=i].to_vec();
let is_last = i == loc_now.path.len() - 1;
view! {
<span class="crumb-sep">"/"</span>
<span
class=move || {
if is_last {
"crumb current".to_string()
} else {
"crumb".to_string()
}
}
on:click=move |_| {
navigate(&Location {
root_id: Some(root_id),
path: target.clone(),
});
}
>
{seg}
</span>
}
}).collect::<Vec<_>>()}
</div>
};
// Toolbar (view toggle)
let set_view = *set_view_mode;
let vm = *view_mode;
let toolbar = view! {
<div class="toolbar">
<button
class=move || {
if vm.get() == ViewMode::Grid {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
}
}
title="Grid view"
on:click=move |_| {
set_view.set(ViewMode::Grid);
ViewMode::Grid.save();
}
>
<Icon name=IconName::Grid class=Some("ic-btn".into())/>
</button>
<button
class=move || {
if vm.get() == ViewMode::List {
"icon-btn active".to_string()
} else {
"icon-btn".to_string()
}
}
title="List view"
on:click=move |_| {
set_view.set(ViewMode::List);
ViewMode::List.save();
}
>
<Icon name=IconName::List class=Some("ic-btn".into())/>
</button>
</div>
};
let ls = *list_state;
let loc2 = *loc;
let set_ctx2 = *set_ctx;
let t = *toast;
let refresh = *fetch;
let entries_area = view! {
<div
class="entries-area"
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx2.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
entry: None,
}));
}
>
{move || {
match ls.get() {
ListState::Loading => view! {
<p class="muted center-note">"Loading…"</p>
}
.into_view()
.into_any(),
ListState::Error(msg) => view! {
<div class="card error-card">
<span>{msg.clone()}</span>
<button class="btn" on:click=move |_| refresh.run(())>
"Retry"
</button>
</div>
}
.into_view()
.into_any(),
ListState::Entries(entries) => {
if entries.is_empty() {
view! { <p class="muted center-note">"This folder is empty."</p> }
.into_view()
.into_any()
} else if vm.get() == ViewMode::Grid {
grid_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any()
} else {
list_view(&entries, root_id, &loc2, &set_ctx2, &t).into_any()
}
}
}
}}
</div>
};
view! {
{crumbs}
{toolbar}
{entries_area}
}
}
fn entry_actions(
entry: Entry,
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
_toast: &ToastMsg,
) -> (impl Fn() + 'static, impl Fn(web_sys::MouseEvent) + 'static) {
let l = *loc;
let set_ctx = *set_ctx;
let entry2 = entry.clone();
let nav = move || {
let mut path = l.get().path;
path.push(entry.name.clone());
navigate(&Location {
root_id: Some(root_id),
path,
});
};
let ctx_handler = move |ev: MouseEvent| {
ev.prevent_default();
ev.stop_propagation();
set_ctx.set(Some(CtxMenu {
x: ev.client_x() as i32,
y: ev.client_y() as i32,
entry: Some(entry2.clone()),
}));
};
(nav, ctx_handler)
}
fn grid_view(
entries: &[Entry],
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
) -> impl IntoView {
view! {
<div class="entries-grid">
{entries.iter().map(|e| {
let icon = icon_for(&e.name, e.is_dir);
let name = e.name.clone();
let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
let t = *toast;
let is_dir = e.is_dir;
let title = name.clone();
let on_click = move |_| {
if is_dir {
nav();
} else {
show(t, "Previews are coming soon");
}
};
view! {
<div
class="tile"
on:click=on_click
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class=Some("tile-icon".into())/>
<div class="tile-name">{name}</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
}
fn list_view(
entries: &[Entry],
root_id: i64,
loc: &ReadSignal<Location>,
set_ctx: &WriteSignal<Option<CtxMenu>>,
toast: &ToastMsg,
) -> impl IntoView {
view! {
<div class="entries-list">
{entries.iter().map(|e| {
let icon = icon_for(&e.name, e.is_dir);
let name = e.name.clone();
let size = format_size(e.size);
let date = format_date(&e.mtime);
let (nav, on_ctx) = entry_actions(e.clone(), root_id, loc, set_ctx, toast);
let t = *toast;
let is_dir = e.is_dir;
let title = name.clone();
let on_click = move |_| {
if is_dir {
nav();
} else {
show(t, "Previews are coming soon");
}
};
view! {
<div class="row" on:click=on_click on:contextmenu=on_ctx title=title>
<Icon name=icon class=Some("row-icon".into())/>
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
<span class="row-date muted">{date}</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
}
// ---------------------------------------------------------------------------
// Context menu (M2 shell: only "Open" and "Refresh" work yet)
// ---------------------------------------------------------------------------
#[component]
fn CtxMenuView(
ctx: ReadSignal<Option<CtxMenu>>,
set_ctx: WriteSignal<Option<CtxMenu>>,
loc: ReadSignal<Location>,
refresh: Callback<()>,
) -> impl IntoView {
// Close on any click or Escape.
{
let set_ctx = set_ctx;
let _h1 = window_event_listener(leptos::ev::click, move |_| {
set_ctx.set(None);
});
let set_ctx2 = set_ctx;
let _h2 = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
set_ctx2.set(None);
}
});
}
view! {
{move || {
let Some(m) = ctx.get() else {
return view! {}.into_any();
};
// Keep the menu inside the viewport.
let (max_x, max_y) = web_sys::window()
.and_then(|w| {
w.inner_width()
.ok()
.and_then(|v| v.as_f64())
.zip(w.inner_height().ok().and_then(|v| v.as_f64()))
})
.map(|(x, y)| (x as i32 - 220, y as i32 - 300))
.unwrap_or((m.x, m.y));
let x = m.x.min(max_x).max(0);
let y = m.y.min(max_y).max(0);
let items: Vec<(&'static str, Option<Callback<()>>)> = match &m.entry {
Some(e) => {
let mut v: Vec<(&'static str, Option<Callback<()>>)> = vec![];
if e.is_dir {
let name = e.name.clone();
let l = loc;
v.push((
"Open",
Some(Callback::new(move |_| {
let cur = l.get();
let mut path = cur.path;
path.push(name.clone());
// Open relative to the same root.
navigate(&Location {
root_id: cur.root_id,
path,
});
})),
));
}
for label in [
"Download", "Rename", "Move", "Copy", "Share", "Info", "Delete",
] {
v.push((label, None));
}
v
}
None => vec![
("New folder", None),
("Upload", None),
("Refresh", Some(Callback::new(move |_| refresh.run(())))),
],
};
view! {
<div class="ctx-menu" style=format!("left:{x}px;top:{y}px")>
{items.into_iter().map(|(label, action)| {
view! {
<CtxItem label=label.to_string() action=action/>
}
}).collect::<Vec<_>>()}
</div>
}
.into_view()
.into_any()
}}
}
}
#[component]
fn CtxItem(label: String, action: Option<Callback<()>>) -> impl IntoView {
let enabled = action.is_some();
view! {
<div
class=move || {
if enabled {
"ctx-item".to_string()
} else {
"ctx-item disabled".to_string()
}
}
title=move || if enabled { String::new() } else { "Coming soon".to_string() }
on:click=move |_| {
if let Some(a) = &action {
a.run(());
}
}
>
{label}
</div>
}
}
Mweb/src/views/login.rs
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, input_value, Me};
use crate::app::AuthPhase;
use crate::router::{navigate, Location};
#[component]
pub fn LoginView(
@@ -27,6 +28,7 @@ pub fn LoginView(
match api::login(name, pass).await {
Ok(_) => match api::me().await {
Ok(m) => {
navigate(&Location::root());
set_me.set(Some(m));
set_phase.set(AuthPhase::Authed);
}
Mweb/src/views/mod.rs
@@ -1,3 +1,4 @@
pub mod browser;
pub mod login;
pub mod setup;
pub mod shell;
Mweb/src/views/setup.rs
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, input_value, Me};
use crate::app::AuthPhase;
use crate::router::{navigate, Location};
#[component]
pub fn SetupView(
@@ -39,6 +40,7 @@ pub fn SetupView(
match api::setup(name, pass).await {
Ok(_) => match api::me().await {
Ok(m) => {
navigate(&Location::root());
set_me.set(Some(m));
set_phase.set(AuthPhase::Authed);
}
Mweb/src/views/shell.rs
@@ -2,19 +2,24 @@ use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::app::AuthPhase;
use crate::components::toast::{provide_toast, ToastView};
use crate::router::Location;
use crate::views::browser::Browser;
#[component]
pub fn ShellView(
me: ReadSignal<Option<Me>>,
set_me: WriteSignal<Option<Me>>,
set_phase: WriteSignal<AuthPhase>,
set_phase: WriteSignal<crate::app::AuthPhase>,
loc: ReadSignal<Location>,
) -> impl IntoView {
let toast = provide_toast();
let on_logout = move |_| {
spawn_local(async move {
let _ = api::logout().await;
set_me.set(None);
set_phase.set(AuthPhase::Login);
set_phase.set(crate::app::AuthPhase::Login);
});
};
@@ -46,53 +51,9 @@ pub fn ShellView(
</div>
</header>
<main class="content">
<div class="card welcome-card">
{move || {
let Some(m) = me.get() else {
return None;
};
let u = m.user?;
Some(view! { "Welcome, " {u.name} "!" }.into_view().into_any())
}}
<p class="muted">
"You're signed in — auth, sessions and your configured
folders are working. The file browser itself arrives in
the next milestone."
</p>
<h2 class="section-title">"Your folders"</h2>
{move || {
let Some(m) = me.get() else {
return None;
};
Some(
view! {
<ul class="roots">
{m.roots.iter().map(|r| {
let name = r.name.clone();
let path = r.path.clone();
let mode = r.mode.clone();
view! {
<li>
<span class="root-name">{name}</span>
<span class="muted">{path}</span>
<span class="badge">
{if mode == "rw" {
"read-write"
} else {
"read-only"
}}
</span>
</li>
}
}).collect::<Vec<_>>()}
</ul>
}
.into_view()
.into_any(),
)
}}
</div>
<Browser me=me loc=loc/>
</main>
<ToastView toast=toast/>
</div>
}
}