|
@@ -0,0 +1,179 @@
|
|
|
|
|
+"use client";
|
|
|
|
|
+
|
|
|
|
|
+import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
+import { useEffect, useRef, useState } from "react";
|
|
|
|
|
+import { get } from "../../lib/api";
|
|
|
|
|
+
|
|
|
|
|
+interface Directory {
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ path: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PathAutocompleteProps {
|
|
|
|
|
+ value: string;
|
|
|
|
|
+ onChange: (value: string) => void;
|
|
|
|
|
+ placeholder?: string;
|
|
|
|
|
+ className?: string;
|
|
|
|
|
+ defaultPath?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function PathAutocomplete({
|
|
|
|
|
+ value,
|
|
|
|
|
+ onChange,
|
|
|
|
|
+ placeholder = "/path/to/destination",
|
|
|
|
|
+ className = "",
|
|
|
|
|
+ defaultPath = "",
|
|
|
|
|
+}: PathAutocompleteProps) {
|
|
|
|
|
+ const [showSuggestions, setShowSuggestions] = useState(false);
|
|
|
|
|
+ const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
|
|
|
+ const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
+ const suggestionsRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // Get the parent directory to search for subdirectories
|
|
|
|
|
+ const searchPath = value || defaultPath;
|
|
|
|
|
+ const parentPath = searchPath.endsWith("/")
|
|
|
|
|
+ ? searchPath.slice(0, -1)
|
|
|
|
|
+ : searchPath;
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch directories for autocomplete
|
|
|
|
|
+ const { data: dirData } = useQuery<{ directories: Directory[] }>({
|
|
|
|
|
+ queryKey: ["directories", parentPath],
|
|
|
|
|
+ queryFn: async () => {
|
|
|
|
|
+ if (!parentPath) return { directories: [] };
|
|
|
|
|
+ return get("/directories", { path: parentPath });
|
|
|
|
|
+ },
|
|
|
|
|
+ enabled: !!parentPath && showSuggestions,
|
|
|
|
|
+ staleTime: 30000, // Cache for 30 seconds
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const directories = dirData?.directories || [];
|
|
|
|
|
+
|
|
|
|
|
+ // Close suggestions when clicking outside
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const handleClickOutside = (event: MouseEvent) => {
|
|
|
|
|
+ if (
|
|
|
|
|
+ suggestionsRef.current &&
|
|
|
|
|
+ !suggestionsRef.current.contains(event.target as Node) &&
|
|
|
|
|
+ !inputRef.current?.contains(event.target as Node)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ setShowSuggestions(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ document.addEventListener("mousedown", handleClickOutside);
|
|
|
|
|
+ return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
+ const newValue = e.target.value;
|
|
|
|
|
+ onChange(newValue);
|
|
|
|
|
+ setShowSuggestions(true);
|
|
|
|
|
+ setSelectedIndex(-1);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleSuggestionClick = (dirPath: string) => {
|
|
|
|
|
+ onChange(dirPath);
|
|
|
|
|
+ setShowSuggestions(false);
|
|
|
|
|
+ setSelectedIndex(-1);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
+ if (!showSuggestions || directories.length === 0) {
|
|
|
|
|
+ if (e.key === "ArrowDown") {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ setShowSuggestions(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch (e.key) {
|
|
|
|
|
+ case "ArrowDown":
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ setSelectedIndex((prev) =>
|
|
|
|
|
+ prev < directories.length - 1 ? prev + 1 : prev
|
|
|
|
|
+ );
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "ArrowUp":
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "Enter":
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ if (selectedIndex >= 0 && selectedIndex < directories.length) {
|
|
|
|
|
+ handleSuggestionClick(directories[selectedIndex].path);
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "Escape":
|
|
|
|
|
+ setShowSuggestions(false);
|
|
|
|
|
+ setSelectedIndex(-1);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleFocus = () => {
|
|
|
|
|
+ if (directories.length > 0) {
|
|
|
|
|
+ setShowSuggestions(true);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="relative">
|
|
|
|
|
+ <input
|
|
|
|
|
+ ref={inputRef}
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={value}
|
|
|
|
|
+ onChange={handleInputChange}
|
|
|
|
|
+ onKeyDown={handleKeyDown}
|
|
|
|
|
+ onFocus={handleFocus}
|
|
|
|
|
+ placeholder={placeholder}
|
|
|
|
|
+ className={className}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {showSuggestions && directories.length > 0 && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={suggestionsRef}
|
|
|
|
|
+ className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-y-auto"
|
|
|
|
|
+ >
|
|
|
|
|
+ {directories.map((dir, index) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={dir.path}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => handleSuggestionClick(dir.path)}
|
|
|
|
|
+ className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-700 ${
|
|
|
|
|
+ index === selectedIndex ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center">
|
|
|
|
|
+ <svg
|
|
|
|
|
+ className="h-4 w-4 mr-2 text-gray-400"
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ stroke="currentColor"
|
|
|
|
|
+ >
|
|
|
|
|
+ <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="text-gray-900 dark:text-gray-100">
|
|
|
|
|
+ {dir.name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="text-xs text-gray-500 dark:text-gray-400 ml-6 truncate">
|
|
|
|
|
+ {dir.path}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {defaultPath && value !== defaultPath && (
|
|
|
|
|
+ <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ Default: {defaultPath}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|