wahhhh
This commit is contained in:
@@ -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>
|
||||
<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))
|
||||
)}
|
||||
</div>
|
||||
<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 => (
|
||||
<CategoryItem key={category._id} category={category} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DndProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the category "{categoryToDelete?.name}"
|
||||
{getSubcategories(categoryToDelete?._id || "").length > 0 &&
|
||||
" and all its subcategories"}. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{categoryToDelete && (
|
||||
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the category "{categoryToDelete.name}".
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Category {
|
||||
_id: string;
|
||||
name: string;
|
||||
parentId?: string
|
||||
parentId?: string;
|
||||
order?: number;
|
||||
}
|
||||
89
package-lock.json
generated
89
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user