M6: token-based shares (public + writable, expiry, management UI)

Shares are token-based links (`#/s/{token}`) that render a file browser
without login. A share points at a file or folder and is read-only or
read-write; read-write shares are gated by the `allow_writable_shares`
admin setting (off by default). Optional expiry uses relative presets or an
absolute datetime.

Backend
- shares table (created in M1) is now used; `ShareRow` + create/lookup/
  list/delete and an `is_expired()` helper (RFC 3339 expiry).
- Auth: a request carrying `?share=<token>` (or `X-Share-Token`) resolves to a
  synthetic single-root AuthUser scoped to the shared item, so all existing
  file ops (list/download/preview/content/mkdir/rename/move/copy/delete/
  upload/save) work unchanged and stay within the shared path. Checked before
  the session so a signed-in user viewing a share sees the shared scope.
- A file share's synthetic root *is* the file: `list_root` became action-aware
  and `resolve_item`/`fs::resolve_file` handle it (single-file download/
  preview/content).
- API: `GET/POST /api/shares`, `DELETE /api/shares/{id}` (owner), and public
  `GET /api/share/{token}` (404 invalid / 410 expired). Writable creation is
  rejected (403) when the setting is off. `/me` now returns
  `allow_writable_shares`.

Frontend
- Router: `Location.share_token` + `#/s/{token}/{path}`; in-share navigation
  and breadcrumbs preserve the token, and deep links reload at the right spot.
- All file API calls append `?share=<token>` while a share is shown.
- Context-menu "Share" (hidden inside shares) opens a dialog: optional
  read-write toggle, expiry (never/1h/1d/1w/1mo/custom datetime), then shows
  the link with copy-to-clipboard.
- "Shares" header button opens a management panel (list, copy, delete).
- Public share page: file shares show a download page; folder shares reuse the
  browser; invalid/expired links show dedicated messages. No login/logout.

Verified in browser: create (file + folder, writable + read-only, expiry),
management list/copy/delete, file & folder share pages, in-share navigation +
deep-link reload, writable mkdir, read-only item disabling, preview, single-file
and archive downloads, and expired/not-found states.

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commit5b02f1d6010b04e8c416c20a5d84624459fb7b55
Parent9fdfb08
19 files changed, 1364 insertions(+), 52 deletions(-)
▾Mserver/src/api/auth.rs
@@ -29,7 +29,8 @@ pub async fn me(
return Ok(Json(serde_json::json!({
"first_boot": true,
"user": null,
"roots": []
"roots": [],
"allow_writable_shares": false
})));
}
@@ -68,7 +69,8 @@ pub async fn me(
"name": user.name,
"is_admin": user.is_admin,
},
"roots": roots
"roots": roots,
"allow_writable_shares": state.db.allow_writable_shares().await,
})))
}
▾Mserver/src/api/common.rs
@@ -7,13 +7,17 @@ use axum::http::request::Parts;
use axum::http::StatusCode;
use crate::auth::parse_session_cookie;
use crate::db::{RootRow, User};
use crate::db::{RootRow, ShareRow, User};
use crate::error::{ApiError, AppState};
pub struct AuthUser {
#[allow(dead_code)] // used from milestone 3 onwards
pub user: User,
pub roots: Vec<RootRow>,
/// Present when authenticated via a public share token. The single entry
/// in `roots` is the shared item (its path is the share's `target`), so all
/// file operations are scoped to it.
#[allow(dead_code)]
pub share: Option<ShareRow>,
}
impl<S> FromRequestParts<S> for AuthUser
@@ -24,21 +28,81 @@ where
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 })
// 1. Public share token (`?share=<token>` or `X-Share-Token`). Checked
// first: a request that explicitly carries a share token is a share
// request, even if a session is also present (so a signed-in user
// viewing a share link sees the shared scope).
if let Some(share_token) = share_token_from_request(parts) {
return match state.db.share_by_token(&share_token).await {
Some(share) if !share.is_expired() => {
let roots = vec![RootRow {
id: share.id,
path: share.target.clone(),
mode: share.mode.clone(),
}];
let user = User {
id: share.creator_id,
name: "shared".to_string(),
is_admin: false,
};
Ok(AuthUser {
user,
roots,
share: Some(share),
})
}
Some(_) => Err(ApiError::new(
StatusCode::GONE,
"this share has expired",
)),
None => Err(ApiError::new(
StatusCode::NOT_FOUND,
"share not found",
)),
};
}
// 2. Signed-in session.
if let Some(token) = parse_session_cookie(&parts.headers) {
return match state.db.session_user(&token).await {
Some(user) => {
let roots = state.db.user_roots(user.id).await;
Ok(AuthUser {
user,
roots,
share: None,
})
}
None => Err(ApiError::new(
StatusCode::UNAUTHORIZED,
"session expired, please sign in again",
)),
};
}
Err(ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"))
}
}
/// Extract the share token from a request, if present: a `?share=<token>`
/// query param or an `X-Share-Token` header.
fn share_token_from_request(parts: &Parts) -> Option<String> {
if let Some(q) = parts.uri.query() {
for pair in q.split('&') {
if let Some((k, v)) = pair.split_once('=') {
if k == "share" && !v.is_empty() {
return Some(v.to_string());
}
}
}
}
parts
.headers
.get("x-share-token")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
/// Lets the extractor pull the concrete state type out of a generic `S`.
▾Mserver/src/api/files.rs
@@ -27,7 +27,7 @@ use tokio::sync::mpsc;
use crate::api::common::AuthUser;
use crate::archive::{self, ArchiveFormat};
use crate::db::RootRow;
use crate::db::{RootRow, ShareRow};
use crate::error::{ApiError, AppState};
use crate::fs::{self, FsError};
@@ -86,13 +86,27 @@ pub async fn file_get(
}
}
/// GET /api/files/{root_id} — list the root directory itself.
/// GET /api/files/{root_id} — list the root directory itself, or serve the
/// root item via `?action=download|preview|content` (the root of a *file*
/// share is the file itself).
pub async fn list_root(
State(state): State<Arc<AppState>>,
auth: AuthUser,
path: AxumPath<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
list_inner(state, auth, path.0, String::new()).await
query: AxumQuery<FileQuery>,
) -> Result<Response, ApiError> {
let root_id = path.0;
match query.action.as_deref() {
Some("download") => {
download(state, auth, root_id, String::new(), query.format.as_deref()).await
}
Some("preview") => preview(state, auth, root_id, String::new()).await,
Some("content") => content(state, auth, root_id, String::new()).await,
_ => {
let json = list_inner(state, auth, root_id, String::new()).await?;
Ok(json.into_response())
}
}
}
async fn list_inner(
@@ -132,10 +146,16 @@ async fn resolve_item(
state: &AppState,
root: &RootRow,
req_rel: &str,
share: Option<&ShareRow>,
) -> Result<(std::path::PathBuf, String, bool, u64), ApiError> {
let (server_root, root_rel, rel) = (state.root.clone(), root.path.clone(), req_rel.to_string());
// A file share's synthetic root *is* the file, so resolve it directly.
let share_target = share.filter(|s| s.is_file).map(|s| s.target.clone());
let inner = tokio::task::spawn_blocking(move || {
let full = fs::resolve_path(&server_root, &root_rel, &rel)?;
let full = match share_target {
Some(target) => fs::resolve_file(&server_root, &target)?,
None => fs::resolve_path(&server_root, &root_rel, &rel)?,
};
let name = full
.file_name()
.map(|n| n.to_string_lossy().into_owned())
@@ -158,7 +178,8 @@ async fn download(
format: Option<&str>,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
let (full, name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if !is_dir {
return file_response(&full, &name, size, false).await;
@@ -192,7 +213,8 @@ async fn preview(
req_rel: String,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
let (full, name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
}
@@ -208,7 +230,8 @@ async fn content(
req_rel: String,
) -> Result<Response, ApiError> {
let root = find_root(&auth.roots, root_id)?;
let (full, _name, is_dir, size) = resolve_item(&state, root, &req_rel).await?;
let (full, _name, is_dir, size) =
resolve_item(&state, root, &req_rel, auth.share.as_ref()).await?;
if is_dir {
return Err(ApiError::new(StatusCode::BAD_REQUEST, "not a file"));
}
▾Mserver/src/api/mod.rs
@@ -8,6 +8,7 @@ use crate::error::AppState;
mod auth;
mod common;
mod files;
mod shares;
mod spa;
pub fn router(state: Arc<AppState>) -> Router {
@@ -22,6 +23,10 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/files/{root_id}", post(files::dispatch_root))
.route("/api/files/{root_id}/{*path}", post(files::dispatch))
.route("/api/files/{root_id}/{*path}", delete(files::delete))
.route("/api/shares", get(shares::list))
.route("/api/shares", post(shares::create))
.route("/api/shares/{id}", delete(shares::delete))
.route("/api/share/{token}", get(shares::resolve))
.fallback(spa::fallback)
.with_state(state)
}
▾Aserver/src/api/shares.rs
@@ -0,0 +1,160 @@
//! Share management (milestone 6).
//!
//! Authenticated (management):
//! - `GET /api/shares` — list the current user's shares
//! - `POST /api/shares` — create a share
//! - `DELETE /api/shares/{id}` — delete one of the current user's shares
//!
//! Public (no login; resolved by token):
//! - `GET /api/share/{token}` — resolve a share for the share page
use std::path::Path;
use std::sync::Arc;
use axum::extract::{Path as AxumPath, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use crate::api::common::AuthUser;
use crate::auth;
use crate::db::ShareRow;
use crate::error::{ApiError, AppState};
use crate::fs;
/// `POST /api/shares` body.
#[derive(Deserialize)]
pub struct CreateBody {
root_id: i64,
/// Item path relative to the root ("" or "." for the root itself).
path: String,
#[serde(default)]
writable: bool,
/// Absolute expiry as RFC 3339, or null for "never".
#[serde(default)]
expires_at: Option<String>,
}
/// Shared JSON shape for a share (list / create / public resolve).
fn share_json(row: &ShareRow, server_root: &Path) -> serde_json::Value {
serde_json::json!({
"id": row.id,
"token": row.token,
"name": share_name(server_root, &row.target),
"is_file": row.is_file,
"writable": row.mode == "rw",
"target": row.target,
"created_at": row.created_at,
"expires_at": row.expires_at,
// The synthetic root id to use in file API calls.
"root_id": row.id,
})
}
/// Display name for a share target: the folder/file name, or the server root's
/// own name when the target is the whole root (".").
fn share_name(server_root: &Path, target: &str) -> String {
let name = if target == "." {
server_root.file_name()
} else {
Path::new(target)
.file_name()
.filter(|_| !Path::new(target).as_os_str().is_empty())
};
name.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| target.to_string())
}
/// GET /api/shares — list the current user's shares.
pub async fn list(
State(state): State<Arc<AppState>>,
auth: AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let rows = state.db.user_shares(auth.user.id).await;
let values: Vec<serde_json::Value> = rows
.iter()
.map(|r| share_json(r, &state.root))
.collect();
Ok(Json(serde_json::json!(values)))
}
/// POST /api/shares — create a share.
pub async fn create(
State(state): State<Arc<AppState>>,
auth: AuthUser,
Json(body): Json<CreateBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
if body.writable && !state.db.allow_writable_shares().await {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"writable shares are disabled",
));
}
let root = auth
.roots
.iter()
.find(|r| r.id == body.root_id)
.ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?;
// Resolve the target to a safe absolute path, then re-express it relative
// to the server root (the stored `target`).
let server_root = state.root.clone();
let root_path = root.path.clone();
let req = body.path.trim().to_string();
let req = if req.is_empty() { ".".to_string() } else { req };
let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
.await
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
let target = abs
.strip_prefix(&state.root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| ".".to_string());
let is_file = abs.is_file();
let token = auth::share_token();
let mode = if body.writable { "rw" } else { "ro" };
let row = state
.db
.create_share(
auth.user.id,
&token,
&target,
is_file,
mode,
body.expires_at.as_deref(),
)
.await?;
Ok(Json(share_json(&row, &state.root)))
}
/// DELETE /api/shares/{id} — delete one of the current user's shares.
pub async fn delete(
State(state): State<Arc<AppState>>,
auth: AuthUser,
AxumPath(id): AxumPath<i64>,
) -> Result<Json<serde_json::Value>, ApiError> {
if !state.db.delete_share(id, auth.user.id).await {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
}
Ok(Json(serde_json::json!({ "ok": true })))
}
/// GET /api/share/{token} — public resolve for the share page.
pub async fn resolve(
State(state): State<Arc<AppState>>,
AxumPath(token): AxumPath<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let Some(row) = state.db.share_by_token(&token).await else {
return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
};
if row.is_expired() {
return Err(ApiError::new(
StatusCode::GONE,
"this share has expired",
));
}
Ok(Json(share_json(&row, &state.root)))
}
▾Mserver/src/auth.rs
@@ -22,11 +22,20 @@ pub fn verify_password(password: &str, hash: &str) -> bool {
/// 32 random bytes, hex-encoded (64 chars).
pub fn random_token() -> String {
hex_token(32)
}
/// 16 random bytes, hex-encoded (32 chars). Used for public share links.
pub fn share_token() -> String {
hex_token(16)
}
fn hex_token(bytes: usize) -> String {
use rand::RngCore;
let mut b = [0u8; 32];
rand::thread_rng().fill_bytes(&mut b);
let mut s = String::with_capacity(64);
for x in b {
let mut b = [0u8; 64];
rand::thread_rng().fill_bytes(&mut b[..bytes.min(64)]);
let mut s = String::with_capacity(bytes * 2);
for x in b[..bytes.min(64)].iter() {
s.push_str(&format!("{x:02x}"));
}
s
▾Mserver/src/db.rs
@@ -21,6 +21,31 @@ pub struct RootRow {
pub mode: String,
}
#[derive(Debug, Clone)]
pub struct ShareRow {
pub id: i64,
pub token: String,
pub creator_id: i64,
/// Path of the shared item relative to the server root.
pub target: String,
pub is_file: bool,
/// "rw" or "ro"
pub mode: String,
pub created_at: String,
pub expires_at: Option<String>,
}
impl ShareRow {
pub fn is_expired(&self) -> bool {
match &self.expires_at {
Some(e) => chrono::DateTime::parse_from_rfc3339(e)
.map(|t| chrono::Utc::now() >= t.with_timezone(&chrono::Utc))
.unwrap_or(false),
None => false,
}
}
}
#[derive(Clone)]
pub struct Db(Arc<tokio::sync::Mutex<Connection>>);
@@ -185,10 +210,65 @@ impl Db {
out
}
// ---------- shares ----------
pub async fn create_share(
&self,
creator_id: i64,
token: &str,
target: &str,
is_file: bool,
mode: &str,
expires_at: Option<&str>,
) -> Result<ShareRow, rusqlite::Error> {
let c = self.0.lock().await;
c.execute(
"INSERT INTO shares (token, creator_id, target, is_file, mode, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![token, creator_id, target, is_file as i64, mode, now(), expires_at],
)?;
let id = c.last_insert_rowid();
Ok(ShareRow {
id,
token: token.to_string(),
creator_id,
target: target.to_string(),
is_file,
mode: mode.to_string(),
created_at: now(),
expires_at: expires_at.map(|s| s.to_string()),
})
}
pub async fn share_by_token(&self, token: &str) -> Option<ShareRow> {
let c = self.0.lock().await;
c.query_row(&*SHARE_BY_TOKEN, [token], map_share).ok()
}
pub async fn user_shares(&self, creator_id: i64) -> Vec<ShareRow> {
let c = self.0.lock().await;
let mut out = Vec::new();
if let Ok(mut stmt) = c.prepare(&*USER_SHARES) {
if let Ok(rows) = stmt.query_map([creator_id], map_share) {
out.extend(rows.flatten());
}
}
out
}
pub async fn delete_share(&self, id: i64, creator_id: i64) -> bool {
let c = self.0.lock().await;
c.execute(
"DELETE FROM shares WHERE id = ?1 AND creator_id = ?2",
params![id, creator_id],
)
.ok()
.map(|n| n > 0)
.unwrap_or(false)
}
// ---------- settings ----------
// Used from later milestones (shares/admin settings).
#[allow(dead_code)]
pub async fn get_setting(&self, key: &str) -> Option<String> {
let c = self.0.lock().await;
c.query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| {
@@ -207,6 +287,34 @@ impl Db {
)?;
Ok(())
}
/// Whether users may create writable (read-write) shares. Off by default;
/// the admin setting gates it.
pub async fn allow_writable_shares(&self) -> bool {
self.get_setting("allow_writable_shares").await.as_deref() == Some("1")
}
}
const SHARE_COLS: &str =
"id, token, creator_id, target, is_file, mode, created_at, expires_at";
const SHARE_BY_TOKEN: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| format!("SELECT {SHARE_COLS} FROM shares WHERE token = ?1"));
const USER_SHARES: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
format!("SELECT {SHARE_COLS} FROM shares WHERE creator_id = ?1 ORDER BY id DESC")
});
fn map_share(r: &rusqlite::Row) -> rusqlite::Result<ShareRow> {
Ok(ShareRow {
id: r.get(0)?,
token: r.get(1)?,
creator_id: r.get(2)?,
target: r.get(3)?,
is_file: r.get::<_, i64>(4)? != 0,
mode: r.get(5)?,
created_at: r.get(6)?,
expires_at: r.get(7)?,
})
}
fn now() -> String {
▾Mserver/src/fs.rs
@@ -74,6 +74,21 @@ pub fn resolve_path(server_root: &Path, root_rel: &str, req_rel: &str) -> Result
Ok(full)
}
/// Resolve a share target that is a single file (relative to the server root).
/// Unlike [`resolve_path`], the target itself is the file — there is no
/// directory root beneath it.
pub fn resolve_file(server_root: &Path, rel: &str) -> Result<PathBuf, FsError> {
let full = server_root.join(rel);
let full = full
.canonicalize()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => FsError::NotFound,
_ => FsError::Forbidden,
})?;
ensure_within(server_root, &full)?;
Ok(full)
}
fn ensure_within(base: &Path, p: &Path) -> Result<(), FsError> {
if p == base || p.starts_with(base) {
Ok(())
▾Mweb/Cargo.toml
@@ -16,6 +16,7 @@ wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"Blob",
"Clipboard",
"Document",
"Element",
"EventTarget",
@@ -27,6 +28,7 @@ web-sys = { version = "0.3", features = [
"HtmlDivElement",
"HtmlElement",
"HtmlInputElement",
"HtmlSelectElement",
"KeyboardEvent",
"Location",
"MouseEvent",
▾Mweb/app.css
@@ -492,6 +492,10 @@ button:disabled {
width: 440px;
max-width: calc(100vw - 32px);
padding: 20px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 44px rgb(0 0 0 / 35%);
}
.modal.picker {
@@ -775,3 +779,169 @@ button:disabled {
margin: 0;
word-break: break-word;
}
/* ---------------------------------------------------------------------------
Shares (milestone 6)
--------------------------------------------------------------------------- */
.share-modal {
width: 480px;
}
.share-link-row {
display: flex;
gap: 8px;
margin: 12px 0;
}
.share-link-input {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 12.5px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
min-width: 0;
}
.share-check {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0 14px;
font-size: 14px;
}
.share-check input {
width: 16px;
height: 16px;
}
.share-expiry-row {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
}
.share-expiry-row label {
font-size: 13px;
color: var(--muted);
min-width: 52px;
}
.share-expiry-row select,
.share-expiry-row input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.share-expiry-note {
margin: 4px 0 0;
font-size: 12.5px;
}
.btn-sm {
padding: 5px 10px;
font-size: 12.5px;
}
/* "Your shares" panel */
.shares-panel {
width: 560px;
}
.share-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 60vh;
overflow-y: auto;
margin-bottom: 4px;
}
.share-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
}
.share-item-main {
min-width: 0;
}
.share-item-name {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-item-meta {
font-size: 12px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-item-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* Public share page */
.share-badge {
font-size: 13px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 999px;
}
.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-icon {
font-size: 64px;
}
.share-file-page h2 {
margin: 0;
word-break: break-word;
}
.share-file-actions .btn-primary {
width: auto;
min-width: 140px;
}
▾Mweb/src/api.rs
@@ -29,6 +29,14 @@ impl ApiError {
_ => None,
}
}
/// The HTTP status code, when this was an HTTP (non-2xx) error.
pub fn status(&self) -> Option<u16> {
match self {
ApiError::Http { status, .. } => Some(*status),
_ => None,
}
}
}
#[derive(Deserialize, Clone)]
@@ -38,6 +46,9 @@ pub struct Me {
pub user: Option<UserInfo>,
#[serde(default)]
pub roots: Vec<RootInfo>,
/// Whether the server allows users to create writable (read-write) shares.
#[serde(default)]
pub allow_writable_shares: bool,
}
#[derive(Deserialize, Clone)]
@@ -139,8 +150,37 @@ pub fn logout() -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
// Files
// ---------------------------------------------------------------------------
/// The public share token while the app is showing a share page. Every file
/// API call appends `?share=<token>` (or `&share=<token>`). Only one share is
/// shown per page, so a plain static suffices (wasm is single-threaded).
use std::sync::Mutex;
static SHARE_TOKEN: Mutex<Option<String>> = Mutex::new(None);
/// Set (or clear, with `None`) the share token used by file API calls.
pub fn set_share_token(token: Option<&str>) {
*SHARE_TOKEN.lock().unwrap() = token.map(|s| s.to_string());
}
fn share_suffix() -> String {
SHARE_TOKEN
.lock()
.unwrap()
.as_deref()
.map(|t| format!("?share={t}"))
.unwrap_or_default()
}
/// Append `key=value` to a URL, using `&` when a query string already exists.
fn append_query(url: &str, kv: &str) -> String {
if url.contains('?') {
format!("{url}&{kv}")
} else {
format!("{url}?{kv}")
}
}
fn files_url(root_id: i64, path: &str) -> String {
if path.is_empty() {
let base = if path.is_empty() {
format!("/api/files/{root_id}")
} else {
let encoded: Vec<String> = path
@@ -148,7 +188,8 @@ fn files_url(root_id: i64, path: &str) -> String {
.map(|s| js_sys::encode_uri_component(s).into())
.collect();
format!("/api/files/{root_id}/{}", encoded.join("/"))
}
};
format!("{base}{}", share_suffix())
}
pub fn list_files(
@@ -248,21 +289,21 @@ pub fn delete_item(
/// `...?action=download` — a single file as-is, or a folder as `format`.
pub fn download_url(root_id: i64, path: &str, format: Option<&str>) -> String {
let base = format!("{}?action=download", files_url(root_id, path));
match format {
Some(f) => format!("{base}&format={f}"),
None => base,
let mut base = append_query(&files_url(root_id, path), "action=download");
if let Some(f) = format {
base = append_query(&base, &format!("format={f}"));
}
base
}
/// `...?action=preview` — a single file, inline (native media).
pub fn preview_url(root_id: i64, path: &str) -> String {
format!("{}?action=preview", files_url(root_id, path))
append_query(&files_url(root_id, path), "action=preview")
}
/// `...?action=content` — raw file bytes for the text preview/editor.
pub fn content_url(root_id: i64, path: &str) -> String {
format!("{}?action=content", files_url(root_id, path))
append_query(&files_url(root_id, path), "action=content")
}
/// Fetch a file's raw text content (for the CodeMirror preview/editor).
@@ -361,7 +402,7 @@ pub async fn save_content(
) -> Result<i64, ApiError> {
let window =
web_sys::window().ok_or_else(|| ApiError::Net("no window available".to_string()))?;
let url = format!("{}?action=content", files_url(root_id, path));
let url = append_query(&files_url(root_id, path), "action=content");
let opts = web_sys::RequestInit::new();
opts.set_method("PUT");
opts.set_mode(web_sys::RequestMode::SameOrigin);
@@ -472,10 +513,9 @@ pub async fn upload(
let body = web_sys::Blob::new_with_buffer_source_sequence(&segments)
.map_err(|e| ApiError::Net(format!("could not build upload body: {e:?}")))?;
let url = format!(
"{}?overwrite={}",
files_url(root_id, dir),
if overwrite { "true" } else { "false" }
let url = append_query(
&files_url(root_id, dir),
&format!("overwrite={}", if overwrite { "true" } else { "false" }),
);
let headers = web_sys::Headers::new()
@@ -519,6 +559,68 @@ pub async fn upload(
serde_wasm_bindgen::from_value(js).map_err(|e| ApiError::Net(e.to_string()))
}
// ---------------------------------------------------------------------------
// Shares (milestone 6)
// ---------------------------------------------------------------------------
#[derive(Deserialize, Clone)]
pub struct ShareInfo {
pub id: i64,
pub token: String,
pub name: String,
pub is_file: bool,
pub writable: bool,
pub target: String,
pub created_at: String,
#[serde(default)]
pub expires_at: Option<String>,
/// The synthetic root id to use in file API calls.
#[serde(default)]
pub root_id: Option<i64>,
}
#[derive(Serialize)]
struct CreateShareBody {
root_id: i64,
path: String,
writable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
}
pub fn list_shares() -> impl std::future::Future<Output = Result<Vec<ShareInfo>, ApiError>> {
request("GET", "/api/shares".to_string(), None::<()>)
}
pub fn create_share(
root_id: i64,
path: &str,
writable: bool,
expires_at: Option<&str>,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request(
"POST",
"/api/shares".to_string(),
Some(CreateShareBody {
root_id,
path: path.to_string(),
writable,
expires_at: expires_at.map(|s| s.to_string()),
}),
)
}
pub fn delete_share(id: i64) -> impl std::future::Future<Output = Result<OkResp, ApiError>> {
request_no_body("DELETE", format!("/api/shares/{id}"))
}
/// Public: resolve a share (no session required).
pub fn resolve_share(
token: &str,
) -> impl std::future::Future<Output = Result<ShareInfo, ApiError>> {
request("GET", format!("/api/share/{token}"), None::<()>)
}
// ---------------------------------------------------------------------------
// File picker (imperative, one at a time)
// ---------------------------------------------------------------------------
▾Mweb/src/app.rs
@@ -46,6 +46,15 @@ pub fn App() -> impl IntoView {
view! {
{move || {
// Public share links (`#/s/{token}/...`) bypass the auth flow.
let loc_now = loc.get();
if let Some(token) = loc_now.share_token.clone() {
return view! {
<crate::views::share_page::ShareView token=token loc=loc/>
}
.into_view()
.into_any();
}
match phase.get() {
AuthPhase::Loading => view! {
<div class="center-screen">
▾Mweb/src/router.rs
@@ -1,13 +1,20 @@
//! 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.
//! Tiny hash-based router.
//!
//! - `#/` — root picker / single-root browser
//! - `#/r/{root_id}/{segment/...}` — a user's folder
//! - `#/s/{token}/{segment/...}` — a public share (no login)
//!
//! 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.
/// Path segments relative to the current root (or share target).
pub path: Vec<String>,
/// Present when the location is a public share (`#/s/{token}/...`).
pub share_token: Option<String>,
}
impl Location {
@@ -15,6 +22,7 @@ impl Location {
Self {
root_id: None,
path: Vec::new(),
share_token: None,
}
}
}
@@ -31,6 +39,15 @@ pub fn parse_location() -> Location {
Some(root_id) => Location {
root_id: Some(root_id),
path: parts.map(String::from).collect(),
share_token: None,
},
None => Location::root(),
},
Some("s") => match parts.next() {
Some(token) => Location {
root_id: None,
path: parts.map(String::from).collect(),
share_token: Some(token.to_string()),
},
None => Location::root(),
},
@@ -39,6 +56,14 @@ pub fn parse_location() -> Location {
}
pub fn location_to_hash(loc: &Location) -> String {
if let Some(token) = &loc.share_token {
let mut s = format!("#/s/{token}");
for seg in &loc.path {
s.push('/');
s.push_str(seg);
}
return s;
}
match loc.root_id {
None => "#/".to_string(),
Some(id) => {
@@ -58,3 +83,13 @@ pub fn navigate(loc: &Location) {
let _ = w.location().set_hash(&location_to_hash(loc));
}
}
/// Build the share link for a token, relative to the current page origin.
pub fn share_url(token: &str) -> String {
let base = web_sys::window()
.and_then(|w| w.location().href().ok())
.unwrap_or_default();
// Cut off any existing hash, then append the share hash.
let cut = base.find('#').unwrap_or(base.len());
format!("{}#/s/{token}", &base[..cut])
}
▾Mweb/src/views/browser.rs
@@ -167,7 +167,7 @@ fn root_picker(
let path = r.path.clone();
let mode = r.mode.clone();
let set_ctx = *set_ctx;
let _loc = loc.clone();
let share_tok = loc.share_token.clone();
view! {
<div
class="root-tile"
@@ -175,6 +175,7 @@ fn root_picker(
navigate(&Location {
root_id: Some(id),
path: vec![],
share_token: share_tok.clone(),
});
}
on:contextmenu=move |ev: MouseEvent| {
@@ -219,6 +220,7 @@ fn file_browser(
let root_id = root.id;
let root_name = root.name.clone();
let loc_now = loc.get();
let loc_sig = *loc;
// Breadcrumbs
let crumbs = view! {
@@ -229,6 +231,7 @@ fn file_browser(
navigate(&Location {
root_id: Some(root_id),
path: vec![],
share_token: loc_sig.get().share_token.clone(),
});
}
>
@@ -252,6 +255,7 @@ fn file_browser(
navigate(&Location {
root_id: Some(root_id),
path: target.clone(),
share_token: loc_sig.get().share_token.clone(),
});
}
>
@@ -371,11 +375,13 @@ fn entry_actions(
let set_ctx = *set_ctx;
let entry2 = entry.clone();
let nav = move || {
let mut path = l.get().path;
let cur = l.get();
let mut path = cur.path;
path.push(entry.name.clone());
navigate(&Location {
root_id: Some(root_id),
path,
share_token: cur.share_token.clone(),
});
};
let ctx_handler = move |ev: MouseEvent| {
@@ -589,6 +595,7 @@ fn CtxMenuView(
navigate(&Location {
root_id: cur.root_id,
path,
share_token: cur.share_token.clone(),
});
})),
None,
@@ -663,7 +670,26 @@ fn CtxMenuView(
)),
None,
));
v.push(("Share".into(), None, Some("Coming soon".into())));
if loc_now.share_token.is_none() {
let e_sh = e2.clone();
let l_sh = loc;
let allow_wr = me_now
.as_ref()
.map(|m| m.allow_writable_shares)
.unwrap_or(false);
v.push((
"Share".into(),
Some(action_share(
&e_sh,
eff_root_id,
l_sh,
allow_wr,
set_dialog,
owner.clone(),
)),
None,
));
}
let e_info = e2.clone();
let l_info = loc;
v.push((
@@ -1140,6 +1166,31 @@ fn action_edit(
})
}
/// Open the share dialog for an item (milestone 6).
fn action_share(
entry: &Entry,
root_id: Option<i64>,
loc: ReadSignal<Location>,
allow_writable: bool,
set_dialog: WriteSignal<Option<Dialog>>,
owner: Owner,
) -> Callback<()> {
let name = entry.name.clone();
owner.with(|| {
Callback::new(move |_| {
let Some(root_id) = root_id else { return };
let loc = loc.get();
let full = join_path(&loc.path, &name);
set_dialog.set(Some(Dialog::Share {
name: name.clone(),
root_id,
path: full,
allow_writable,
}));
})
})
}
fn action_info(
entry: &Entry,
loc: ReadSignal<Location>,
▾Mweb/src/views/dialogs.rs
@@ -70,6 +70,13 @@ pub enum Dialog {
root_name: String,
rel_path: String,
},
/// Create a share link for an item.
Share {
name: String,
root_id: i64,
path: String,
allow_writable: bool,
},
}
/// Split a "/"-separated directory path into its non-empty segments.
@@ -243,6 +250,30 @@ pub fn DialogView(
.into_view()
.into_any()
}
Dialog::Share {
name,
root_id,
path,
allow_writable,
} => {
let (name, root_id, path, allow_writable) = (
name.clone(),
*root_id,
path.clone(),
*allow_writable,
);
view! {
<crate::views::shares::ShareDialog
name=name
root_id=root_id
path=path
allow_writable=allow_writable
close=close.clone()
/>
}
.into_view()
.into_any()
}
}
}}
}
▾Mweb/src/views/mod.rs
@@ -2,4 +2,6 @@ pub mod browser;
pub mod dialogs;
pub mod login;
pub mod setup;
pub mod share_page;
pub mod shares;
pub mod shell;
▾Aweb/src/views/share_page.rs
@@ -0,0 +1,161 @@
//! Public share page (milestone 6): renders a shared item without login.
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, RootInfo, ShareInfo, UserInfo};
use crate::components::toast::{provide_toast, ToastView};
use crate::router::Location;
use crate::views::browser::Browser;
#[derive(Clone, PartialEq)]
enum SharePageState {
Loading,
NotFound,
Expired,
Active,
}
#[component]
pub fn ShareView(token: String, loc: ReadSignal<Location>) -> impl IntoView {
let toast = provide_toast();
let (me, set_me) = signal(Option::<Me>::None);
let (info, set_info) = signal(Option::<ShareInfo>::None);
let (state, set_state) = signal(SharePageState::Loading);
// Resolve the share once per token and scope all file API calls to it.
{
let tok = token.clone();
let set_me2 = set_me;
let set_info2 = set_info;
let set_state2 = set_state;
spawn_local(async move {
api::set_share_token(Some(&tok));
match api::resolve_share(&tok).await {
Ok(i) => {
let mode = if i.writable { "rw" } else { "ro" };
let me_val = Me {
first_boot: false,
user: Some(UserInfo {
id: i.id,
name: "shared".to_string(),
is_admin: false,
}),
roots: vec![RootInfo {
id: i.root_id.unwrap_or(i.id),
name: i.name.clone(),
path: "/".to_string(),
mode: mode.to_string(),
}],
allow_writable_shares: false,
};
set_me2.set(Some(me_val));
set_info2.set(Some(i));
set_state2.set(SharePageState::Active);
}
Err(e) => {
let expired = e.status() == Some(410);
set_state2.set(if expired {
SharePageState::Expired
} else {
SharePageState::NotFound
});
}
}
});
on_cleanup(move || api::set_share_token(None));
}
view! {
<div class="layout share-layout">
<header class="topbar">
<div class="brand">"📂 filebrowser-ng"</div>
<div class="topbar-right">
{move || {
let name = info.get().map(|i| i.name.clone());
match name {
Some(n) => view! {
<span class="share-badge">{format!("🔗 Shared: {n}")}</span>
}
.into_view()
.into_any(),
None => view! {}.into_any(),
}
}}
</div>
</header>
<main class="content">
{move || match state.get() {
SharePageState::Loading => view! {
<div class="center-screen">
<p class="muted">"Loading share…"</p>
</div>
}
.into_view()
.into_any(),
SharePageState::NotFound => view! {
<div class="center-screen">
<h2>"Share not found"</h2>
<p class="muted">"This link is invalid or the share was removed."</p>
</div>
}
.into_view()
.into_any(),
SharePageState::Expired => view! {
<div class="center-screen">
<h2>"Share expired"</h2>
<p class="muted">"This share link is no longer active."</p>
</div>
}
.into_view()
.into_any(),
SharePageState::Active => {
let Some(i) = info.get() else {
return view! {}.into_any();
};
if i.is_file {
let name = i.name.clone();
let rid = i.root_id.unwrap_or(i.id);
view! {
<div class="share-file-page">
<div class="share-file-icon">"📄"</div>
<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()
} else {
if me.get().is_none() {
return view! {
<div class="center-screen">
<p class="muted">"Loading…"</p>
</div>
}
.into_view()
.into_any();
}
view! {
<Browser me=me loc=loc/>
}
.into_view()
.into_any()
}
}
}}
</main>
<footer class="share-footer muted">"Powered by filebrowser-ng"</footer>
<ToastView toast=toast/>
</div>
}
}
▾Aweb/src/views/shares.rs
@@ -0,0 +1,351 @@
//! Share creation dialog and the "Your shares" management panel (milestone 6).
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, ShareInfo};
use crate::components::toast::{show, ToastMsg};
use crate::router::share_url;
use crate::util::format_date;
// ---------------------------------------------------------------------------
// Expiry helpers (compute the RFC 3339 expiry client-side)
// ---------------------------------------------------------------------------
/// Format a millisecond timestamp as an RFC 3339 UTC string.
fn ms_to_rfc3339(ms: f64) -> Option<String> {
if ms.is_nan() {
return None;
}
let secs = (ms / 1000.0) as i64;
chrono::DateTime::from_timestamp(secs, 0)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
/// "never". Presets are computed from "now"; "custom" parses the
/// datetime-local value as the browser's local time.
fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
let now = js_sys::Date::now();
let h = 60.0 * 60.0 * 1000.0;
let target_ms: Option<f64> = match choice {
"never" => return None,
"1h" => Some(now + h),
"1d" => Some(now + 24.0 * h),
"1w" => Some(now + 7.0 * 24.0 * h),
"1mo" => Some(now + 30.0 * 24.0 * h),
"custom" => {
if custom.is_empty() {
None
} else {
// `Date.parse` treats a bare datetime as local time, so the
// result is already the correct UTC instant.
Some(js_sys::Date::parse(custom))
}
}
_ => return None,
};
target_ms.and_then(ms_to_rfc3339)
}
fn copy_to_clipboard(text: &str, toast: ToastMsg) {
let Some(win) = web_sys::window() else {
show(toast, "Copy the link manually".to_string());
return;
};
let cb = win.navigator().clipboard();
let t = text.to_string();
spawn_local(async move {
let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
show(toast, "Link copied to clipboard".to_string());
});
}
fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
// ---------------------------------------------------------------------------
// Share creation dialog (two-phase: form -> link)
// ---------------------------------------------------------------------------
#[component]
pub fn ShareDialog(
name: String,
root_id: i64,
path: String,
allow_writable: bool,
close: Callback<()>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (writable, set_writable) = signal(false);
let (choice, set_choice) = signal("never".to_string());
let (custom, set_custom) = signal(String::new());
let (created, set_created) = signal(Option::<ShareInfo>::None);
let (busy, set_busy) = signal(false);
let do_create = move |_| {
if busy.get() {
return;
}
set_busy.set(true);
let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
let wr = writable.get();
let rid = root_id;
let p = path.clone();
spawn_local(async move {
match api::create_share(rid, &p, wr, exp.as_deref()).await {
Ok(info) => set_created.set(Some(info)),
Err(e) => {
show(toast, format!("Could not create share: {e}"));
set_busy.set(false);
}
}
});
};
view! {
<div class="modal-overlay" on:click=move |_| close.run(())>
<div
class="modal share-modal"
on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
>
{move || match created.get() {
Some(ref info) => {
let link = share_url(&info.token);
view! {
<h2 class="modal-title">"Share link"</h2>
<p class="modal-message">
"Anyone with this link can "
{if writable.get() { "read and write " } else { "read " }}
{format!("“{name}”.")}
</p>
<div class="share-link-row">
<input
class="share-link-input"
readonly=true
value=link.clone()
on:click=move |ev: web_sys::MouseEvent| {
if let Some(i) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
i.select();
}
}
/>
<button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>"Copy"</button>
</div>
<p class="muted share-expiry-note">
{match info.expires_at {
Some(ref e) => format!("Expires {0}", format_date(e)),
None => "Never expires".to_string(),
}}
</p>
<div class="modal-actions">
<button class="btn btn-primary" on:click=move |_| close.run(())>"Done"</button>
</div>
}
.into_view()
.into_any()
}
None => view! {
<h2 class="modal-title">{format!("Share {name}")}</h2>
<p class="modal-message">"Create a link for this item. Writable links let viewers upload and edit."</p>
{move || if allow_writable {
view! {
<label class="share-check">
<input
type="checkbox"
checked=move || writable.get()
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_writable.set(t.checked());
}
}
/>
"Allow editing (read-write)"
</label>
}
.into_view()
.into_any()
} else {
view! {
<p class="muted">"Writable shares are disabled by the server."</p>
}
.into_view()
.into_any()
}}
<div class="share-expiry-row">
<label for="share-expiry">"Expires"</label>
<select
id="share-expiry"
on:change=move |ev: web_sys::Event| {
if let Some(v) = select_value(&ev) {
set_choice.set(v);
}
}
>
<option value="never">"Never"</option>
<option value="1h">"1 hour"</option>
<option value="1d">"1 day"</option>
<option value="1w">"1 week"</option>
<option value="1mo">"1 month"</option>
<option value="custom">"Custom…"</option>
</select>
</div>
{move || if choice.get() == "custom" {
view! {
<div class="share-expiry-row">
<label for="share-custom-dt">"Until"</label>
<input
type="datetime-local"
id="share-custom-dt"
value=move || custom.get()
on:change=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_custom.set(t.value());
}
}
/>
</div>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
<button
class="btn btn-primary"
disabled=move || busy.get()
on:click=do_create.clone()
>
{move || if busy.get() { "Creating…" } else { "Create link" }}
</button>
</div>
}
.into_view()
.into_any(),
}}
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// "Your shares" management panel
// ---------------------------------------------------------------------------
#[component]
pub fn SharesPanel(close: Callback<()>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
{
let set = set_shares;
let toast2 = toast;
spawn_local(async move {
match api::list_shares().await {
Ok(s) => set.set(Some(s)),
Err(e) => show(toast2, e.to_string()),
}
});
}
view! {
<div class="modal-overlay" on:click=move |_| close.run(())>
<div
class="modal shares-panel"
on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
>
<h2 class="modal-title">"Your shares"</h2>
{move || match shares.get() {
None => view! {
<p class="muted">"Loading…"</p>
}
.into_view()
.into_any(),
Some(ref list) if list.is_empty() => view! {
<p class="muted">"No shares yet. Right-click a file or folder and choose “Share”."</p>
}
.into_view()
.into_any(),
Some(ref list) => {
let items = list
.iter()
.map(|s| {
let s2 = s.clone();
let link = share_url(&s.token);
let mut meta = format!(
"{} · {}",
s.target,
if s.writable { "read-write" } else { "read-only" }
);
if let Some(e) = &s.expires_at {
meta.push_str(&format!(" · expires {}", format_date(e)));
}
meta.push_str(&format!(" · created {}", format_date(&s.created_at)));
view! {
<div class="share-item">
<div class="share-item-main">
<div class="share-item-name">
{if s.is_file { "📄" } else { "📁" }}
{" "}{s.name.clone()}
</div>
<div class="share-item-meta muted">{meta}</div>
</div>
<div class="share-item-actions">
<button
class="btn btn-sm"
title="Copy link"
on:click=move |_| copy_to_clipboard(&link, toast)
>"Copy"</button>
<button
class="btn btn-sm btn-danger"
title="Delete share"
on:click=move |_| {
let id = s2.id;
let read = shares;
let write = set_shares;
let toast2 = toast;
spawn_local(async move {
if api::delete_share(id).await.is_ok() {
if let Some(mut cur) = read.get() {
cur.retain(|x| x.id != id);
write.set(Some(cur));
}
} else {
show(toast2, "Could not delete share".to_string());
}
});
}
>"Delete"</button>
</div>
</div>
}
})
.collect::<Vec<_>>();
view! {
<div class="share-list">{items}</div>
}
}
.into_view()
.into_any(),
}}
<div class="modal-actions">
<button class="btn" on:click=move |_| close.run(())>"Close"</button>
</div>
</div>
</div>
}
}
▾Mweb/src/views/shell.rs
@@ -5,6 +5,7 @@ use crate::api::{self, Me};
use crate::components::toast::{provide_toast, ToastView};
use crate::router::Location;
use crate::views::browser::Browser;
use crate::views::shares::SharesPanel;
#[component]
pub fn ShellView(
@@ -14,6 +15,7 @@ pub fn ShellView(
loc: ReadSignal<Location>,
) -> impl IntoView {
let toast = provide_toast();
let (show_shares, set_show_shares) = signal(false);
let on_logout = move |_| {
spawn_local(async move {
@@ -47,12 +49,22 @@ pub fn ShellView(
.into_any(),
)
}}
<button class="btn" on:click=move |_| set_show_shares.set(true)>"Shares"</button>
<button class="btn" on:click=on_logout>"Log out"</button>
</div>
</header>
<main class="content">
<Browser me=me loc=loc/>
</main>
{move || if show_shares.get() {
view! {
<SharesPanel close=Callback::new(move |_| set_show_shares.set(false))/>
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}}
<ToastView toast=toast/>
</div>
}