feat: add hierarchical folder browser for library creation

- Extend API /folders endpoint to support browsing subdirectories with path parameter
- Add depth and has_children fields to FolderItem
- Create FolderBrowser component with tree view navigation
- Create FolderPicker component with input, browse button and popup modal
- Add API proxy route for /api/folders
- Update LibraryForm to use new FolderPicker component
- Fix path handling to correctly resolve /libraries/ subdirectories
This commit is contained in:
2026-03-06 18:03:09 +01:00
parent 7cdc72b6e1
commit 4f6833b42b
9 changed files with 462 additions and 42 deletions

View File

@@ -40,6 +40,8 @@ pub struct IndexJobResponse {
pub struct FolderItem {
pub name: String,
pub path: String,
pub depth: usize,
pub has_children: bool,
}
#[derive(Serialize, ToSchema)]
@@ -194,10 +196,14 @@ fn get_libraries_root() -> String {
}
/// List available folders in /libraries for library creation
/// Supports browsing subdirectories via optional path parameter
#[utoipa::path(
get,
path = "/folders",
tag = "indexing",
params(
("path" = Option<String>, Query, description = "Optional subdirectory path to browse (e.g., '/libraries/manga/action')"),
),
responses(
(status = 200, body = Vec<FolderItem>),
(status = 401, description = "Unauthorized"),
@@ -205,18 +211,73 @@ fn get_libraries_root() -> String {
),
security(("Bearer" = []))
)]
pub async fn list_folders(State(_state): State<AppState>) -> Result<Json<Vec<FolderItem>>, ApiError> {
pub async fn list_folders(
State(_state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<FolderItem>>, ApiError> {
let libraries_root = get_libraries_root();
let libraries_path = std::path::Path::new(&libraries_root);
let base_path = std::path::Path::new(&libraries_root);
// Determine which path to browse
let target_path = if let Some(sub_path) = params.get("path") {
// Validate the path to prevent directory traversal attacks
if sub_path.contains("..") || sub_path.contains("~") {
return Err(ApiError::bad_request("Invalid path"));
}
// Remove /libraries/ prefix if present since base_path is already /libraries
let cleaned_path = sub_path.trim_start_matches("/libraries/").trim_start_matches('/');
if cleaned_path.is_empty() {
base_path.to_path_buf()
} else {
base_path.join(cleaned_path)
}
} else {
base_path.to_path_buf()
};
// Ensure the path is within the libraries root
let canonical_target = target_path.canonicalize().unwrap_or(target_path.clone());
let canonical_base = base_path.canonicalize().unwrap_or(base_path.to_path_buf());
if !canonical_target.starts_with(&canonical_base) {
return Err(ApiError::bad_request("Path is outside libraries root"));
}
let mut folders = Vec::new();
let depth = if params.get("path").is_some() {
canonical_target.strip_prefix(&canonical_base)
.map(|p| p.components().count())
.unwrap_or(0)
} else {
0
};
if let Ok(entries) = std::fs::read_dir(libraries_path) {
if let Ok(entries) = std::fs::read_dir(&canonical_target) {
for entry in entries.flatten() {
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let name = entry.file_name().to_string_lossy().to_string();
// Check if this folder has children
let has_children = if let Ok(sub_entries) = std::fs::read_dir(entry.path()) {
sub_entries.flatten().any(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
})
} else {
false
};
// Calculate the full path relative to libraries root
let full_path = if let Ok(relative) = entry.path().strip_prefix(&canonical_base) {
format!("/libraries/{}", relative.to_string_lossy())
} else {
format!("/libraries/{}", name)
};
folders.push(FolderItem {
name: name.clone(),
path: format!("/libraries/{}", name),
name,
path: full_path,
depth,
has_children,
});
}
}