Files
HangmanLab.Frontend/src/components/Navigations/PathNode.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

230 lines
9.4 KiB
JavaScript

import React, { useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { toggleNodeExpansion } from '../../store/navigationSlice';
import { Link } from "react-router-dom";
import {
ChevronRight,
Folder,
FolderOpen,
Settings2,
Pencil,
Check,
Trash2,
ChevronUp,
ChevronDown,
} from "lucide-react";
import PermissionGuard from "../PermissionGuard";
import { useDeletePath, useMovePath, useUpdatePath } from "../../utils/queries/path-queries";
import { useIndexMarkdown, useMoveMarkdown } from "../../utils/queries/markdown-queries";
import MarkdownNode from "./MarkdownNode";
import PathSettingModal from "../Modals/PathSettingModal";
import { cn } from "../../lib/utils";
const iconBtn =
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
const PathNode = ({ path, isRoot = false }) => {
const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(path.name || "");
const expandedNodes = useSelector(state => state.navigation.expandedNodes);
const dispatch = useDispatch();
const isExpanded = isRoot || expandedNodes[path.id];
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const { data: indexMarkdown } = useIndexMarkdown(path.id);
const movePath = useMovePath();
const moveMarkdown = useMoveMarkdown();
const expand = () => {
if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
};
const toggleExpand = () => dispatch(toggleNodeExpansion(path.id));
const handleSave = () => {
updatePath.mutate({ id: path.id, data: { name: newName } }, {
onSuccess: () => setIsEditing(false),
onError: () => alert("failed to update this path"),
});
};
const handleDelete = () => {
if (window.confirm("Are you sure?")) {
deletePath.mutate(path.id, {
onError: () => alert("failed to delete this path"),
});
}
};
const handleEdit = () => setIsEditing(true);
const handleMovePath = (pth, direction) => {
movePath.mutate({ path: pth, direction }, {
onError: () => alert("failed to move this path"),
});
};
const handleMoveMarkdown = (md, direction) => {
moveMarkdown.mutate({ markdown: md, direction }, {
onError: () => alert("failed to move this markdown"),
});
};
const childPaths = path.children.filter(x => x.type === "path");
const sortedPaths = childPaths
? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order))
: [];
const markdowns = path.children.filter(x => x.type === "markdown");
const sortedMarkdowns = markdowns
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
: [];
if (isRoot)
return (
<ul className="space-y-0.5">
{sortedPaths.map((p) => (
<PathNode key={p.id} path={p} isRoot={false} />
))}
{sortedMarkdowns.map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
);
return (
<li>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
<button
type="button"
onClick={toggleExpand}
title={isExpanded ? "Collapse" : "Expand"}
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isExpanded && "rotate-90"
)}
/>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-secondary" />
) : (
<Folder className="h-4 w-4 text-secondary/70" />
)}
</button>
{isEditing ? (
<input
autoFocus
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
) : indexMarkdown ? (
// Clicking the name navigates to the folder's index page
// AND expands the subtree (expanded state is global, so
// the children stay visible after navigation).
<Link
to={`/markdown/${indexMarkdown.id}`}
onClick={expand}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
>
{path.name}
</Link>
) : (
<span
onClick={toggleExpand}
className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
>
{path.name}
</span>
)}
<PermissionGuard rolesRequired={["admin"]}>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className={iconBtn}
title="Settings"
onClick={() => setIsPathSettingModalOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
{isEditing ? (
<button
type="button"
className={iconBtn}
title="Save"
onClick={handleSave}
>
<Check className="h-3.5 w-3.5 text-primary" />
</button>
) : (
<button
type="button"
className={iconBtn}
title="Rename"
onClick={handleEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col">
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "forward")}
title="Move up"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "backward")}
title="Move down"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
<PathSettingModal
isOpen={isPathSettingModalOpen}
path={path}
onClose={() => setIsPathSettingModalOpen(false)}
/>
</PermissionGuard>
</div>
{isExpanded && (
<ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
{sortedPaths.map((child) => (
<PathNode key={child.id} path={child} />
))}
{sortedMarkdowns.map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
)}
</li>
);
};
export default PathNode;