Add directory dialog
This commit is contained in:
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { DirectoryDialog } from "./directory-dialog";
|
||||||
|
|
||||||
export function AddLibraryForm() {
|
export function AddLibraryForm() {
|
||||||
const [path, setPath] = useState("");
|
const [path, setPath] = useState("");
|
||||||
@@ -35,12 +36,16 @@ export function AddLibraryForm() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4 p-4 max-w-md">
|
<form onSubmit={handleSubmit} className="space-y-4 p-4 max-w-md">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="path">Music Library Path</Label>
|
<Label htmlFor="path">Music Library Path</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="path"
|
<Input
|
||||||
value={path}
|
id="path"
|
||||||
onChange={(e) => setPath(e.target.value)}
|
value={path}
|
||||||
placeholder="/home/username/Music"
|
onChange={(e) => setPath(e.target.value)}
|
||||||
/>
|
placeholder="/home/username/Music"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<DirectoryDialog onSelect={(selectedPath) => setPath(selectedPath)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Adding..." : "Add Library"}
|
{loading ? "Adding..." : "Add Library"}
|
||||||
|
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { JSX, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { fetchFromApi } from "@/lib/api";
|
||||||
|
import { ChevronDown, ChevronRight, Folder } from "lucide-react";
|
||||||
|
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export type Directory = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
is_root: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryDialogProps = {
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryNode = Directory & { children: DirectoryNode[] };
|
||||||
|
|
||||||
|
function buildDirectoryTree(flatList: Directory[]): DirectoryNode[] {
|
||||||
|
const pathMap = new Map<string, DirectoryNode>();
|
||||||
|
const roots: DirectoryNode[] = [];
|
||||||
|
|
||||||
|
// Convert to map with empty children arrays
|
||||||
|
for (const dir of flatList) {
|
||||||
|
pathMap.set(dir.path, { ...dir, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the tree
|
||||||
|
for (const dir of pathMap.values()) {
|
||||||
|
if (dir.parent && pathMap.has(dir.parent)) {
|
||||||
|
pathMap.get(dir.parent)!.children.push(dir);
|
||||||
|
} else {
|
||||||
|
roots.push(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectoryDialog({ onSelect }: DirectoryDialogProps) {
|
||||||
|
const [directories, setDirectories] = useState<Directory[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
const [directoryTree, setDirectoryTree] = useState<DirectoryNode[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchFromApi<Directory[]>("/fs/directories")
|
||||||
|
.then((dirs) => {
|
||||||
|
setDirectories(dirs);
|
||||||
|
setDirectoryTree(buildDirectoryTree(dirs));
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggleExpand = (path: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(path) ? next.delete(path) : next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMatch = (dir: Directory) =>
|
||||||
|
dir.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
dir.path.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const renderTree = (
|
||||||
|
nodes: DirectoryNode[],
|
||||||
|
level: number = 0
|
||||||
|
): JSX.Element[] => {
|
||||||
|
return nodes
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.flatMap((dir) => {
|
||||||
|
const isExpanded = expanded.has(dir.path);
|
||||||
|
const matchesSearch = dir.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const children =
|
||||||
|
isExpanded && dir.children.length > 0
|
||||||
|
? renderTree(dir.children, level + 1)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Hide non-matching nodes if search is active
|
||||||
|
if (searchQuery && !matchesSearch && children.length === 0) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
<div
|
||||||
|
key={dir.path}
|
||||||
|
style={{ paddingLeft: `${level * 16}px` }}
|
||||||
|
className={cn(
|
||||||
|
"py-1 flex items-center gap-1 cursor-pointer rounded px-2 group",
|
||||||
|
selectedPath === dir.path && "bg-muted"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPath(dir.path);
|
||||||
|
onSelect(dir.path);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dir.children.length > 0 ? (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpand(dir.path);
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span title={dir.path} className="truncate">
|
||||||
|
{dir.name}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
...children,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Browse...
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Choose a directory</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Browse for a directory to add as a library.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<ScrollArea className="h-[60vh]">
|
||||||
|
<div className="p-2 flex flex-col w-full">
|
||||||
|
{renderTree(directoryTree)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -13,6 +14,7 @@ pub struct Directory {
|
|||||||
name: String,
|
name: String,
|
||||||
path: String,
|
path: String,
|
||||||
parent: Option<String>,
|
parent: Option<String>,
|
||||||
|
is_root: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_readable_and_writable(path: &Path) -> bool {
|
fn is_readable_and_writable(path: &Path) -> bool {
|
||||||
@@ -57,6 +59,7 @@ fn is_hidden(entry: &DirEntry) -> bool {
|
|||||||
|
|
||||||
pub fn get_readable_writable_dirs(root: &Path) -> Vec<Directory> {
|
pub fn get_readable_writable_dirs(root: &Path) -> Vec<Directory> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
|
let mut all_paths = HashSet::new();
|
||||||
|
|
||||||
for entry in WalkDir::new(root)
|
for entry in WalkDir::new(root)
|
||||||
.follow_links(false)
|
.follow_links(false)
|
||||||
@@ -69,13 +72,27 @@ pub fn get_readable_writable_dirs(root: &Path) -> Vec<Directory> {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
if is_readable_and_writable(path) {
|
if is_readable_and_writable(path) {
|
||||||
dirs.push(Directory {
|
all_paths.insert(path.to_path_buf());
|
||||||
name: entry.file_name().to_string_lossy().to_string(),
|
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
parent: path.parent().map(|p| p.to_string_lossy().to_string()),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for path in &all_paths {
|
||||||
|
let parent = path.parent().map(|p| p.to_string_lossy().to_string());
|
||||||
|
let is_root = parent
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| !all_paths.contains(&PathBuf::from(p)))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
dirs.push(Directory {
|
||||||
|
name: path
|
||||||
|
.file_name()
|
||||||
|
.map(|f| f.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "/".to_string()),
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
parent,
|
||||||
|
is_root,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user