From de40dde4bd4d0bca28cd5abf7eaaebb9069c47f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:29:56 +0200 Subject: [PATCH] feat(desktop): FileCloud-style share dialog from Explorer context menu (#160) (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): FileCloud-style share dialog from Explorer context menu (#160) The previous "Copy share link" command silently created a public share — no options, no dialog. Issue #160 asks for the FileCloud flow: right-click -> Share link -> popup with permission/expiry/password controls. - ShareCreateService gains the full PUT /share surface (password, expire, downloads, preview_only, allow_edit, allow_upload, upload_only) via serde-optional fields - New ManagerCommand::ShareLink resolves path -> uri and broadcasts OpenShareDialog { drive_id, uri, name, is_dir } - src-tauri opens a dedicated "share" webview window on /share and adds a create_share command mapping dialog options onto the service - New Share.tsx dialog: permission mode, expiry, password, download limit -> create -> link + copy button; "share" locale namespace added - Explorer submenu now offers "Share link..." (dialog) plus the existing "Copy share link" quick action, both built from a shared resolve-send macro * fix(desktop): pass impl trait name explicitly in share handler macro macro_rules metavar concat [< _Impl>] is not supported on the CI toolchain; take the generated *_Impl type as a parameter instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 1 + .../crates/cloudreve-api/src/models/share.rs | 29 +- .../cloudreve-sync/src/drive/commands.rs | 32 ++- .../src/drive/manager/command_handlers.rs | 32 +++ .../crates/cloudreve-sync/src/events/mod.rs | 18 ++ desktop/crates/cloudreve-sync/src/lib.rs | 1 + .../src/shellext/context_menu/mod.rs | 2 +- .../src/shellext/context_menu/share_link.rs | 238 +++++++++------- .../src/shellext/context_menu/sub_commands.rs | 13 +- desktop/locales/app.yaml | 12 + desktop/src-tauri/src/commands.rs | 131 +++++++++ desktop/src-tauri/src/event_handler.rs | 7 + desktop/src-tauri/src/lib.rs | 1 + desktop/ui/public/locales/de/common.json | 23 ++ desktop/ui/public/locales/en-US/common.json | 23 ++ desktop/ui/public/locales/es/common.json | 23 ++ desktop/ui/public/locales/fr/common.json | 23 ++ desktop/ui/public/locales/it/common.json | 23 ++ desktop/ui/public/locales/ja/common.json | 23 ++ desktop/ui/public/locales/ko/common.json | 23 ++ desktop/ui/public/locales/pl/common.json | 23 ++ desktop/ui/public/locales/ru/common.json | 23 ++ desktop/ui/public/locales/zh-CN/common.json | 23 ++ desktop/ui/public/locales/zh-TW/common.json | 23 ++ desktop/ui/src/App.tsx | 2 + desktop/ui/src/pages/Share.tsx | 259 ++++++++++++++++++ 26 files changed, 913 insertions(+), 118 deletions(-) create mode 100644 desktop/ui/src/pages/Share.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 8a1ad499..3355f0b8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -252,6 +252,7 @@ Goal: Windows + macOS + Linux from the `desktop/` tree in this repo. - [x] Release pipelines for all four artifacts, all verified locally — server: `release.yml` + goreleaser on `v*` tags (12 binaries linux/win/darwin/freebsd + ghcr docker amd64/arm64/slim + manifests; snapshot build verified); desktop: `desktop-release.yml` on `desktop-v*` tags (.msi/.exe, .dmg, .deb/.AppImage — local `cargo tauri build` produced both Linux bundles, `.deb` declares `fuse3` for on-demand mode, `libfuse2`+`fuse3` added to runner deps for the AppImage toolchain); android: new `android-release.yml` on `android-v*` tags ships signed release APK + AAB (env-keystore via `ANDROID_KEYSTORE_*` secrets, debug-cert fallback so APKs sideload; `lintVitalRelease` caught a real missing `dataSync` foregroundServiceType on `SystemForegroundService` — fixed in manifest, would have crashed API 34+); `tauri-plugin-http` pinned `~2.6` + npm `@tauri-apps/plugin-http@^2.6.1` to clear the version-mismatch guard that blocked every desktop release build - [x] #167 (upstream desktop#49) — online-only thumbnails missing in Explorer: root cause was a client/server contract mismatch — the CE `/file/thumb` response carries only `url`/`expires` while the `cloudreve-api` model required `obfuscated`, failing deserialization on every thumbnail request (`E_FAIL` to Explorer; hydrated files were unaffected since Windows thumbs them locally). `obfuscated` is now `#[serde(default)]`; the decode path still runs when a server emits the flag - [x] Linux on-demand hydration via FUSE (`fuser`) — new "On-demand" sync mode in Add Drive (Linux-only picker): remote tree projects virtually into a FUSE mount at the configured path backed by inventory; files hydrate into a private store (`~/.cloudreve/fuse-store/`) on open with per-path serialization, sibling tmp dir + atomic rename, zero kernel cache TTLs; the watcher/sync engine operate on the store so hydrated edits sync normally; remote refreshes evict stale hydrated copies (event-blocked so no remote delete); engine writes gated by `local_updated_at`/`local_size` snapshots so downloads/hydrations never boomerang into uploads; `unlink`/`rename` on virtual files route through synthesized events + `MountCommand::Rename`; full-sync mode unchanged on Linux/macOS; covered by a real kernel-mount integration test (`tests/fuse_mount.rs`) +- [x] #160 (upstream desktop#34) — FileCloud-style quick share: Explorer "Share link…" submenu item opens a share dialog window (new `ManagerCommand::ShareLink` → `OpenShareDialog` event → dedicated `share` webview window + `/share` route) with permission mode (view+download / preview-only / edit / folder upload-only), expiry, password, and download limit mapped to the full `PUT /share` surface (`ShareCreateService` gained the option fields); "Copy share link" stays as the one-click public-link action alongside it ## 7. Phase E — Android app (native, no iOS) diff --git a/desktop/crates/cloudreve-api/src/models/share.rs b/desktop/crates/cloudreve-api/src/models/share.rs index f2520d58..cc34ac4f 100644 --- a/desktop/crates/cloudreve-api/src/models/share.rs +++ b/desktop/crates/cloudreve-api/src/models/share.rs @@ -21,9 +21,32 @@ pub struct ListShareResponse { pub pagination: PaginationResults, } -/// Share create service. Only the target URI is required; the server applies -/// public-link defaults for all other fields. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Share create service. Only the target URI is required; omitted fields fall +/// back to the server's public-link defaults. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ShareCreateService { pub uri: String, + /// Password-protected share; requires `password`. + #[serde(default)] + pub is_private: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Remaining download quota; 0/absent = unlimited. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Seconds until the share expires; 0/absent = never. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expire: Option, + /// Visitors can preview but not download. + #[serde(default)] + pub preview_only: bool, + /// Visitors can edit files in place (WOPI-capable types). + #[serde(default)] + pub allow_edit: bool, + /// Visitors can upload into the shared folder. + #[serde(default)] + pub allow_upload: bool, + /// Drop-box share: upload-only, contents hidden from visitors. + #[serde(default)] + pub upload_only: bool, } diff --git a/desktop/crates/cloudreve-sync/src/drive/commands.rs b/desktop/crates/cloudreve-sync/src/drive/commands.rs index 1f40541b..0833314f 100644 --- a/desktop/crates/cloudreve-sync/src/drive/commands.rs +++ b/desktop/crates/cloudreve-sync/src/drive/commands.rs @@ -156,6 +156,10 @@ pub enum ManagerCommand { CopyShareLink { path: PathBuf, }, + /// Open the share dialog for a file or folder + ShareLink { + path: PathBuf, + }, PersistConfig, GenerateThumbnail { path: PathBuf, @@ -450,22 +454,34 @@ impl Mount { Ok(thumb_response.bytes().await?) } - /// Create a public share link for the file or folder at `path`, - /// returning the share URL. - pub async fn create_share_link(&self, path: PathBuf) -> Result { + /// Resolve a mount-space local path to its cloudreve URI. + pub async fn share_uri(&self, path: PathBuf) -> Result { // `path` arrives in mount space from the file manager, so the URI root // is `sync_path` — mount and store share the same relative layout. let (sync_path, remote_base) = { let config = self.config.read().await; (config.sync_path.clone(), config.remote_path.to_string()) }; - let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) + let uri = local_path_to_cr_uri(path, sync_path, remote_base) .context("failed to convert local path to cloudreve uri")? .to_string(); - Ok(self - .cr_client - .create_share(&ShareCreateService { uri }) - .await?) + Ok(uri) + } + + /// Create a share link for the file or folder at `path`, + /// returning the share URL. + pub async fn create_share_link(&self, path: PathBuf) -> Result { + let uri = self.share_uri(path).await?; + self.create_share(ShareCreateService { + uri, + ..Default::default() + }) + .await + } + + /// Create a share link from a full share request, returning the share URL. + pub async fn create_share(&self, request: ShareCreateService) -> Result { + Ok(self.cr_client.create_share(&request).await?) } pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> { diff --git a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs index 53efc2c2..18d8eb14 100644 --- a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs +++ b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs @@ -50,6 +50,15 @@ impl DriveManager { } }); } + ManagerCommand::ShareLink { path } => { + let path = path.clone(); + spawn(async move { + let result = manager.handle_share_link(path.clone()).await; + if let Err(e) = result { + tracing::error!(target: "drive::manager", path = %path.display(), error = %e, "ShareLink command failed"); + } + }); + } ManagerCommand::PersistConfig => { let result = manager.persist().await; if let Err(e) = result { @@ -236,6 +245,29 @@ impl DriveManager { Ok(()) } + /// Handle ShareLink command — resolves the target URI and asks the UI to + /// open the share dialog where the user picks permissions before creating + /// the link. + pub(super) async fn handle_share_link(&self, path: PathBuf) -> Result<()> { + tracing::debug!(target: "drive::manager", path = %path.display(), "ShareLink command"); + + let mount = self + .search_drive_by_child_path(path.to_str().unwrap_or("")) + .await + .ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?; + + let uri = mount.share_uri(path.clone()).await?; + let name = match path.file_name() { + Some(n) => n.to_string_lossy().to_string(), + None => mount.get_config().await.name, + }; + let is_dir = path.is_dir(); + + self.event_broadcaster + .open_share_dialog(mount.id.clone(), uri, name, is_dir); + Ok(()) + } + /// Handle ShowConflictToast command pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> { tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command"); diff --git a/desktop/crates/cloudreve-sync/src/events/mod.rs b/desktop/crates/cloudreve-sync/src/events/mod.rs index 79e50c2d..bff7da40 100644 --- a/desktop/crates/cloudreve-sync/src/events/mod.rs +++ b/desktop/crates/cloudreve-sync/src/events/mod.rs @@ -15,6 +15,13 @@ pub enum Event { OpenSyncStatusWindow, /// Request to open the settings window OpenSettingsWindow, + /// Request to open the share dialog for a file or folder + OpenShareDialog { + drive_id: String, + uri: String, + name: String, + is_dir: bool, + }, } impl Event { @@ -24,6 +31,7 @@ impl Event { Event::NoDrive {} => "NoDrive", Event::OpenSyncStatusWindow => "OpenSyncStatusWindow", Event::OpenSettingsWindow => "OpenSettingsWindow", + Event::OpenShareDialog { .. } => "OpenShareDialog", } } } @@ -92,6 +100,16 @@ impl EventBroadcaster { self.broadcast(Event::OpenSettingsWindow); } + /// Helper: Broadcast open share dialog event + pub fn open_share_dialog(&self, drive_id: String, uri: String, name: String, is_dir: bool) { + self.broadcast(Event::OpenShareDialog { + drive_id, + uri, + name, + is_dir, + }); + } + /// Get the number of active subscribers pub fn subscriber_count(&self) -> usize { self.sender.receiver_count() diff --git a/desktop/crates/cloudreve-sync/src/lib.rs b/desktop/crates/cloudreve-sync/src/lib.rs index 51b007df..2c020372 100644 --- a/desktop/crates/cloudreve-sync/src/lib.rs +++ b/desktop/crates/cloudreve-sync/src/lib.rs @@ -18,6 +18,7 @@ pub mod uploader; pub mod utils; // Re-export commonly used types +pub use cloudreve_api::models::share::ShareCreateService; pub use cloudreve_api::normalize_site_url; pub use config::{AppConfig, ConfigManager}; pub use drive::manager::{ diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs index 2e263e3e..99bbecde 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs @@ -12,7 +12,7 @@ mod view_online; pub use explorer_command::CrExplorerCommandHandler; pub use factory::CrExplorerCommandFactory; pub use resolve_conflict::ResolveConflictCommandHandler; -pub use share_link::ShareLinkCommandHandler; +pub use share_link::{CopyShareLinkCommandHandler, ShareLinkCommandHandler}; pub use sub_commands::SubCommands; pub use sync_now::SyncNowCommandHandler; pub use view_online::ViewOnlineCommandHandler; diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs index 13a280b8..facf9942 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs @@ -11,132 +11,166 @@ use windows::{ /// shlguid.h: SID_SFolderView — resolves IFolderView from the command site. const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce); -#[implement(IExplorerCommand, IObjectWithSite)] -pub struct ShareLinkCommandHandler { - drive_manager: Arc, - app_root: AppRoot, - site: Mutex>, -} +/// Resolve the invoked filesystem path from either the selection or, for +/// folder-background invokes, the folder behind the command site. +fn resolve_invoke_path( + selection: Option<&IShellItemArray>, + site: &Mutex>, +) -> Result> { + if let Some(items) = selection { + unsafe { + let count = items.GetCount()?; + if count != 1 { + return Ok(None); + } -impl ShareLinkCommandHandler { - pub fn new(drive_manager: Arc, app_root: AppRoot) -> Self { - Self { - drive_manager, - app_root, - site: Mutex::new(None), + let item = items.GetItemAt(0)?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + return Ok(Some(PathBuf::from(display_name.to_string()?))); } } - fn send_copy_share_link(&self, path: PathBuf) { - tracing::debug!(target: "shellext::context_menu", path = %path.display(), "Copy share link requested"); - let command_tx = self.drive_manager.get_command_sender(); - if let Err(e) = command_tx.send(ManagerCommand::CopyShareLink { path }) { - tracing::error!(target: "shellext::context_menu", error = %e, "Failed to send CopyShareLink command"); - } + // Folder-background invoke carries no selection; resolve the current + // folder through the site Explorer gave us in SetSite. + let site = site.lock().unwrap().clone(); + let Some(site) = site else { + return Ok(None); + }; + unsafe { + let service_provider: IServiceProvider = site.cast()?; + let folder_view: IFolderView = service_provider.QueryService(&SID_S_FOLDER_VIEW)?; + let item: IShellItem = folder_view.GetFolder()?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + Ok(Some(PathBuf::from(display_name.to_string()?))) } } -impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl { - fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> { - *self.site.lock().unwrap() = punksite.cloned(); - Ok(()) - } +fn item_count_state(items: Option<&IShellItemArray>) -> Result { + let Some(items) = items else { + // Not selecting anything, but still triggered from a folder + return Ok(ECS_ENABLED.0 as u32); + }; - fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> { - let site = self.site.lock().unwrap(); - if let Some(site) = site.as_ref() { - unsafe { site.query(riid, ppvsite) }.ok() + unsafe { + let count = items.GetCount()?; + if count <= 1 { + Ok(ECS_ENABLED.0 as u32) } else { - Err(Error::from(E_FAIL)) + Ok(ECS_HIDDEN.0 as u32) } } } -impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl { - fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result { - let title = t!("copyShareLink"); - let hstring = HSTRING::from(title.as_ref()); - unsafe { SHStrDupW(&hstring) } - } - - fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result { - let icon_path = format!("{}\\people.ico", self.app_root.image_path()); - let hstring = HSTRING::from(icon_path); - unsafe { SHStrDupW(&hstring) } - } - - fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result { - Err(Error::from(E_NOTIMPL)) - } +macro_rules! share_command_handler { + ($name:ident, $impl_name:ident, $title_key:literal, $icon:literal, $guid:literal, $variant:ident) => { + #[implement(IExplorerCommand, IObjectWithSite)] + pub struct $name { + drive_manager: Arc, + app_root: AppRoot, + site: Mutex>, + } - fn GetCanonicalName(&self) -> Result { - Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b)) - } + impl $name { + pub fn new(drive_manager: Arc, app_root: AppRoot) -> Self { + Self { + drive_manager, + app_root, + site: Mutex::new(None), + } + } + } - fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result { - let Some(items) = items else { - // Not select anthing, but still triggerd from a folder - return Ok(ECS_ENABLED.0 as u32); - }; + impl IObjectWithSite_Impl for $impl_name { + fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> { + *self.site.lock().unwrap() = punksite.cloned(); + Ok(()) + } - unsafe { - let count = items.GetCount()?; - if count <= 1 { - Ok(ECS_ENABLED.0 as u32) - } else { - Ok(ECS_HIDDEN.0 as u32) + fn GetSite( + &self, + riid: *const GUID, + ppvsite: *mut *mut core::ffi::c_void, + ) -> Result<()> { + let site = self.site.lock().unwrap(); + if let Some(site) = site.as_ref() { + unsafe { site.query(riid, ppvsite) }.ok() + } else { + Err(Error::from(E_FAIL)) + } } } - } - fn Invoke( - &self, - selection: Option<&IShellItemArray>, - _bindctx: Option<&IBindCtx>, - ) -> Result<()> { - tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked"); - - if let Some(items) = selection { - unsafe { - let count = items.GetCount()?; - if count != 1 { - return Ok(()); - } + impl IExplorerCommand_Impl for $impl_name { + fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result { + let title = t!($title_key); + let hstring = HSTRING::from(title.as_ref()); + unsafe { SHStrDupW(&hstring) } + } - // Get the first item - let item = items.GetItemAt(0)?; - let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; - let path = PathBuf::from(display_name.to_string()?); + fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result { + let icon_path = format!("{}\\{}", self.app_root.image_path(), $icon); + let hstring = HSTRING::from(icon_path); + unsafe { SHStrDupW(&hstring) } + } - self.send_copy_share_link(path); + fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result { + Err(Error::from(E_NOTIMPL)) } - } else { - // Folder-background invoke carries no selection; resolve the - // current folder through the site Explorer gave us in SetSite. - let site = self.site.lock().unwrap().clone(); - let Some(site) = site else { - return Ok(()); - }; - unsafe { - let service_provider: IServiceProvider = site.cast()?; - let folder_view: IFolderView = - service_provider.QueryService(&SID_S_FOLDER_VIEW)?; - let item: IShellItem = folder_view.GetFolder()?; - let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; - let path = PathBuf::from(display_name.to_string()?); - - self.send_copy_share_link(path); + + fn GetCanonicalName(&self) -> Result { + Ok(GUID::from_u128($guid)) } - } - Ok(()) - } + fn GetState( + &self, + items: Option<&IShellItemArray>, + _oktobeslow: BOOL, + ) -> Result { + item_count_state(items) + } - fn GetFlags(&self) -> Result { - Ok(ECF_DEFAULT.0 as u32) - } + fn Invoke( + &self, + selection: Option<&IShellItemArray>, + _bindctx: Option<&IBindCtx>, + ) -> Result<()> { + tracing::debug!(target: "shellext::context_menu", concat!($title_key, " context menu command invoked")); + + if let Some(path) = resolve_invoke_path(selection, &self.site)? { + let command_tx = self.drive_manager.get_command_sender(); + if let Err(e) = command_tx.send(ManagerCommand::$variant { path }) { + tracing::error!(target: "shellext::context_menu", error = %e, concat!("Failed to send ", $title_key, " command")); + } + } - fn EnumSubCommands(&self) -> Result { - Err(Error::from(E_NOTIMPL)) - } + Ok(()) + } + + fn GetFlags(&self) -> Result { + Ok(ECF_DEFAULT.0 as u32) + } + + fn EnumSubCommands(&self) -> Result { + Err(Error::from(E_NOTIMPL)) + } + } + }; } + +share_command_handler!( + ShareLinkCommandHandler, + ShareLinkCommandHandler_Impl, + "shareLink", + "people.ico", + 0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b, + ShareLink +); + +share_command_handler!( + CopyShareLinkCommandHandler, + CopyShareLinkCommandHandler_Impl, + "copyShareLink", + "globe7.ico", + 0x4a1f9e2d_8b7c_4d3e_a5f1_2c7b9d8e6a4f, + CopyShareLink +); diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs index 74ffde54..d8ddafc3 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs @@ -1,6 +1,6 @@ use super::{ - ResolveConflictCommandHandler, ShareLinkCommandHandler, SyncNowCommandHandler, - ViewOnlineCommandHandler, + CopyShareLinkCommandHandler, ResolveConflictCommandHandler, ShareLinkCommandHandler, + SyncNowCommandHandler, ViewOnlineCommandHandler, }; use crate::{drive::manager::DriveManager, utils::app::AppRoot}; use std::sync::{Arc, Mutex}; @@ -113,15 +113,20 @@ macro_rules! sub_command_factory { sub_command_factory!(create_view_online_command, ViewOnlineCommandHandler); sub_command_factory!(create_share_link_command, ShareLinkCommandHandler); +sub_command_factory!( + create_copy_share_link_command, + CopyShareLinkCommandHandler +); sub_command_factory!(create_sync_now_command, SyncNowCommandHandler); sub_command_factory!( create_resolve_conflict_command, ResolveConflictCommandHandler ); -const SUB_COMMAND_FACTORIES: [SubCommandFactory; 4] = [ - create_view_online_command, +const SUB_COMMAND_FACTORIES: [SubCommandFactory; 5] = [ create_share_link_command, + create_copy_share_link_command, + create_view_online_command, create_sync_now_command, create_resolve_conflict_command, ]; diff --git a/desktop/locales/app.yaml b/desktop/locales/app.yaml index 003ffcc4..ed355e3f 100644 --- a/desktop/locales/app.yaml +++ b/desktop/locales/app.yaml @@ -11,6 +11,18 @@ viewOnline: ru: "Просмотреть онлайн" pl: "Zobacz online" it: "Visualizza online" +shareLink: + en-US: "Share link…" + zh-CN: "分享链接…" + zh-TW: "分享連結…" + ja: "共有リンク…" + de: "Link teilen…" + fr: "Lien de partage…" + es: "Enlace para compartir…" + ko: "공유 링크…" + ru: "Поделиться ссылкой…" + pl: "Udostępnij link…" + it: "Condividi link…" copyShareLink: en-US: "Copy share link" zh-CN: "复制分享链接" diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs index 5eb1a542..dfdfe0c6 100644 --- a/desktop/src-tauri/src/commands.rs +++ b/desktop/src-tauri/src/commands.rs @@ -878,6 +878,137 @@ pub fn show_settings_window_impl(app: &AppHandle) { } } +/// Show or create the share dialog window for a file or folder. +pub fn show_share_window_impl( + app: &AppHandle, + drive_id: &str, + uri: &str, + name: &str, + is_dir: bool, +) { + let url_path = format!( + "index.html#/share?drive={}&uri={}&name={}&dir={}", + urlencoding::encode(drive_id), + urlencoding::encode(uri), + urlencoding::encode(name), + if is_dir { "1" } else { "0" }, + ); + let url_path = get_url_with_lang(&url_path); + + // A stale window could belong to a different item; rebuild it each time. + if let Some(window) = app.get_webview_window("share") { + let _ = window.destroy(); + } + + #[cfg(windows)] + let effects = WindowEffectsConfig { + effects: vec![WindowEffect::Mica, WindowEffect::Acrylic], + state: None, + radius: None, + color: None, + }; + + let builder = WebviewWindowBuilder::new(app, "share", WebviewUrl::App(url_path.into())) + .title("Share link") + .inner_size(480.0, 640.0) + .resizable(false) + .visible(false) + .decorations(false) + .minimizable(false); + + #[cfg(not(windows))] + // See `show_main_window_at_position`: keep non-Windows webviews opaque to + // avoid platform compositor artifacts while preserving Windows effects. + let builder = builder.background_color(Color(255, 255, 255, 255)); + + #[cfg(windows)] + let builder = builder.transparent(true); + + #[cfg(target_os = "macos")] + let builder = builder + .title_bar_style(TitleBarStyle::Overlay) + .hidden_title(true); + + let Some(builder) = apply_default_window_icon(builder, app, "share") else { + return; + }; + + match builder.build() { + Ok(window) => { + #[cfg(target_os = "macos")] + update_dock_on_window_close(&window); + + #[cfg(windows)] + { + let _ = window.set_effects(effects); + } + + move_window_safely(&window, Position::Center, "share"); + #[cfg(windows)] + let _ = window.create_overlay_titlebar(); + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "macos")] + crate::update_dock_visibility(app); + } + Err(e) => { + tracing::error!(target: "main", error = %e, "Failed to create share window"); + } + } +} + +/// Options accepted by the share dialog, mapped onto the server's share +/// creation fields. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareOptions { + pub password: Option, + pub downloads: Option, + /// Seconds until expiry. + pub expire: Option, + pub preview_only: Option, + pub allow_edit: Option, + pub allow_upload: Option, + pub upload_only: Option, +} + +/// Create a share link from the share dialog, returning the share URL. +#[tauri::command] +pub async fn create_share( + state: State<'_, AppStateHandle>, + drive_id: String, + uri: String, + options: ShareOptions, +) -> CommandResult { + let app_state = state + .get() + .ok_or_else(|| "App not yet initialized".to_string())?; + + let mount = app_state + .drive_manager + .get_drive(&drive_id) + .await + .ok_or_else(|| "Drive not found".to_string())?; + + let password = options.password.filter(|p| !p.is_empty()); + let request = cloudreve_sync::ShareCreateService { + uri, + is_private: password.is_some(), + password, + downloads: options.downloads.filter(|d| *d > 0), + expire: options.expire.filter(|e| *e > 0), + preview_only: options.preview_only.unwrap_or(false), + allow_edit: options.allow_edit.unwrap_or(false), + allow_upload: options.allow_upload.unwrap_or(false), + upload_only: options.upload_only.unwrap_or(false), + }; + + mount + .create_share(request) + .await + .map_err(|e| e.to_string()) +} + /// The TaskId defined in AppxManifest.xml for the startup task #[cfg(windows)] const STARTUP_TASK_ID: &str = "cloudreve"; diff --git a/desktop/src-tauri/src/event_handler.rs b/desktop/src-tauri/src/event_handler.rs index 062932ad..2d18690b 100644 --- a/desktop/src-tauri/src/event_handler.rs +++ b/desktop/src-tauri/src/event_handler.rs @@ -3,6 +3,7 @@ use tauri::{AppHandle, Emitter}; use crate::commands::{ show_add_drive_window_impl, show_main_window_center, show_settings_window_impl, + show_share_window_impl, }; /// Handle incoming events from the event broadcaster. @@ -15,6 +16,12 @@ pub fn handle_event(app_handle: &AppHandle, event: &Event) { } Event::OpenSyncStatusWindow => handle_open_sync_status_window(app_handle), Event::OpenSettingsWindow => handle_open_settings_window(app_handle), + Event::OpenShareDialog { + drive_id, + uri, + name, + is_dir, + } => show_share_window_impl(app_handle, drive_id, uri, name, *is_dir), } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6356e011..4986e353 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -469,6 +469,7 @@ pub fn run() { commands::get_drives_info, commands::get_file_icon, commands::show_file_in_explorer, + commands::create_share, commands::show_add_drive_window, commands::show_reauthorize_window, commands::show_settings_window, diff --git a/desktop/ui/public/locales/de/common.json b/desktop/ui/public/locales/de/common.json index 2d231a3d..8a6e0304 100644 --- a/desktop/ui/public/locales/de/common.json +++ b/desktop/ui/public/locales/de/common.json @@ -136,5 +136,28 @@ "monthsAgo": "Vor {{value}} Monaten", "yearAgo": "Vor {{value}} Jahr", "yearsAgo": "Vor {{value}} Jahren" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/en-US/common.json b/desktop/ui/public/locales/en-US/common.json index 001adb21..b8c73f0f 100644 --- a/desktop/ui/public/locales/en-US/common.json +++ b/desktop/ui/public/locales/en-US/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} months ago", "yearAgo": "{{value}} year ago", "yearsAgo": "{{value}} years ago" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/es/common.json b/desktop/ui/public/locales/es/common.json index 75efb86a..a68a318d 100644 --- a/desktop/ui/public/locales/es/common.json +++ b/desktop/ui/public/locales/es/common.json @@ -136,5 +136,28 @@ "monthsAgo": "Hace {{value}} meses", "yearAgo": "Hace {{value}} año", "yearsAgo": "Hace {{value}} años" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/fr/common.json b/desktop/ui/public/locales/fr/common.json index 69aa463d..f01211e3 100644 --- a/desktop/ui/public/locales/fr/common.json +++ b/desktop/ui/public/locales/fr/common.json @@ -136,5 +136,28 @@ "monthsAgo": "Il y a {{value}} mois", "yearAgo": "Il y a {{value}} an", "yearsAgo": "Il y a {{value}} ans" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/it/common.json b/desktop/ui/public/locales/it/common.json index f9e05707..d0c42cc6 100644 --- a/desktop/ui/public/locales/it/common.json +++ b/desktop/ui/public/locales/it/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} mesi fa", "yearAgo": "{{value}} anno fa", "yearsAgo": "{{value}} anni fa" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/ja/common.json b/desktop/ui/public/locales/ja/common.json index 233b190c..6acd23bb 100644 --- a/desktop/ui/public/locales/ja/common.json +++ b/desktop/ui/public/locales/ja/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} ヶ月前", "yearAgo": "{{value}} 年前", "yearsAgo": "{{value}} 年前" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/ko/common.json b/desktop/ui/public/locales/ko/common.json index e13e89ff..705f90d0 100644 --- a/desktop/ui/public/locales/ko/common.json +++ b/desktop/ui/public/locales/ko/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}}개월 전", "yearAgo": "{{value}}년 전", "yearsAgo": "{{value}}년 전" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/pl/common.json b/desktop/ui/public/locales/pl/common.json index e6edf386..f5f01edd 100644 --- a/desktop/ui/public/locales/pl/common.json +++ b/desktop/ui/public/locales/pl/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} miesięcy temu", "yearAgo": "{{value}} rok temu", "yearsAgo": "{{value}} lat temu" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/ru/common.json b/desktop/ui/public/locales/ru/common.json index 6eb913fc..8bd40ab1 100644 --- a/desktop/ui/public/locales/ru/common.json +++ b/desktop/ui/public/locales/ru/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} месяцев назад", "yearAgo": "{{value}} год назад", "yearsAgo": "{{value}} лет назад" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/public/locales/zh-CN/common.json b/desktop/ui/public/locales/zh-CN/common.json index c4bbe0be..9c7e75e9 100644 --- a/desktop/ui/public/locales/zh-CN/common.json +++ b/desktop/ui/public/locales/zh-CN/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} 个月前", "yearAgo": "{{value}} 年前", "yearsAgo": "{{value}} 年前" + }, + "share": { + "title": "分享 {{name}} 的链接", + "linkReady": "分享链接已创建:", + "permission": "分享权限", + "permViewDownload": "查看 + 下载", + "permPreviewOnly": "仅预览(禁止下载)", + "permAllowEdit": "查看 + 下载 + 编辑", + "permUploadOnly": "仅上传(收集箱)", + "expires": "过期时间", + "expire_never": "永不过期", + "expire_oneHour": "1 小时", + "expire_oneDay": "1 天", + "expire_sevenDays": "7 天", + "expire_thirtyDays": "30 天", + "passwordProtect": "密码保护", + "password": "密码", + "downloadLimit": "下载次数限制", + "unlimited": "不限", + "create": "创建分享", + "cancel": "取消", + "done": "完成", + "copied": "链接已复制到剪贴板" } } diff --git a/desktop/ui/public/locales/zh-TW/common.json b/desktop/ui/public/locales/zh-TW/common.json index 12a60dcb..fdd48619 100644 --- a/desktop/ui/public/locales/zh-TW/common.json +++ b/desktop/ui/public/locales/zh-TW/common.json @@ -136,5 +136,28 @@ "monthsAgo": "{{value}} 個月前", "yearAgo": "{{value}} 年前", "yearsAgo": "{{value}} 年前" + }, + "share": { + "title": "Share link for {{name}}", + "linkReady": "Your share link is ready:", + "permission": "Sharing permissions", + "permViewDownload": "View + Download", + "permPreviewOnly": "Preview only (no download)", + "permAllowEdit": "View + Download + Edit", + "permUploadOnly": "Upload only (drop box)", + "expires": "Expires", + "expire_never": "Never expires", + "expire_oneHour": "1 hour", + "expire_oneDay": "1 day", + "expire_sevenDays": "7 days", + "expire_thirtyDays": "30 days", + "passwordProtect": "Protect with password", + "password": "Password", + "downloadLimit": "Download limit", + "unlimited": "Unlimited", + "create": "Create share", + "cancel": "Cancel", + "done": "Done", + "copied": "Link copied to clipboard" } } diff --git a/desktop/ui/src/App.tsx b/desktop/ui/src/App.tsx index b4450f44..b0ff647c 100644 --- a/desktop/ui/src/App.tsx +++ b/desktop/ui/src/App.tsx @@ -15,6 +15,7 @@ import { createAppTheme } from "./theme"; import AddDrive from "./pages/AddDrive"; import Popup from "./pages/popup"; import Settings from "./pages/settings"; +import Share from "./pages/Share"; function LoadingFallback() { return ( @@ -47,6 +48,7 @@ function App() { } /> } /> } /> + } /> diff --git a/desktop/ui/src/pages/Share.tsx b/desktop/ui/src/pages/Share.tsx new file mode 100644 index 00000000..172add15 --- /dev/null +++ b/desktop/ui/src/pages/Share.tsx @@ -0,0 +1,259 @@ +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + FormControlLabel, + IconButton, + InputAdornment, + MenuItem, + Snackbar, + Typography, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; +import { DenseFilledTextField } from "../common/StyledComponent"; + +type Permission = "view" | "preview" | "edit" | "upload"; + +const EXPIRE_OPTIONS = [ + { value: 0, label: "never" }, + { value: 3600, label: "oneHour" }, + { value: 86400, label: "oneDay" }, + { value: 604800, label: "sevenDays" }, + { value: 2592000, label: "thirtyDays" }, +] as const; + +export default function Share() { + const { t } = useTranslation(); + const [params] = useSearchParams(); + const driveId = params.get("drive") ?? ""; + const uri = params.get("uri") ?? ""; + const name = params.get("name") ?? ""; + const isDir = params.get("dir") === "1"; + + const [permission, setPermission] = useState("view"); + const [expire, setExpire] = useState(0); + const [usePassword, setUsePassword] = useState(false); + const [password, setPassword] = useState(""); + const [downloads, setDownloads] = useState(""); + const [creating, setCreating] = useState(false); + const [shareUrl, setShareUrl] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const createShare = async () => { + setCreating(true); + setError(null); + try { + const url = await invoke("create_share", { + driveId, + uri, + options: { + password: usePassword && password ? password : null, + downloads: downloads ? parseInt(downloads, 10) : null, + expire: expire > 0 ? expire : null, + previewOnly: permission === "preview", + allowEdit: permission === "edit", + allowUpload: permission === "upload", + uploadOnly: permission === "upload", + }, + }); + setShareUrl(url); + } catch (e) { + setError(String(e)); + } finally { + setCreating(false); + } + }; + + const copyLink = async () => { + if (!shareUrl) return; + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + }; + + return ( + + {/* Title bar with drag region */} + + + {t("share.title", { name })} + + getCurrentWindow().close()} + sx={{ WebkitAppRegion: "no-drag", appRegion: "no-drag" }} + > + + + + + + {shareUrl ? ( + <> + + {t("share.linkReady")} + + + + + + + ), + }, + }} + sx={{ mt: 1.5 }} + onFocus={(e) => e.target.select()} + /> + + ) : ( + <> + setPermission(e.target.value as Permission)} + sx={{ mt: 1 }} + > + {t("share.permViewDownload")} + {t("share.permPreviewOnly")} + {t("share.permAllowEdit")} + {isDir && ( + {t("share.permUploadOnly")} + )} + + + setExpire(Number(e.target.value))} + sx={{ mt: 2 }} + > + {EXPIRE_OPTIONS.map((o) => ( + + {t(`share.expire_${o.label}`)} + + ))} + + + setUsePassword(e.target.checked)} + size="small" + /> + } + label={t("share.passwordProtect")} + sx={{ mt: 1.5, display: "flex" }} + /> + {usePassword && ( + setPassword(e.target.value)} + slotProps={{ htmlInput: { maxLength: 32 } }} + sx={{ mt: 1 }} + /> + )} + + setDownloads(e.target.value)} + placeholder={t("share.unlimited")} + slotProps={{ htmlInput: { min: 1 } }} + sx={{ mt: 2 }} + /> + + )} + + {error && ( + + {error} + + )} + + + {/* Footer */} + + {shareUrl ? ( + + ) : ( + <> + + + + )} + + + setCopied(false)} + message={t("share.copied")} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}