diff --git a/Cargo.lock b/Cargo.lock index 0c02db1..82e9605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,27 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -431,6 +452,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -581,6 +608,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -1013,13 +1049,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1210,6 +1252,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.2" @@ -1248,6 +1296,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1289,6 +1343,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1305,6 +1365,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1541,6 +1607,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1739,6 +1815,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1754,6 +1841,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2069,6 +2165,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2437,15 +2549,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mtgc" version = "0.1.0" dependencies = [ + "image", "mtgc-core", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", @@ -2547,6 +2671,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2690,6 +2823,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -2949,6 +3083,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -3053,6 +3197,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -3304,6 +3459,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -4559,6 +4726,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.10" @@ -4816,6 +4998,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -5180,6 +5376,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5481,6 +5688,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -5611,6 +5888,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -6096,6 +6379,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -6167,6 +6468,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.3" @@ -6337,6 +6655,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.13.1" diff --git a/crates/mtgc-core/src/http.rs b/crates/mtgc-core/src/http.rs index a63b633..a3d9a27 100644 --- a/crates/mtgc-core/src/http.rs +++ b/crates/mtgc-core/src/http.rs @@ -51,3 +51,24 @@ pub(crate) fn get_json(url: &str) -> Result { .call()? .into_json()?) } + +/// Most bytes [`fetch_image`] will read. Scryfall's largest card scans are a +/// few hundred kilobytes; this only stops a bad URL streaming forever. +const MAX_IMAGE_BYTES: u64 = 20 << 20; + +/// GET a card image (a Scryfall scan) as bytes, for sharing or copying it. +/// HTTPS only. +pub fn fetch_image(url: &str) -> Result> { + if !url.starts_with("https://") { + return Err(crate::error::CoreError::Other(format!("not an https image URL: {url}"))); + } + let mut bytes = Vec::new(); + agent() + .get(url) + .set("User-Agent", USER_AGENT) + .call()? + .into_reader() + .take(MAX_IMAGE_BYTES) + .read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index d1e98f2..d98a43d 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -28,6 +28,7 @@ use std::path::Path; use rusqlite::Connection; pub use error::{CoreError, Result}; +pub use http::fetch_image; pub use model::{ CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy, LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5613a8c..0e10973 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,11 @@ mtgc-core = { path = "../crates/mtgc-core" } tauri-plugin-opener = "2.5.4" url = "2" +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +# Copying a card image: the clipboard takes raw pixels, and Scryfall serves JPEGs. +tauri-plugin-clipboard-manager = "2" +image = { version = "0.25", default-features = false, features = ["jpeg"] } + [target.'cfg(target_os = "android")'.dependencies] # Delivers sync sign-ins back to the app through the `mtgc://` scheme. tauri-plugin-deep-link = "2" diff --git a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt new file mode 100644 index 0000000..1225224 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SharePlugin.kt @@ -0,0 +1,51 @@ +package com.garrettmills.mtgc + +import android.app.Activity +import android.content.ClipData +import android.content.Intent +import androidx.core.content.FileProvider +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.Plugin +import java.io.File + +@InvokeArg +class ShareImageArgs { + /** A JPEG under the app's cache dir, which the FileProvider exposes. */ + lateinit var path: String + var title: String? = null +} + +/** + * The Android half of the app's `share` plugin (src-tauri/src/share.rs): + * opens the system share sheet for a card image the Rust side has saved. + */ +@TauriPlugin +class SharePlugin(private val activity: Activity) : Plugin(activity) { + @Command + fun shareImage(invoke: Invoke) { + try { + val args = invoke.parseArgs(ShareImageArgs::class.java) + val uri = FileProvider.getUriForFile( + activity, + "${activity.packageName}.fileprovider", + File(args.path), + ) + val send = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, uri) + args.title?.let { putExtra(Intent.EXTRA_TITLE, it) } + // ClipData lets the chooser show a preview of the image, and carries + // the read grant along with it. + clipData = ClipData.newRawUri(args.title, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + activity.startActivity(Intent.createChooser(send, args.title)) + invoke.resolve() + } catch (ex: Exception) { + invoke.reject(ex.message) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0e712d..f9f69a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod libraries; mod news; +mod share; mod state; mod sync; @@ -38,7 +39,10 @@ pub(crate) fn logged(what: &str, result: Result) -> Result = Result; + +/// Scryfall serves each scan at several sizes under the same path; the +/// previews show "normal", but a shared or pasted image deserves "large". +fn large_variant(url: &str) -> String { + if url.starts_with("https://cards.scryfall.io/normal/") { + url.replacen("/normal/", "/large/", 1) + } else { + url.to_string() + } +} + +/// Copy (desktop) or share (Android) the card image at `url`. Returns what +/// happened — "copied" or "shared" — for the UI to confirm. +#[tauri::command] +pub async fn share_card_image(app: AppHandle, url: String, name: String) -> Res<&'static str> { + crate::sync::blocking(move || { + let bytes = mtgc_core::fetch_image(&large_variant(&url)).map_err(|e| e.to_string())?; + deliver(&app, &bytes, &name) + }) + .await +} + +#[cfg(desktop)] +fn deliver(app: &AppHandle, bytes: &[u8], _name: &str) -> Res<&'static str> { + use tauri_plugin_clipboard_manager::ClipboardExt; + // The clipboard takes raw pixels, and Scryfall's scans are JPEGs. + let rgba = image::load_from_memory(bytes).map_err(|e| e.to_string())?.to_rgba8(); + let (width, height) = rgba.dimensions(); + app.clipboard() + .write_image(&tauri::image::Image::new(rgba.as_raw(), width, height)) + .map_err(|e| e.to_string())?; + Ok("copied") +} + +#[cfg(target_os = "android")] +fn deliver(app: &AppHandle, bytes: &[u8], name: &str) -> Res<&'static str> { + use tauri::Manager; + // The cache dir is what the FileProvider exposes (res/xml/file_paths.xml). + // Named after the card, since that's what the receiving app shows. + let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("shared"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let file_name: String = name + .chars() + .map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }) + .collect(); + let path = dir.join(format!("{}.jpg", file_name.trim())); + std::fs::write(&path, bytes).map_err(|e| e.to_string())?; + + let plugin = app.state::(); + plugin + .0 + .run_mobile_plugin::<()>( + "shareImage", + serde_json::json!({ "path": path.to_string_lossy(), "title": name }), + ) + .map_err(|e| e.to_string())?; + Ok("shared") +} + +#[cfg(target_os = "ios")] +fn deliver(_app: &AppHandle, _bytes: &[u8], _name: &str) -> Res<&'static str> { + Err("Sharing isn't available on iOS yet.".into()) +} + +/// The Kotlin half's handle, for `run_mobile_plugin`. +#[cfg(target_os = "android")] +struct SharePlugin(tauri::plugin::PluginHandle); + +/// Registers the Android share plugin. A no-op plugin elsewhere. +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("share") + .setup(|_app, _api| { + #[cfg(target_os = "android")] + { + use tauri::Manager; + let handle = _api.register_android_plugin("com.garrettmills.mtgc", "SharePlugin")?; + _app.manage(SharePlugin(handle)); + } + Ok(()) + }) + .build() +} diff --git a/src/api.ts b/src/api.ts index a32294e..7ee5317 100644 --- a/src/api.ts +++ b/src/api.ts @@ -508,6 +508,9 @@ export const api = { isCommander = false, isMaybeboard = false, ) => invoke("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }), + /** Copy (desktop) or share (Android) a card image; says which happened. */ + shareCardImage: (url: string, name: string) => + invoke<"copied" | "shared">("share_card_image", { url, name }), deckContents: (deckId: number) => invoke("deck_contents", { deckId }), suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) => invoke("suggest_cards", { deckId, offset, options }), diff --git a/src/components.tsx b/src/components.tsx index f9ac182..1c0f7dd 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -98,6 +98,15 @@ const ICON_UPLOAD = icon( , ); +const ICON_SHARE = icon( + <> + + + + + , +); +const ICON_CHECK = icon(); // A box (as on the "Not owned" filter) with a plus: add to the library. const ICON_LIBRARY_ADD = icon( <> @@ -156,6 +165,8 @@ export const Icons = { download: ICON_DOWNLOAD, upload: ICON_UPLOAD, libraryAdd: ICON_LIBRARY_ADD, + share: ICON_SHARE, + check: ICON_CHECK, }; // --- Dialogs ------------------------------------------------------------ @@ -1294,6 +1305,46 @@ function PreviewPaneToggle({ ); } +// Android gets the system share sheet; desktop copies the image instead. +const CAN_SHARE_SHEET = /Android/i.test(navigator.userAgent); + +/** Shares the card image being shown — copies it on desktop — and confirms + * with a brief check mark (or the error, in the tooltip). */ +function ShareImageButton({ url, name, overlay }: { url: string; name: string; overlay?: boolean }) { + const [state, setState] = useState<"idle" | "busy" | "done" | { error: string }>("idle"); + useEffect(() => { + if (state !== "done" && typeof state !== "object") return; + const t = setTimeout(() => setState("idle"), state === "done" ? 1500 : 4000); + return () => clearTimeout(t); + }, [state]); + const idleLabel = CAN_SHARE_SHEET ? "Share image" : "Copy image"; + const label = + state === "done" + ? CAN_SHARE_SHEET + ? "Shared" + : "Copied" + : typeof state === "object" + ? `Couldn't ${CAN_SHARE_SHEET ? "share" : "copy"} the image: ${state.error}` + : idleLabel; + return ( + { + setState("busy"); + try { + await api.shareCardImage(url, name); + setState("done"); + } catch (err) { + setState({ error: String(err) }); + } + }} + /> + ); +} + function ClickPreviewModal({ state, onClose, @@ -1309,6 +1360,8 @@ function ClickPreviewModal({ const [art, setArt] = useState(null); const card: PreviewCard = art ? { ...state.card, ...art } : state.card; const hasBack = !!card.back_image_uri; + // What sharing sends: the face being looked at. + const shownImage = showBack && hasBack ? card.back_image_uri : card.image_uri; const backdropRef = useRef(null); const modalRef = useRef(null); const infoRef = useRef(null); @@ -1423,6 +1476,9 @@ function ClickPreviewModal({ + + + {hasBack && (