This commit is contained in:
NotII
2025-04-08 02:02:54 +01:00
parent d8416a8cba
commit 73507e6ac0
4 changed files with 286 additions and 42 deletions

View File

@@ -1,10 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import Layout from "@/components/layout/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Pencil, Trash2, ChevronRight, ChevronDown } from "lucide-react";
import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, MoveVertical } from "lucide-react";
import { toast } from "sonner";
import {
Select,
@@ -27,17 +27,39 @@ import { apiRequest } from "@/lib/api";
import type { Category } from "@/models/categories";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
// Drag and Drop imports
import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
// Define interfaces for drag item
interface DragItem {
id: string;
parentId?: string;
}
// Item Types
const ItemTypes = {
CATEGORY: 'category',
};
export default function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [newCategoryName, setNewCategoryName] = useState("");
const [selectedParentId, setSelectedParentId] = useState<string>("");
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const rootCategories = categories.filter(cat => !cat.parentId);
// Get root categories sorted by order
const rootCategories = categories
.filter(cat => !cat.parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Get subcategories sorted by order
const getSubcategories = (parentId: string) =>
categories.filter(cat => cat.parentId === parentId);
categories
.filter(cat => cat.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
useEffect(() => {
fetchCategories();
@@ -59,9 +81,18 @@ export default function CategoriesPage() {
}
try {
// Find the highest order in the selected parent group
const siblings = selectedParentId
? categories.filter(cat => cat.parentId === selectedParentId)
: categories.filter(cat => !cat.parentId);
const maxOrder = siblings.reduce((max, cat) =>
Math.max(max, cat.order || 0), 0);
const response = await apiRequest("/categories", "POST", {
name: newCategoryName,
parentId: selectedParentId || undefined,
order: maxOrder + 1,
});
setCategories([...categories, response]);
@@ -103,21 +134,142 @@ export default function CategoriesPage() {
}
};
const renderCategoryItem = (category: Category, level: number = 0) => {
const moveCategory = async (dragId: string, hoverId: string, parentId?: string) => {
// Create new array with updated orders
const dragIndex = categories.findIndex(cat => cat._id === dragId);
const hoverIndex = categories.findIndex(cat => cat._id === hoverId);
if (dragIndex === -1 || hoverIndex === -1) return;
const draggedCategory = categories[dragIndex];
// Make sure we're only reordering within the same parent group
if (draggedCategory.parentId !== parentId) return;
// Create a copy for reordering
const updatedCategories = [...categories];
// Get only categories with the same parent for reordering
const siblingCategories = updatedCategories
.filter(cat => cat.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Remove the dragged category from its position
const draggedItem = siblingCategories.find(cat => cat._id === dragId);
if (!draggedItem) return;
const filteredSiblings = siblingCategories.filter(cat => cat._id !== dragId);
// Find where to insert the dragged item
const hoverItem = siblingCategories.find(cat => cat._id === hoverId);
if (!hoverItem) return;
const hoverPos = filteredSiblings.findIndex(cat => cat._id === hoverId);
// Insert the dragged item at the new position
filteredSiblings.splice(hoverPos, 0, draggedItem);
// Update the order for all siblings
filteredSiblings.forEach((cat, index) => {
cat.order = index;
});
// Update the categories state
setCategories(updatedCategories.map(cat => {
const updatedCat = filteredSiblings.find(c => c._id === cat._id);
return updatedCat || cat;
}));
// Save the new order to the server
try {
await apiRequest(`/categories/${dragId}`, "PUT", {
order: filteredSiblings.find(cat => cat._id === dragId)?.order,
});
} catch (error) {
toast.error("Failed to update category order");
// Revert to original order
fetchCategories();
}
};
const toggleExpand = (categoryId: string) => {
setExpanded(prev => ({
...prev,
[categoryId]: !prev[categoryId]
}));
};
// Category Item Component with Drag & Drop
const CategoryItem = ({ category, level = 0 }: { category: Category, level?: number }) => {
const subcategories = getSubcategories(category._id);
const hasSubcategories = subcategories.length > 0;
const isEditing = editingCategory?._id === category._id;
const isExpanded = expanded[category._id];
const ref = useRef<HTMLDivElement>(null);
// Set up drag
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.CATEGORY,
item: { id: category._id, parentId: category.parentId },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
// Set up drop
const [{ handlerId, isOver }, drop] = useDrop<DragItem, void, { handlerId: string | symbol | null; isOver: boolean }>({
accept: ItemTypes.CATEGORY,
collect: (monitor) => ({
handlerId: monitor.getHandlerId(),
isOver: monitor.isOver(),
}),
hover(item: DragItem, monitor) {
if (!ref.current) return;
const dragId = item.id;
const hoverId = category._id;
// Don't replace items with themselves
if (dragId === hoverId) return;
// Only allow reordering within the same parent
if (item.parentId !== category.parentId) return;
moveCategory(dragId, hoverId, category.parentId);
},
});
// Connect the drag and drop refs
drag(drop(ref));
return (
<div key={category._id} className="space-y-1">
<div
className={`group flex items-center p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors
${isEditing ? 'bg-gray-100 dark:bg-gray-800' : ''}`}
ref={ref}
className={`group flex items-center p-2 rounded-md transition-colors
${isEditing ? 'bg-gray-100 dark:bg-gray-800' : ''}
${isOver ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}
${isDragging ? 'opacity-50' : 'opacity-100'}`}
style={{ marginLeft: `${level * 24}px` }}
data-handler-id={handlerId}
>
<div className="cursor-grab mr-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<MoveVertical className="h-4 w-4" />
</div>
{hasSubcategories && (
<ChevronRight className="h-4 w-4 mr-2 text-muted-foreground" />
<button
onClick={() => toggleExpand(category._id)}
className="mr-1 focus:outline-none"
>
{isExpanded ?
<ChevronDown className="h-4 w-4 text-muted-foreground" /> :
<ChevronRight className="h-4 w-4 text-muted-foreground" />
}
</button>
)}
<div className="flex-1 flex items-center space-x-2">
{isEditing ? (
<Input
@@ -179,7 +331,9 @@ export default function CategoriesPage() {
)}
</div>
</div>
{subcategories.map(subcat => renderCategoryItem(subcat, level + 1))}
{isExpanded && subcategories.map(subcat =>
<CategoryItem key={subcat._id} category={subcat} level={level + 1} />
)}
</div>
);
};
@@ -242,40 +396,40 @@ export default function CategoriesPage() {
<CardTitle className="text-xl font-semibold">Category List</CardTitle>
</CardHeader>
<CardContent>
<DndProvider backend={HTML5Backend}>
<div className="space-y-1">
{rootCategories.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No categories yet. Add your first category above.
</p>
) : (
rootCategories.map(category => renderCategoryItem(category))
rootCategories.map(category => (
<CategoryItem key={category._id} category={category} />
))
)}
</div>
</DndProvider>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
{categoryToDelete && (
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the category &quot;{categoryToDelete?.name}&quot;
{getSubcategories(categoryToDelete?._id || "").length > 0 &&
" and all its subcategories"}. This action cannot be undone.
This will permanently delete the category "{categoryToDelete.name}".
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-red-500 hover:bg-red-600"
>
Delete
</AlertDialogAction>
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</Layout>
);

View File

@@ -1,5 +1,6 @@
export interface Category {
_id: string;
name: string;
parentId?: string
parentId?: string;
order?: number;
}

89
package-lock.json generated
View File

@@ -55,6 +55,8 @@
"pusher-js": "^8.4.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.0.0",
@@ -2421,6 +2423,24 @@
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
"license": "MIT"
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@react-three/drei": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.0.6.tgz",
@@ -4463,6 +4483,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5197,7 +5228,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
@@ -5842,6 +5872,15 @@
"integrity": "sha512-7GOkcqn0Y9EqU2OJZlzkwxj9Uynuln7URvr7dRjgqNJNZ5UbbjL/v1BjAvQogy57Psdd/ek1u2s6IDEFYlabrA==",
"license": "Apache-2.0"
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -8216,6 +8255,45 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
@@ -8478,6 +8556,15 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",

View File

@@ -63,6 +63,8 @@
"pusher-js": "^8.4.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.1",
"react-markdown": "^10.0.0",