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

@@ -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 { listFolders, createLibrary, deleteLibrary, fetchLibraries, fetchSeries, scanLibrary, LibraryDto, FolderItem } from "../../lib/api";
import { LibraryActions } from "../components/LibraryActions";
import { LibraryForm } from "../components/LibraryForm";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
Button, Badge, FormField, FormInput, FormSelect, FormRow
Button, Badge
} from "../components/ui";
export const dynamic = "force-dynamic";
@@ -92,24 +93,7 @@ export default async function LibrariesPage() {
<CardDescription>Create a new library from an existing folder</CardDescription>
</CardHeader>
<CardContent>
<form 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>
<LibraryForm initialFolders={folders} action={addLibrary} />
</CardContent>
</Card>