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

16
Cargo.lock generated
View File

@@ -8,22 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "admin-ui"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"reqwest",
"serde",
"serde_json",
"stripstream-core",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.8.4" version = "0.8.4"

View File

@@ -40,6 +40,8 @@ pub struct IndexJobResponse {
pub struct FolderItem { pub struct FolderItem {
pub name: String, pub name: String,
pub path: String, pub path: String,
pub depth: usize,
pub has_children: bool,
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -194,10 +196,14 @@ fn get_libraries_root() -> String {
} }
/// List available folders in /libraries for library creation /// List available folders in /libraries for library creation
/// Supports browsing subdirectories via optional path parameter
#[utoipa::path( #[utoipa::path(
get, get,
path = "/folders", path = "/folders",
tag = "indexing", tag = "indexing",
params(
("path" = Option<String>, Query, description = "Optional subdirectory path to browse (e.g., '/libraries/manga/action')"),
),
responses( responses(
(status = 200, body = Vec<FolderItem>), (status = 200, body = Vec<FolderItem>),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -205,18 +211,73 @@ fn get_libraries_root() -> String {
), ),
security(("Bearer" = [])) 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_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 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() { for entry in entries.flatten() {
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let name = entry.file_name().to_string_lossy().to_string(); 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 { folders.push(FolderItem {
name: name.clone(), name,
path: format!("/libraries/{}", name), path: full_path,
depth,
has_children,
}); });
} }
} }

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const apiBaseUrl = process.env.API_BASE_URL || "http://api:8080";
const apiToken = process.env.API_BOOTSTRAP_TOKEN;
if (!apiToken) {
return NextResponse.json({ error: "API token not configured" }, { status: 500 });
}
try {
const { searchParams } = new URL(request.url);
const path = searchParams.get("path");
let apiUrl = `${apiBaseUrl}/folders`;
if (path) {
apiUrl += `?path=${encodeURIComponent(path)}`;
}
const response = await fetch(apiUrl, {
headers: {
Authorization: `Bearer ${apiToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: `API error: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Proxy error:", error);
return NextResponse.json({ error: "Failed to fetch folders" }, { status: 500 });
}
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useState, useCallback } from "react";
import { FolderItem } from "../../lib/api";
interface TreeNode extends FolderItem {
children?: TreeNode[];
isLoading?: boolean;
}
interface FolderBrowserProps {
initialFolders: FolderItem[];
selectedPath: string;
onSelect: (path: string) => void;
}
export function FolderBrowser({ initialFolders, selectedPath, onSelect }: FolderBrowserProps) {
// Convert initial folders to tree structure
const [tree, setTree] = useState<TreeNode[]>(
initialFolders.map(f => ({ ...f, children: f.has_children ? [] : undefined }))
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const loadChildren = useCallback(async (parentPath: string): Promise<FolderItem[]> => {
try {
const response = await fetch(`/api/folders?path=${encodeURIComponent(parentPath)}`);
if (response.ok) {
return await response.json();
}
} catch (error) {
console.error("Failed to load folders:", error);
}
return [];
}, []);
const findAndUpdateNode = (
nodes: TreeNode[],
targetPath: string,
updateFn: (node: TreeNode) => TreeNode
): TreeNode[] => {
return nodes.map(node => {
if (node.path === targetPath) {
return updateFn(node);
}
if (node.children) {
return { ...node, children: findAndUpdateNode(node.children, targetPath, updateFn) };
}
return node;
});
};
const toggleExpand = useCallback(async (node: TreeNode) => {
if (!node.has_children) {
onSelect(node.path);
return;
}
const isExpanded = expandedPaths.has(node.path);
if (isExpanded) {
// Collapse
setExpandedPaths(prev => {
const next = new Set(prev);
next.delete(node.path);
return next;
});
} else {
// Expand
setExpandedPaths(prev => new Set(prev).add(node.path));
// Load children if not already loaded
if (!node.children || node.children.length === 0) {
setTree(prev => findAndUpdateNode(prev, node.path, n => ({ ...n, isLoading: true })));
const children = await loadChildren(node.path);
const childNodes = children.map(f => ({ ...f, children: f.has_children ? [] : undefined }));
setTree(prev => findAndUpdateNode(prev, node.path, n => ({
...n,
children: childNodes,
isLoading: false
})));
}
}
}, [expandedPaths, loadChildren, onSelect]);
const renderNode = (node: TreeNode, level: number = 0) => {
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPath === node.path;
const hasChildren = node.has_children;
return (
<div key={node.path}>
<div
className={`flex items-center gap-2 px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer border-b border-border/10 last:border-b-0 ${
isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''
}`}
style={{ paddingLeft: `${12 + level * 20}px` }}
onClick={() => onSelect(node.path)}
>
{/* Expand/Collapse button */}
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpand(node);
}}
className="flex items-center justify-center w-5 h-5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
{node.isLoading ? (
<svg className="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg
className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</button>
) : (
<span className="w-5" />
)}
{/* Folder icon */}
<svg
className={`w-5 h-5 flex-shrink-0 ${isSelected ? 'text-primary' : 'text-warning'}`}
fill={hasChildren ? "currentColor" : "none"}
fillOpacity={hasChildren ? 0.2 : undefined}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
{/* Folder name */}
<span className={`flex-1 text-sm truncate ${isSelected ? 'font-medium text-primary' : 'text-foreground'}`}>
{node.name}
</span>
{/* Selected indicator */}
{isSelected && (
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* Render children if expanded */}
{isExpanded && node.children && node.children.length > 0 && (
<div>
{node.children.map(child => renderNode(child, level + 1))}
</div>
)}
</div>
);
};
return (
<div className="bg-card overflow-hidden">
{/* Folder tree */}
<div className="max-h-80 overflow-y-auto">
{tree.length === 0 ? (
<div className="px-3 py-8 text-sm text-muted-foreground text-center">
No folders found
</div>
) : (
tree.map(node => renderNode(node))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { FolderBrowser } from "./FolderBrowser";
import { FolderItem } from "../../lib/api";
import { Button } from "./ui";
interface FolderPickerProps {
initialFolders: FolderItem[];
selectedPath: string;
onSelect: (path: string) => void;
}
export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const handleSelect = (path: string) => {
onSelect(path);
setIsOpen(false);
};
return (
<div className="relative">
{/* Input avec bouton browse */}
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<input
type="text"
readOnly
value={selectedPath || "Select a folder..."}
className={`
w-full px-3 py-2 rounded-lg border bg-card
text-sm font-mono
${selectedPath ? 'text-foreground' : 'text-muted-foreground italic'}
border-border/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20
transition-all duration-200
`}
/>
{selectedPath && (
<button
type="button"
onClick={() => onSelect("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsOpen(true)}
className="flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
Browse
</Button>
</div>
{/* Popup Modal */}
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
onClick={() => setIsOpen(false)}
/>
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border/50 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="font-medium">Select Folder</span>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Folder Browser */}
<div className="p-0">
<FolderBrowser
initialFolders={initialFolders}
selectedPath={selectedPath}
onSelect={handleSelect}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-muted/30">
<span className="text-xs text-muted-foreground">
Click a folder to select it
</span>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { FolderPicker } from "./FolderPicker";
import { FolderItem } from "../../lib/api";
import { Button, FormField, FormInput, FormRow } from "./ui";
interface LibraryFormProps {
initialFolders: FolderItem[];
action: (formData: FormData) => void;
}
export function LibraryForm({ initialFolders, action }: LibraryFormProps) {
const [selectedPath, setSelectedPath] = useState<string>("");
return (
<form action={action}>
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField className="flex-1 min-w-64">
<input type="hidden" name="root_path" value={selectedPath} />
<FolderPicker
initialFolders={initialFolders}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
</FormField>
</FormRow>
<div className="mt-4 flex justify-end">
<Button type="submit" disabled={!selectedPath}>
Add Library
</Button>
</div>
</form>
);
}

View File

@@ -2,9 +2,10 @@ import { revalidatePath } from "next/cache";
import Link from "next/link"; import Link from "next/link";
import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api"; import { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
import { LibraryActions } from "../components/LibraryActions"; import { LibraryActions } from "../components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm";
import { import {
Card, CardHeader, CardTitle, CardDescription, CardContent, Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge, FormField, FormInput, FormSelect, FormRow Button, Badge
} from "../components/ui"; } from "../components/ui";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -92,24 +93,7 @@ export default async function LibrariesPage() {
<CardDescription>Create a new library from an existing folder</CardDescription> <CardDescription>Create a new library from an existing folder</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action={addLibrary}> <LibraryForm initialFolders={folders} action={addLibrary} />
<FormRow>
<FormField className="flex-1 min-w-48">
<FormInput name="name" placeholder="Library name" required />
</FormField>
<FormField className="flex-1 min-w-48">
<FormSelect name="root_path" required defaultValue="">
<option value="" disabled>Select folder...</option>
{folders.map((folder) => (
<option key={folder.path} value={folder.path}>
{folder.name}
</option>
))}
</FormSelect>
</FormField>
<Button type="submit">Add Library</Button>
</FormRow>
</form>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -41,6 +41,8 @@ export type TokenDto = {
export type FolderItem = { export type FolderItem = {
name: string; name: string;
path: string; path: string;
depth: number;
has_children: boolean;
}; };
export type BookDto = { export type BookDto = {
@@ -177,8 +179,9 @@ export async function cancelJob(id: string) {
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" }); return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
} }
export async function listFolders() { export async function listFolders(path?: string) {
return apiFetch<FolderItem[]>("/folders"); const url = path ? `/folders?path=${encodeURIComponent(path)}` : "/folders";
return apiFetch<FolderItem[]>(url);
} }
export async function listTokens() { export async function listTokens() {

File diff suppressed because one or more lines are too long