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>
This commit is contained in:
@@ -1,13 +1,27 @@
|
||||
import React, {useState} from "react";
|
||||
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 "./PathNode.css";
|
||||
import {useDeletePath, useMovePath, useUpdatePath} from "../../utils/queries/path-queries";
|
||||
import {useIndexMarkdown, useMoveMarkdown} from "../../utils/queries/markdown-queries";
|
||||
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);
|
||||
@@ -20,76 +34,57 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
|
||||
const deletePath = useDeletePath();
|
||||
const updatePath = useUpdatePath();
|
||||
|
||||
const {data: indexMarkdown} = useIndexMarkdown(path.id);
|
||||
|
||||
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));
|
||||
if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
|
||||
};
|
||||
const toggleExpand = () => dispatch(toggleNodeExpansion(path.id));
|
||||
|
||||
const handleSave = () => {
|
||||
updatePath.mutate({id: path.id, data: {name: newName}}, {
|
||||
updatePath.mutate({ id: path.id, data: { name: newName } }, {
|
||||
onSuccess: () => setIsEditing(false),
|
||||
onError: err => alert("failed to update this path"),
|
||||
})
|
||||
onError: () => alert("failed to update this path"),
|
||||
});
|
||||
};
|
||||
const handleDelete = () => {
|
||||
if(window.confirm("Are you sure?")) {
|
||||
if (window.confirm("Are you sure?")) {
|
||||
deletePath.mutate(path.id, {
|
||||
onError: err => alert("failed to delete this path"),
|
||||
})
|
||||
onError: () => alert("failed to delete this path"),
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
const handleEdit = () => setIsEditing(true);
|
||||
|
||||
const handleMovePath = (pth, direction) => {
|
||||
movePath.mutate({path: pth, direction: direction}, {
|
||||
movePath.mutate({ path: pth, direction }, {
|
||||
onError: () => alert("failed to move this path"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveMarkdown = (md, direction) => {
|
||||
moveMarkdown.mutate({markdown: md, direction: direction}, {
|
||||
moveMarkdown.mutate({ markdown: md, direction }, {
|
||||
onError: () => alert("failed to move this markdown"),
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const childPaths = path.children.filter(x => x.type==="path");
|
||||
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 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)
|
||||
if (isRoot)
|
||||
return (
|
||||
<ul className="menu-list">
|
||||
{sortedPaths.map((path) => (
|
||||
<PathNode
|
||||
key={path.id}
|
||||
path={path}
|
||||
isRoot={false}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ul className="space-y-0.5">
|
||||
{sortedPaths.map((p) => (
|
||||
<PathNode key={p.id} path={p} isRoot={false} />
|
||||
))}
|
||||
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
|
||||
{sortedMarkdowns.map((markdown) => (
|
||||
<MarkdownNode
|
||||
markdown={markdown}
|
||||
handleMoveMarkdown={handleMoveMarkdown}
|
||||
@@ -98,106 +93,110 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
return (
|
||||
<li key={path.id}>
|
||||
<div className="path-node-header field has-addons">
|
||||
<span className="control has-text-weight-bold path-toggle" onClick={isRoot ? undefined : toggleExpand}>
|
||||
{isExpanded ? "-" : "+"}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<div className="control has-icons-left">
|
||||
<input
|
||||
className="input is-small path-edit-input"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
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
|
||||
className="path-name has-text-weight-bold control"
|
||||
onClick={isRoot ? undefined : expand}
|
||||
onClick={toggleExpand}
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
|
||||
>
|
||||
{
|
||||
indexMarkdown ? (
|
||||
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link index-path-node">
|
||||
{path.name}
|
||||
</Link>
|
||||
) : (
|
||||
<a className="is-link path-node">{path.name}</a>
|
||||
)
|
||||
}
|
||||
|
||||
{path.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<PermissionGuard rolesRequired={["admin"]}>
|
||||
<div className="field has-addons actions control is-justify-content-flex-end">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
onClick={() => {
|
||||
setIsPathSettingModalOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-cog"/>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
{isEditing ? (
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-check"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-info"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-pen"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-danger is-small"
|
||||
onClick={handleDelete}
|
||||
type="button">
|
||||
<span className="icon">
|
||||
<i className="fas fa-trash"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<div
|
||||
className="control is-flex is-flex-direction-column is-align-items-center"
|
||||
style={{marginLeft: "0.5rem"}}
|
||||
<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
|
||||
className="button is-small mb-1 move-forward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMovePath(path, "forward")}
|
||||
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
|
||||
className="button is-small mb-1 move-backward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMovePath(path, "backward")}
|
||||
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>
|
||||
@@ -210,15 +209,11 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<ul>
|
||||
<ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
|
||||
{sortedPaths.map((child) => (
|
||||
<PathNode
|
||||
key={child.id}
|
||||
path={child}
|
||||
/>
|
||||
<PathNode key={child.id} path={child} />
|
||||
))}
|
||||
|
||||
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
|
||||
{sortedMarkdowns.map((markdown) => (
|
||||
<MarkdownNode
|
||||
markdown={markdown}
|
||||
handleMoveMarkdown={handleMoveMarkdown}
|
||||
|
||||
Reference in New Issue
Block a user