Add real file loading: native file/folder picker + OS drag-and-drop, backed by real Rust commands (pick_files/pick_folder/expand_dropped_paths)
This commit is contained in:
Generated
+160
-1
@@ -2077,6 +2077,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2534,6 +2535,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2580,6 +2583,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rhai"
|
name = "rhai"
|
||||||
version = "1.26.0"
|
version = "1.26.0"
|
||||||
@@ -3288,6 +3315,64 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"toml 1.1.4+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -4303,6 +4388,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.53.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4336,13 +4430,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
|
"windows_aarch64_msvc 0.53.1",
|
||||||
|
"windows_i686_gnu 0.53.1",
|
||||||
|
"windows_i686_gnullvm 0.53.1",
|
||||||
|
"windows_i686_msvc 0.53.1",
|
||||||
|
"windows_x86_64_gnu 0.53.1",
|
||||||
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
|
"windows_x86_64_msvc 0.53.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4373,6 +4484,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4385,6 +4502,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4397,12 +4520,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4415,6 +4550,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4427,6 +4568,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4439,6 +4586,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -4451,6 +4604,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.5.40"
|
version = "0.5.40"
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
renamr-core = { path = "../crates/renamr-core" }
|
renamr-core = { path = "../crates/renamr-core" }
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
walkdir = "2"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default permissions for Renamr's main window.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "dialog:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub ext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hidden(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n.starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_file_entry(path: &Path) -> FileEntry {
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.map(|s| s.to_string_lossy().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
FileEntry {
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
name,
|
||||||
|
ext,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands a list of dropped/selected paths into the individual files they contain.
|
||||||
|
/// Directories are walked recursively (this is how a user dropping a whole show/season
|
||||||
|
/// folder ends up with every episode file listed); hidden files/dirs are skipped.
|
||||||
|
fn expand(paths: &[String]) -> Vec<FileEntry> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for raw in paths {
|
||||||
|
let path = Path::new(raw);
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
for entry in WalkDir::new(path)
|
||||||
|
.into_iter()
|
||||||
|
.filter_entry(|e| !is_hidden(e.path()) || e.path() == path)
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
if entry.file_type().is_file() {
|
||||||
|
let full = entry.path().to_string_lossy().to_string();
|
||||||
|
if seen.insert(full) {
|
||||||
|
out.push(to_file_entry(entry.path()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if path.is_file() {
|
||||||
|
let full = path.to_string_lossy().to_string();
|
||||||
|
if seen.insert(full) {
|
||||||
|
out.push(to_file_entry(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native "Select Files" dialog, mirroring the original's Load Files button.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pick_files(app: AppHandle) -> Vec<FileEntry> {
|
||||||
|
let selection = app.dialog().file().blocking_pick_files();
|
||||||
|
let paths: Vec<String> = selection
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|f| f.into_path().ok())
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
expand(&paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native "Select Folders" dialog; the folder's contents are expanded recursively.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn pick_folder(app: AppHandle) -> Vec<FileEntry> {
|
||||||
|
let selection = app.dialog().file().blocking_pick_folder();
|
||||||
|
let paths: Vec<String> = selection
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|f| f.into_path().ok())
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
expand(&paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands paths dropped onto the window (files and/or folders) into their files.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn expand_dropped_paths(paths: Vec<String>) -> Vec<FileEntry> {
|
||||||
|
expand(&paths)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
mod files;
|
||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
use renamr_core::format::ExpressionFormat;
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -16,7 +18,13 @@ fn render_expression(expression: String, bindings: HashMap<String, String>) -> R
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![render_expression])
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
render_expression,
|
||||||
|
files::pick_files,
|
||||||
|
files::pick_folder,
|
||||||
|
files::expand_dropped_paths,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Renamr");
|
.expect("error while running Renamr");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,52 @@
|
|||||||
background: #28515a;
|
background: #28515a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-menu-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #16262d;
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-menu-popup button {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-menu-popup button:hover {
|
||||||
|
background: var(--surface-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty-hint {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.rename-actions {
|
.rename-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+224
-63
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
ClearIcon,
|
ClearIcon,
|
||||||
@@ -12,44 +12,71 @@ import {
|
|||||||
UpIcon,
|
UpIcon,
|
||||||
} from "./Icons";
|
} from "./Icons";
|
||||||
|
|
||||||
interface SampleItem {
|
interface Item {
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
ext: string;
|
ext: string;
|
||||||
bindings: Record<string, string> | null; // null = no match (rendered muted, like the original)
|
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
|
||||||
|
isSample: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder sample batch standing in for a real folder scan + online match, which
|
interface FileEntry {
|
||||||
// aren't ported yet (see the porting roadmap). This is enough to show the two-list
|
path: string;
|
||||||
// layout, selection sync, and live naming-scheme preview working end-to-end.
|
name: string; // filename without extension
|
||||||
const SAMPLE_ITEMS: SampleItem[] = [
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
|
||||||
|
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
|
||||||
|
// This is what full metadata-matched renaming will look like once the matching engine
|
||||||
|
// (see the porting roadmap) is ported — real loaded files use their own filename fields
|
||||||
|
// instead (see realFileBindings below) since there's no online identification yet.
|
||||||
|
const SAMPLE_ITEMS: Item[] = [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "sample-1",
|
||||||
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
|
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
|
||||||
ext: "mkv",
|
ext: "mkv",
|
||||||
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
|
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
|
||||||
|
isSample: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "sample-2",
|
||||||
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
|
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
|
||||||
ext: "mkv",
|
ext: "mkv",
|
||||||
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
|
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
|
||||||
|
isSample: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "sample-3",
|
||||||
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
|
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
|
||||||
ext: "avi",
|
ext: "avi",
|
||||||
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
|
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
|
||||||
|
isSample: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "4",
|
id: "sample-4",
|
||||||
originalName: "random_video_file.mp4",
|
originalName: "random_video_file.mp4",
|
||||||
ext: "mp4",
|
ext: "mp4",
|
||||||
bindings: null,
|
bindings: null,
|
||||||
|
isSample: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
|
const SAMPLE_EXPRESSION = "{n} - {s00e00} - {t}";
|
||||||
|
// The matching engine isn't ported yet, so real loaded files only have their own
|
||||||
|
// filename-derived fields to preview with (mirrors the original's plain FileNameFormatter,
|
||||||
|
// used whenever a file has no online match).
|
||||||
|
const REAL_FILE_EXPRESSION = "{name}";
|
||||||
|
|
||||||
|
function realFileToItem(entry: FileEntry): Item {
|
||||||
|
return {
|
||||||
|
id: entry.path,
|
||||||
|
originalName: entry.ext ? `${entry.name}.${entry.ext}` : entry.name,
|
||||||
|
ext: entry.ext,
|
||||||
|
bindings: { name: entry.name, ext: entry.ext },
|
||||||
|
isSample: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function ListButton({
|
function ListButton({
|
||||||
icon,
|
icon,
|
||||||
@@ -67,29 +94,73 @@ function ListButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onOutside = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="load-menu" ref={ref}>
|
||||||
|
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => setOpen((v) => !v)} />
|
||||||
|
{open && (
|
||||||
|
<div className="load-menu-popup">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onFiles();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select Files…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onFolder();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select Folder…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RenamePanel() {
|
export default function RenamePanel() {
|
||||||
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
|
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
||||||
const [editingFormat, setEditingFormat] = useState(false);
|
const [editingFormat, setEditingFormat] = useState(false);
|
||||||
|
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||||
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
|
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
|
||||||
const [computeError, setComputeError] = useState<string | null>(null);
|
const [computeError, setComputeError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
||||||
|
// latest expression/items instead of the stale values from its first render.
|
||||||
|
const expressionRef = useRef(expression);
|
||||||
|
useEffect(() => {
|
||||||
|
expressionRef.current = expression;
|
||||||
|
}, [expression]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
SAMPLE_ITEMS.map(async (item) => {
|
items.map(async (item) => {
|
||||||
if (!item.bindings) return [item.id, null] as const;
|
if (!item.bindings) return [item.id, null] as const;
|
||||||
try {
|
const rendered = await invoke<string>("render_expression", {
|
||||||
const rendered = await invoke<string>("render_expression", {
|
expression,
|
||||||
expression,
|
bindings: item.bindings,
|
||||||
bindings: item.bindings,
|
});
|
||||||
});
|
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
||||||
return [item.id, `${rendered}.${item.ext}`] as const;
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.then((entries) => {
|
.then((entries) => {
|
||||||
@@ -109,7 +180,7 @@ export default function RenamePanel() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [expression]);
|
}, [expression, items]);
|
||||||
|
|
||||||
const notImplemented = (feature: string) => {
|
const notImplemented = (feature: string) => {
|
||||||
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
||||||
@@ -117,31 +188,128 @@ export default function RenamePanel() {
|
|||||||
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchedCount = useMemo(() => SAMPLE_ITEMS.filter((i) => i.bindings).length, []);
|
const addRealFiles = (entries: FileEntry[]) => {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
|
// Only auto-switch the naming scheme the first time real files replace the demo,
|
||||||
|
// and only if the user hasn't already customized it.
|
||||||
|
if (expressionRef.current === SAMPLE_EXPRESSION) {
|
||||||
|
setExpression(REAL_FILE_EXPRESSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((prev) => {
|
||||||
|
const real = prev.filter((i) => !i.isSample);
|
||||||
|
const existing = new Set(real.map((i) => i.id));
|
||||||
|
const added = entries.map(realFileToItem).filter((i) => !existing.has(i.id));
|
||||||
|
const next = [...real, ...added];
|
||||||
|
if (added.length > 0) setSelected(added[0].id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFiles = () => {
|
||||||
|
invoke<FileEntry[]>("pick_files")
|
||||||
|
.then(addRealFiles)
|
||||||
|
.catch((err) => setStatus(`Couldn't open the file picker: ${err}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFolder = () => {
|
||||||
|
invoke<FileEntry[]>("pick_folder")
|
||||||
|
.then(addRealFiles)
|
||||||
|
.catch((err) => setStatus(`Couldn't open the folder picker: ${err}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// OS drag-and-drop of files/folders onto the window.
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
import("@tauri-apps/api/webview")
|
||||||
|
.then(({ getCurrentWebview }) =>
|
||||||
|
getCurrentWebview().onDragDropEvent((event) => {
|
||||||
|
if (event.payload.type === "drop") {
|
||||||
|
invoke<FileEntry[]>("expand_dropped_paths", { paths: event.payload.paths })
|
||||||
|
.then(addRealFiles)
|
||||||
|
.catch((err) => setStatus(`Couldn't read dropped files: ${err}`));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop
|
||||||
|
// just won't be available there; the Load button still works via invoke.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeSelected = () => {
|
||||||
|
if (!selected) return notImplemented("Excluding items");
|
||||||
|
setItems((prev) => {
|
||||||
|
const idx = prev.findIndex((i) => i.id === selected);
|
||||||
|
const next = prev.filter((i) => i.id !== selected);
|
||||||
|
const fallback = next[Math.min(idx, next.length - 1)];
|
||||||
|
setSelected(fallback ? fallback.id : null);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
setItems([]);
|
||||||
|
setSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]);
|
||||||
|
|
||||||
|
const renderOriginalRow = (item: Item) => (
|
||||||
|
<Row
|
||||||
|
key={item.id}
|
||||||
|
selected={selected === item.id}
|
||||||
|
muted={!item.bindings}
|
||||||
|
onClick={() => setSelected(item.id)}
|
||||||
|
text={item.originalName}
|
||||||
|
badge={item.ext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNameRow = (item: Item) => {
|
||||||
|
const name = computedNames[item.id];
|
||||||
|
const [base, ext] = name ? splitExt(name) : [null, item.ext];
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
key={item.id}
|
||||||
|
selected={selected === item.id}
|
||||||
|
muted={!item.bindings}
|
||||||
|
onClick={() => setSelected(item.id)}
|
||||||
|
text={item.bindings ? base ?? "…" : "No data"}
|
||||||
|
badge={ext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rename-panel">
|
<div className="rename-panel">
|
||||||
<div className="rename-lists">
|
<div className="rename-lists">
|
||||||
<ListCard
|
<ListCard
|
||||||
title="Original Files"
|
title="Original Files"
|
||||||
rows={SAMPLE_ITEMS.map((item) => (
|
items={items}
|
||||||
<Row
|
renderRow={renderOriginalRow}
|
||||||
key={item.id}
|
emptyHint="Drop files or folders here, or click Load"
|
||||||
selected={selected === item.id}
|
|
||||||
muted={!item.bindings}
|
|
||||||
onClick={() => setSelected(item.id)}
|
|
||||||
text={item.originalName}
|
|
||||||
badge={item.ext}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
buttonRow={
|
buttonRow={
|
||||||
<>
|
<>
|
||||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
||||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||||
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||||
<span className="list-button-gap" />
|
<span className="list-button-gap" />
|
||||||
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={() => notImplemented("Excluding items")} />
|
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
||||||
<ListButton icon={<ClearIcon />} label="Clear" onClick={() => notImplemented("Clearing the list")} />
|
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
||||||
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
|
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
|
||||||
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
||||||
</>
|
</>
|
||||||
@@ -159,25 +327,14 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
<ListCard
|
<ListCard
|
||||||
title="New Names"
|
title="New Names"
|
||||||
rows={SAMPLE_ITEMS.map((item) => {
|
items={items}
|
||||||
const name = computedNames[item.id];
|
renderRow={renderNameRow}
|
||||||
const [base, ext] = name ? splitExt(name) : [null, item.ext];
|
emptyHint="Naming-scheme preview appears here"
|
||||||
return (
|
|
||||||
<Row
|
|
||||||
key={item.id}
|
|
||||||
selected={selected === item.id}
|
|
||||||
muted={!item.bindings}
|
|
||||||
onClick={() => setSelected(item.id)}
|
|
||||||
text={item.bindings ? base ?? "…" : "No match"}
|
|
||||||
badge={ext}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
buttonRow={
|
buttonRow={
|
||||||
<>
|
<>
|
||||||
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
|
||||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||||
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||||
<span className="list-button-gap" />
|
<span className="list-button-gap" />
|
||||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
|
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
|
||||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||||
@@ -197,9 +354,10 @@ export default function RenamePanel() {
|
|||||||
/>
|
/>
|
||||||
{computeError && <p className="format-error">{computeError}</p>}
|
{computeError && <p className="format-error">{computeError}</p>}
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Live preview against {matchedCount} matched sample file{matchedCount === 1 ? "" : "s"}. This runs the
|
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
|
||||||
real Rust naming-expression engine (Rhai-backed) through Tauri — the only piece of the renamer that's
|
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files only expose{" "}
|
||||||
fully implemented so far.
|
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) since the matching engine that would
|
||||||
|
populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/etc. from online metadata isn't built yet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -211,17 +369,23 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
function ListCard({
|
function ListCard({
|
||||||
title,
|
title,
|
||||||
rows,
|
items,
|
||||||
|
renderRow,
|
||||||
|
emptyHint,
|
||||||
buttonRow,
|
buttonRow,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
rows: ReactNode;
|
items: Item[];
|
||||||
|
renderRow: (item: Item) => ReactNode;
|
||||||
|
emptyHint: string;
|
||||||
buttonRow: ReactNode;
|
buttonRow: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="list-card">
|
<div className="list-card">
|
||||||
<span className="list-card-title">{title}</span>
|
<span className="list-card-title">{title}</span>
|
||||||
<div className="list-card-body">{rows}</div>
|
<div className="list-card-body">
|
||||||
|
{items.length === 0 ? <div className="list-empty-hint">{emptyHint}</div> : items.map(renderRow)}
|
||||||
|
</div>
|
||||||
<div className="list-button-row">{buttonRow}</div>
|
<div className="list-button-row">{buttonRow}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -241,10 +405,7 @@ function Row({
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`} onClick={onClick}>
|
||||||
className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<span className="list-row-text">{text}</span>
|
<span className="list-row-text">{text}</span>
|
||||||
{badge && <span className="list-row-badge">{badge}</span>}
|
{badge && <span className="list-row-badge">{badge}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user