Files
HangmanLab.Frontend/src/components/PathManager.js
hzhang 952387d50f feat: dark-tech UI redesign + markdown patch cards
Redesign the frontend with a dark-tech theme: add Tailwind + PostCSS,
design tokens, and shadcn-style primitives (Button/Card/Input/Dialog/
DropdownMenu/Tabs/ScrollArea/etc.); restyle the app shell, navigation,
sidebar tree, content view, markdown rendering, editors, modals and
settings panels. Behavior/props unchanged; Font Awesome replaced with
lucide-react.

Add the patch cards feature UI: patch-queries hooks and a PatchCards
component rendered below the markdown body, with an Add Patch button
and create/edit dialog.

Fix tree expandability: folders with an index page now expand on name
click (and navigate), and the chevron+folder icon is one larger toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:28:13 +01:00

181 lines
7.0 KiB
JavaScript

import React, {useEffect, useState, useRef, useContext} from "react";
import {useCreatePath, usePaths} from "../utils/queries/path-queries";
import { useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import "./PathManager.css";
import {fetch_} from "../utils/request-utils";
import {ConfigContext} from "../ConfigProvider";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { Spinner } from "./ui/misc";
const PathManager = ({ currentPathId = 1, onPathChange }) => {
const [currentFullPath, setCurrentFullPath] = useState([{ name: "Root", id: 1 }]);
const [searchTerm, setSearchTerm] = useState("");
const [dropdownActive, setDropdownActive] = useState(false);
const inputRef = useRef();
const queryClient = useQueryClient();
const { data: subPaths, isLoading: isSubPathsLoading, error: subPathsError } = usePaths(currentPathId);
const createPath = useCreatePath();
const config = useContext(ConfigContext).config;
const buildFullPath = async (pathId) => {
const path = [];
let current_id = pathId;
while (current_id) {
try {
const pathData = await queryClient.fetchQuery({
queryKey: ["path", current_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
});
if (!pathData) break;
path.unshift({ name: pathData.name, id: pathData.id });
current_id = pathData.parent_id;
} catch (error) {
console.error(`Failed to fetch path with id ${current_id}:`, error);
break;
}
}
return path;
};
useEffect(() => {
const init = async () => {
const path = await buildFullPath(currentPathId);
setCurrentFullPath(path);
};
init();
}, [currentPathId, queryClient]);
const handlePathClick = (pathId, pathIndex) => {
const newPath = currentFullPath.slice(0, pathIndex + 1);
setCurrentFullPath(newPath);
onPathChange(pathId);
};
const handleSubPathSelect = (subPath) => {
const updatedPath = [...currentFullPath, { name: subPath.name, id: subPath.id }];
setCurrentFullPath(updatedPath);
onPathChange(subPath.id);
setSearchTerm("");
setDropdownActive(false);
};
const handleAddDirectory = () => {
if (!searchTerm.trim()) {
alert("Directory name cannot be empty.");
return;
}
createPath.mutate(
{ name: searchTerm.trim(), parent_id: currentPathId },
{
onSuccess: (newDir) => {
queryClient.setQueryData({queryKey: ["path", newDir.id]}, newDir);
queryClient.invalidateQueries({queryKey: ["paths", currentPathId]});
setSearchTerm("");
alert("Directory created successfully.");
},
onError: (error) => {
console.error("Failed to create directory:", error);
alert("Failed to create directory.");
},
}
);
};
const handleInputFocus = () => setDropdownActive(true);
const handleInputBlur = () => {
setTimeout(() => setDropdownActive(false), 150);
};
const filteredSubPaths = subPaths
? subPaths.filter((path) =>
path.name.toLowerCase().includes(searchTerm.toLowerCase())
)
: [];
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddDirectory();
}
};
return (
<div className="path-manager space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{currentFullPath.map((path, index) => (
<button
type="button"
key={path.id}
className="rounded-md border border-border bg-surface/60 px-2 py-1 font-mono text-xs text-primary transition-colors hover:border-primary/60"
onClick={() => handlePathClick(path.id, index)}
>
{path.name + "/"}
</button>
))}
</div>
<div className="path-manager-body">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
className="h-8 text-xs"
type="text"
placeholder="Search or create directory"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
{dropdownActive && (
<div className="path-manager-dropdown absolute left-0 top-full z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-card shadow-glow">
{filteredSubPaths.length > 0 ? (
filteredSubPaths.map((subPath) => (
<button
type="button"
key={subPath.id}
className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={() => handleSubPathSelect(subPath)}
>
{subPath.name}
</button>
))
) : (
<div className="px-3 py-2 text-xs text-muted-foreground">
No matches found
</div>
)}
</div>
)}
</div>
<Button
type="button"
size="sm"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
>
<Plus className="h-4 w-4" /> Create "{searchTerm}"
</Button>
</div>
{isSubPathsLoading && <Spinner label="Loading paths" />}
{subPathsError && (
<p className="font-mono text-xs text-destructive">
Error loading subdirectories.
</p>
)}
</div>
</div>
);
};
export default PathManager;