Add directory dialog
Some checks failed
CI / Check Style (push) Failing after 27s
CI / Run Clippy (push) Failing after 5m0s
CI / Run Tests (push) Failing after 10m1s

This commit is contained in:
2025-07-28 04:13:34 +02:00
parent 75ca414ef1
commit 77bdae5fff
3 changed files with 207 additions and 12 deletions

View File

@@ -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>
<div className="flex gap-2">
<Input <Input
id="path" id="path"
value={path} value={path}
onChange={(e) => setPath(e.target.value)} onChange={(e) => setPath(e.target.value)}
placeholder="/home/username/Music" 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"}

View File

@@ -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>
);
}

View File

@@ -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
} }