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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
apps/backoffice/app/api/folders/route.ts
Normal file
39
apps/backoffice/app/api/folders/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
184
apps/backoffice/app/components/FolderBrowser.tsx
Normal file
184
apps/backoffice/app/components/FolderBrowser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/backoffice/app/components/FolderPicker.tsx
Normal file
126
apps/backoffice/app/components/FolderPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/backoffice/app/components/LibraryForm.tsx
Normal file
38
apps/backoffice/app/components/LibraryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
1
apps/backoffice/tsconfig.tsbuildinfo
Normal file
1
apps/backoffice/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user