feat(desktop): FileCloud-style share dialog from Explorer context menu (#160) (#239)

* 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>
pull/3593/head
Tomáš Dvořák 2 weeks ago committed by GitHub
parent 1a6f13fd09
commit de40dde4bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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] 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] #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/<id>`) 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] 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/<id>`) 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) ## 7. Phase E — Android app (native, no iOS)

@ -21,9 +21,32 @@ pub struct ListShareResponse {
pub pagination: PaginationResults, pub pagination: PaginationResults,
} }
/// Share create service. Only the target URI is required; the server applies /// Share create service. Only the target URI is required; omitted fields fall
/// public-link defaults for all other fields. /// back to the server's public-link defaults.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ShareCreateService { pub struct ShareCreateService {
pub uri: String, 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<String>,
/// Remaining download quota; 0/absent = unlimited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<i32>,
/// Seconds until the share expires; 0/absent = never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expire: Option<i32>,
/// 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,
} }

@ -156,6 +156,10 @@ pub enum ManagerCommand {
CopyShareLink { CopyShareLink {
path: PathBuf, path: PathBuf,
}, },
/// Open the share dialog for a file or folder
ShareLink {
path: PathBuf,
},
PersistConfig, PersistConfig,
GenerateThumbnail { GenerateThumbnail {
path: PathBuf, path: PathBuf,
@ -450,22 +454,34 @@ impl Mount {
Ok(thumb_response.bytes().await?) Ok(thumb_response.bytes().await?)
} }
/// Create a public share link for the file or folder at `path`, /// Resolve a mount-space local path to its cloudreve URI.
/// returning the share URL. pub async fn share_uri(&self, path: PathBuf) -> Result<String> {
pub async fn create_share_link(&self, path: PathBuf) -> Result<String> {
// `path` arrives in mount space from the file manager, so the URI root // `path` arrives in mount space from the file manager, so the URI root
// is `sync_path` — mount and store share the same relative layout. // is `sync_path` — mount and store share the same relative layout.
let (sync_path, remote_base) = { let (sync_path, remote_base) = {
let config = self.config.read().await; let config = self.config.read().await;
(config.sync_path.clone(), config.remote_path.to_string()) (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")? .context("failed to convert local path to cloudreve uri")?
.to_string(); .to_string();
Ok(self Ok(uri)
.cr_client }
.create_share(&ShareCreateService { uri })
.await?) /// 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<String> {
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<String> {
Ok(self.cr_client.create_share(&request).await?)
} }
pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> { pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> {

@ -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 => { ManagerCommand::PersistConfig => {
let result = manager.persist().await; let result = manager.persist().await;
if let Err(e) = result { if let Err(e) = result {
@ -236,6 +245,29 @@ impl DriveManager {
Ok(()) 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 /// Handle ShowConflictToast command
pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> { pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> {
tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command"); tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command");

@ -15,6 +15,13 @@ pub enum Event {
OpenSyncStatusWindow, OpenSyncStatusWindow,
/// Request to open the settings window /// Request to open the settings window
OpenSettingsWindow, 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 { impl Event {
@ -24,6 +31,7 @@ impl Event {
Event::NoDrive {} => "NoDrive", Event::NoDrive {} => "NoDrive",
Event::OpenSyncStatusWindow => "OpenSyncStatusWindow", Event::OpenSyncStatusWindow => "OpenSyncStatusWindow",
Event::OpenSettingsWindow => "OpenSettingsWindow", Event::OpenSettingsWindow => "OpenSettingsWindow",
Event::OpenShareDialog { .. } => "OpenShareDialog",
} }
} }
} }
@ -92,6 +100,16 @@ impl EventBroadcaster {
self.broadcast(Event::OpenSettingsWindow); 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 /// Get the number of active subscribers
pub fn subscriber_count(&self) -> usize { pub fn subscriber_count(&self) -> usize {
self.sender.receiver_count() self.sender.receiver_count()

@ -18,6 +18,7 @@ pub mod uploader;
pub mod utils; pub mod utils;
// Re-export commonly used types // Re-export commonly used types
pub use cloudreve_api::models::share::ShareCreateService;
pub use cloudreve_api::normalize_site_url; pub use cloudreve_api::normalize_site_url;
pub use config::{AppConfig, ConfigManager}; pub use config::{AppConfig, ConfigManager};
pub use drive::manager::{ pub use drive::manager::{

@ -12,7 +12,7 @@ mod view_online;
pub use explorer_command::CrExplorerCommandHandler; pub use explorer_command::CrExplorerCommandHandler;
pub use factory::CrExplorerCommandFactory; pub use factory::CrExplorerCommandFactory;
pub use resolve_conflict::ResolveConflictCommandHandler; pub use resolve_conflict::ResolveConflictCommandHandler;
pub use share_link::ShareLinkCommandHandler; pub use share_link::{CopyShareLinkCommandHandler, ShareLinkCommandHandler};
pub use sub_commands::SubCommands; pub use sub_commands::SubCommands;
pub use sync_now::SyncNowCommandHandler; pub use sync_now::SyncNowCommandHandler;
pub use view_online::ViewOnlineCommandHandler; pub use view_online::ViewOnlineCommandHandler;

@ -11,14 +11,66 @@ use windows::{
/// shlguid.h: SID_SFolderView — resolves IFolderView from the command site. /// shlguid.h: SID_SFolderView — resolves IFolderView from the command site.
const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce); const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce);
#[implement(IExplorerCommand, IObjectWithSite)] /// Resolve the invoked filesystem path from either the selection or, for
pub struct ShareLinkCommandHandler { /// folder-background invokes, the folder behind the command site.
fn resolve_invoke_path(
selection: Option<&IShellItemArray>,
site: &Mutex<Option<IUnknown>>,
) -> Result<Option<PathBuf>> {
if let Some(items) = selection {
unsafe {
let count = items.GetCount()?;
if count != 1 {
return Ok(None);
}
let item = items.GetItemAt(0)?;
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
return Ok(Some(PathBuf::from(display_name.to_string()?)));
}
}
// 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()?)))
}
}
fn item_count_state(items: Option<&IShellItemArray>) -> Result<u32> {
let Some(items) = items else {
// Not selecting anything, but still triggered from a folder
return Ok(ECS_ENABLED.0 as u32);
};
unsafe {
let count = items.GetCount()?;
if count <= 1 {
Ok(ECS_ENABLED.0 as u32)
} else {
Ok(ECS_HIDDEN.0 as u32)
}
}
}
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<DriveManager>, drive_manager: Arc<DriveManager>,
app_root: AppRoot, app_root: AppRoot,
site: Mutex<Option<IUnknown>>, site: Mutex<Option<IUnknown>>,
} }
impl ShareLinkCommandHandler { impl $name {
pub fn new(drive_manager: Arc<DriveManager>, app_root: AppRoot) -> Self { pub fn new(drive_manager: Arc<DriveManager>, app_root: AppRoot) -> Self {
Self { Self {
drive_manager, drive_manager,
@ -26,23 +78,19 @@ impl ShareLinkCommandHandler {
site: Mutex::new(None), site: Mutex::new(None),
} }
} }
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");
}
} }
}
impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl { impl IObjectWithSite_Impl for $impl_name {
fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> { fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> {
*self.site.lock().unwrap() = punksite.cloned(); *self.site.lock().unwrap() = punksite.cloned();
Ok(()) Ok(())
} }
fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> { fn GetSite(
&self,
riid: *const GUID,
ppvsite: *mut *mut core::ffi::c_void,
) -> Result<()> {
let site = self.site.lock().unwrap(); let site = self.site.lock().unwrap();
if let Some(site) = site.as_ref() { if let Some(site) = site.as_ref() {
unsafe { site.query(riid, ppvsite) }.ok() unsafe { site.query(riid, ppvsite) }.ok()
@ -50,17 +98,17 @@ impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl {
Err(Error::from(E_FAIL)) Err(Error::from(E_FAIL))
} }
} }
} }
impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl { impl IExplorerCommand_Impl for $impl_name {
fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> { fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
let title = t!("copyShareLink"); let title = t!($title_key);
let hstring = HSTRING::from(title.as_ref()); let hstring = HSTRING::from(title.as_ref());
unsafe { SHStrDupW(&hstring) } unsafe { SHStrDupW(&hstring) }
} }
fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> { fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
let icon_path = format!("{}\\people.ico", self.app_root.image_path()); let icon_path = format!("{}\\{}", self.app_root.image_path(), $icon);
let hstring = HSTRING::from(icon_path); let hstring = HSTRING::from(icon_path);
unsafe { SHStrDupW(&hstring) } unsafe { SHStrDupW(&hstring) }
} }
@ -70,23 +118,15 @@ impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl {
} }
fn GetCanonicalName(&self) -> Result<GUID> { fn GetCanonicalName(&self) -> Result<GUID> {
Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b)) Ok(GUID::from_u128($guid))
} }
fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result<u32> { fn GetState(
let Some(items) = items else { &self,
// Not select anthing, but still triggerd from a folder items: Option<&IShellItemArray>,
return Ok(ECS_ENABLED.0 as u32); _oktobeslow: BOOL,
}; ) -> Result<u32> {
item_count_state(items)
unsafe {
let count = items.GetCount()?;
if count <= 1 {
Ok(ECS_ENABLED.0 as u32)
} else {
Ok(ECS_HIDDEN.0 as u32)
}
}
} }
fn Invoke( fn Invoke(
@ -94,38 +134,12 @@ impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl {
selection: Option<&IShellItemArray>, selection: Option<&IShellItemArray>,
_bindctx: Option<&IBindCtx>, _bindctx: Option<&IBindCtx>,
) -> Result<()> { ) -> Result<()> {
tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked"); tracing::debug!(target: "shellext::context_menu", concat!($title_key, " context menu command invoked"));
if let Some(items) = selection {
unsafe {
let count = items.GetCount()?;
if count != 1 {
return Ok(());
}
// Get the first item
let item = items.GetItemAt(0)?;
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
let path = PathBuf::from(display_name.to_string()?);
self.send_copy_share_link(path);
}
} 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); 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"));
} }
} }
@ -139,4 +153,24 @@ impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl {
fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> { fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> {
Err(Error::from(E_NOTIMPL)) 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
);

@ -1,6 +1,6 @@
use super::{ use super::{
ResolveConflictCommandHandler, ShareLinkCommandHandler, SyncNowCommandHandler, CopyShareLinkCommandHandler, ResolveConflictCommandHandler, ShareLinkCommandHandler,
ViewOnlineCommandHandler, SyncNowCommandHandler, ViewOnlineCommandHandler,
}; };
use crate::{drive::manager::DriveManager, utils::app::AppRoot}; use crate::{drive::manager::DriveManager, utils::app::AppRoot};
use std::sync::{Arc, Mutex}; 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_view_online_command, ViewOnlineCommandHandler);
sub_command_factory!(create_share_link_command, ShareLinkCommandHandler); 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_sync_now_command, SyncNowCommandHandler);
sub_command_factory!( sub_command_factory!(
create_resolve_conflict_command, create_resolve_conflict_command,
ResolveConflictCommandHandler ResolveConflictCommandHandler
); );
const SUB_COMMAND_FACTORIES: [SubCommandFactory; 4] = [ const SUB_COMMAND_FACTORIES: [SubCommandFactory; 5] = [
create_view_online_command,
create_share_link_command, create_share_link_command,
create_copy_share_link_command,
create_view_online_command,
create_sync_now_command, create_sync_now_command,
create_resolve_conflict_command, create_resolve_conflict_command,
]; ];

@ -11,6 +11,18 @@ viewOnline:
ru: "Просмотреть онлайн" ru: "Просмотреть онлайн"
pl: "Zobacz online" pl: "Zobacz online"
it: "Visualizza 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: copyShareLink:
en-US: "Copy share link" en-US: "Copy share link"
zh-CN: "复制分享链接" zh-CN: "复制分享链接"

@ -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<String>,
pub downloads: Option<i32>,
/// Seconds until expiry.
pub expire: Option<i32>,
pub preview_only: Option<bool>,
pub allow_edit: Option<bool>,
pub allow_upload: Option<bool>,
pub upload_only: Option<bool>,
}
/// 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<String> {
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 /// The TaskId defined in AppxManifest.xml for the startup task
#[cfg(windows)] #[cfg(windows)]
const STARTUP_TASK_ID: &str = "cloudreve"; const STARTUP_TASK_ID: &str = "cloudreve";

@ -3,6 +3,7 @@ use tauri::{AppHandle, Emitter};
use crate::commands::{ use crate::commands::{
show_add_drive_window_impl, show_main_window_center, show_settings_window_impl, show_add_drive_window_impl, show_main_window_center, show_settings_window_impl,
show_share_window_impl,
}; };
/// Handle incoming events from the event broadcaster. /// 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::OpenSyncStatusWindow => handle_open_sync_status_window(app_handle),
Event::OpenSettingsWindow => handle_open_settings_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),
} }
} }

@ -469,6 +469,7 @@ pub fn run() {
commands::get_drives_info, commands::get_drives_info,
commands::get_file_icon, commands::get_file_icon,
commands::show_file_in_explorer, commands::show_file_in_explorer,
commands::create_share,
commands::show_add_drive_window, commands::show_add_drive_window,
commands::show_reauthorize_window, commands::show_reauthorize_window,
commands::show_settings_window, commands::show_settings_window,

@ -136,5 +136,28 @@
"monthsAgo": "Vor {{value}} Monaten", "monthsAgo": "Vor {{value}} Monaten",
"yearAgo": "Vor {{value}} Jahr", "yearAgo": "Vor {{value}} Jahr",
"yearsAgo": "Vor {{value}} Jahren" "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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} months ago", "monthsAgo": "{{value}} months ago",
"yearAgo": "{{value}} year ago", "yearAgo": "{{value}} year ago",
"yearsAgo": "{{value}} years 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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "Hace {{value}} meses", "monthsAgo": "Hace {{value}} meses",
"yearAgo": "Hace {{value}} año", "yearAgo": "Hace {{value}} año",
"yearsAgo": "Hace {{value}} años" "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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "Il y a {{value}} mois", "monthsAgo": "Il y a {{value}} mois",
"yearAgo": "Il y a {{value}} an", "yearAgo": "Il y a {{value}} an",
"yearsAgo": "Il y a {{value}} ans" "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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} mesi fa", "monthsAgo": "{{value}} mesi fa",
"yearAgo": "{{value}} anno fa", "yearAgo": "{{value}} anno fa",
"yearsAgo": "{{value}} anni 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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} ヶ月前", "monthsAgo": "{{value}} ヶ月前",
"yearAgo": "{{value}} 年前", "yearAgo": "{{value}} 年前",
"yearsAgo": "{{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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}}개월 전", "monthsAgo": "{{value}}개월 전",
"yearAgo": "{{value}}년 전", "yearAgo": "{{value}}년 전",
"yearsAgo": "{{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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} miesięcy temu", "monthsAgo": "{{value}} miesięcy temu",
"yearAgo": "{{value}} rok temu", "yearAgo": "{{value}} rok temu",
"yearsAgo": "{{value}} lat 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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} месяцев назад", "monthsAgo": "{{value}} месяцев назад",
"yearAgo": "{{value}} год назад", "yearAgo": "{{value}} год назад",
"yearsAgo": "{{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"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} 个月前", "monthsAgo": "{{value}} 个月前",
"yearAgo": "{{value}} 年前", "yearAgo": "{{value}} 年前",
"yearsAgo": "{{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": "链接已复制到剪贴板"
} }
} }

@ -136,5 +136,28 @@
"monthsAgo": "{{value}} 個月前", "monthsAgo": "{{value}} 個月前",
"yearAgo": "{{value}} 年前", "yearAgo": "{{value}} 年前",
"yearsAgo": "{{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"
} }
} }

@ -15,6 +15,7 @@ import { createAppTheme } from "./theme";
import AddDrive from "./pages/AddDrive"; import AddDrive from "./pages/AddDrive";
import Popup from "./pages/popup"; import Popup from "./pages/popup";
import Settings from "./pages/settings"; import Settings from "./pages/settings";
import Share from "./pages/Share";
function LoadingFallback() { function LoadingFallback() {
return ( return (
@ -47,6 +48,7 @@ function App() {
<Route path="/reauthorize/:driveId/:siteUrl/:driveName" element={<AddDrive mode="reauthorize" />} /> <Route path="/reauthorize/:driveId/:siteUrl/:driveName" element={<AddDrive mode="reauthorize" />} />
<Route path="/popup" element={<Popup />} /> <Route path="/popup" element={<Popup />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/share" element={<Share />} />
</Routes> </Routes>
</HashRouter> </HashRouter>
</ThemeProvider> </ThemeProvider>

@ -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<Permission>("view");
const [expire, setExpire] = useState<number>(0);
const [usePassword, setUsePassword] = useState(false);
const [password, setPassword] = useState("");
const [downloads, setDownloads] = useState("");
const [creating, setCreating] = useState(false);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const createShare = async () => {
setCreating(true);
setError(null);
try {
const url = await invoke<string>("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 (
<Box
sx={{
height: "100vh",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Title bar with drag region */}
<Box
data-tauri-drag-region
sx={{
px: 2,
pt: 1.5,
pb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: 0,
}}
>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{t("share.title", { name })}
</Typography>
<IconButton
size="small"
onClick={() => getCurrentWindow().close()}
sx={{ WebkitAppRegion: "no-drag", appRegion: "no-drag" }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ flex: 1, overflow: "auto", px: 3, pb: 2 }}>
{shareUrl ? (
<>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t("share.linkReady")}
</Typography>
<DenseFilledTextField
fullWidth
value={shareUrl}
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={copyLink} edge="end">
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
},
}}
sx={{ mt: 1.5 }}
onFocus={(e) => e.target.select()}
/>
</>
) : (
<>
<DenseFilledTextField
select
fullWidth
label={t("share.permission")}
value={permission}
onChange={(e) => setPermission(e.target.value as Permission)}
sx={{ mt: 1 }}
>
<MenuItem value="view">{t("share.permViewDownload")}</MenuItem>
<MenuItem value="preview">{t("share.permPreviewOnly")}</MenuItem>
<MenuItem value="edit">{t("share.permAllowEdit")}</MenuItem>
{isDir && (
<MenuItem value="upload">{t("share.permUploadOnly")}</MenuItem>
)}
</DenseFilledTextField>
<DenseFilledTextField
select
fullWidth
label={t("share.expires")}
value={expire}
onChange={(e) => setExpire(Number(e.target.value))}
sx={{ mt: 2 }}
>
{EXPIRE_OPTIONS.map((o) => (
<MenuItem key={o.value} value={o.value}>
{t(`share.expire_${o.label}`)}
</MenuItem>
))}
</DenseFilledTextField>
<FormControlLabel
control={
<Checkbox
checked={usePassword}
onChange={(e) => setUsePassword(e.target.checked)}
size="small"
/>
}
label={t("share.passwordProtect")}
sx={{ mt: 1.5, display: "flex" }}
/>
{usePassword && (
<DenseFilledTextField
fullWidth
label={t("share.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
slotProps={{ htmlInput: { maxLength: 32 } }}
sx={{ mt: 1 }}
/>
)}
<DenseFilledTextField
fullWidth
label={t("share.downloadLimit")}
type="number"
value={downloads}
onChange={(e) => setDownloads(e.target.value)}
placeholder={t("share.unlimited")}
slotProps={{ htmlInput: { min: 1 } }}
sx={{ mt: 2 }}
/>
</>
)}
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
{/* Footer */}
<Box
sx={{
px: 3,
py: 2,
display: "flex",
justifyContent: "flex-end",
gap: 1,
flexShrink: 0,
}}
>
{shareUrl ? (
<Button variant="contained" onClick={() => getCurrentWindow().close()}>
{t("share.done")}
</Button>
) : (
<>
<Button onClick={() => getCurrentWindow().close()}>
{t("share.cancel")}
</Button>
<Button
variant="contained"
onClick={createShare}
disabled={creating || (usePassword && !password)}
startIcon={
creating ? <CircularProgress size={16} color="inherit" /> : null
}
>
{t("share.create")}
</Button>
</>
)}
</Box>
<Snackbar
open={copied}
autoHideDuration={2000}
onClose={() => setCopied(false)}
message={t("share.copied")}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</Box>
);
}
Loading…
Cancel
Save