Files
igny8/frontend/src/pages/Sites/PageManager.tsx
2025-11-18 06:36:56 +05:00

403 lines
13 KiB
TypeScript

/**
* Page Manager (Advanced)
* Phase 7: Advanced Site Management
* Features: Drag-drop reorder, bulk actions, selection
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { PlusIcon, EditIcon, TrashIcon, GripVerticalIcon, CheckSquareIcon, SquareIcon } from 'lucide-react';
import PageMeta from '../../components/common/PageMeta';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
interface Page {
id: number;
slug: string;
title: string;
type: string;
status: string;
order: number;
blocks: any[];
}
// Draggable Page Item Component
const DraggablePageItem: React.FC<{
page: Page;
index: number;
isSelected: boolean;
onSelect: (id: number) => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
movePage: (dragIndex: number, hoverIndex: number) => void;
}> = ({ page, index, isSelected, onSelect, onEdit, onDelete, movePage }) => {
const [{ isDragging }, drag] = useDrag({
type: 'page',
item: { id: page.id, index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'page',
hover: (draggedItem: { id: number; index: number }) => {
if (draggedItem.index !== index) {
movePage(draggedItem.index, index);
draggedItem.index = index;
}
},
});
return (
<div
ref={(node) => drag(drop(node))}
className={`flex items-center justify-between p-4 border rounded-lg transition-all ${
isDragging
? 'opacity-50 border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'
} ${isSelected ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''}`}
>
<div className="flex items-center gap-4 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSelect(page.id);
}}
className="cursor-pointer"
>
{isSelected ? (
<CheckSquareIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
) : (
<SquareIcon className="w-5 h-5 text-gray-400" />
)}
</button>
<GripVerticalIcon className="w-5 h-5 text-gray-400 cursor-move" />
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">{page.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
/{page.slug} {page.type} {page.status} Order: {page.order}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onEdit(page.id)}>
<EditIcon className="w-4 h-4 mr-1" />
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => onDelete(page.id)}>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
);
};
export default function PageManager() {
const { siteId } = useParams<{ siteId: string }>();
const navigate = useNavigate();
const toast = useToast();
const [pages, setPages] = useState<Page[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPages, setSelectedPages] = useState<Set<number>>(new Set());
const [isReordering, setIsReordering] = useState(false);
useEffect(() => {
if (siteId) {
loadPages();
}
}, [siteId]);
const loadPages = async () => {
try {
setLoading(true);
// First, get blueprints for this site
const blueprintsData = await fetchAPI(`/v1/site-builder/blueprints/?site=${siteId}`);
const blueprints = Array.isArray(blueprintsData?.results) ? blueprintsData.results : Array.isArray(blueprintsData) ? blueprintsData : [];
if (blueprints.length === 0) {
setPages([]);
return;
}
// Load pages from the first blueprint (or allow selection)
const blueprintId = blueprints[0].id;
const pagesData = await fetchAPI(`/v1/site-builder/pages/?site_blueprint=${blueprintId}`);
const pagesList = Array.isArray(pagesData?.results) ? pagesData.results : Array.isArray(pagesData) ? pagesData : [];
setPages(pagesList.sort((a, b) => a.order - b.order));
} catch (error: any) {
toast.error(`Failed to load pages: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleAddPage = () => {
navigate(`/sites/${siteId}/pages/new`);
};
const handleEditPage = (pageId: number) => {
navigate(`/sites/${siteId}/pages/${pageId}/edit`);
};
const handleDeletePage = async (pageId: number) => {
if (!confirm('Are you sure you want to delete this page?')) return;
try {
await fetchAPI(`/v1/site-builder/pages/${pageId}/`, {
method: 'DELETE',
});
toast.success('Page deleted successfully');
loadPages();
} catch (error: any) {
toast.error(`Failed to delete page: ${error.message}`);
}
};
const movePage = (dragIndex: number, hoverIndex: number) => {
const draggedPage = pages[dragIndex];
const newPages = [...pages];
newPages.splice(dragIndex, 1);
newPages.splice(hoverIndex, 0, draggedPage);
// Update order values
newPages.forEach((page, index) => {
page.order = index;
});
setPages(newPages);
setIsReordering(true);
};
const savePageOrder = async () => {
try {
// Update all pages' order
await Promise.all(
pages.map((page, index) =>
fetchAPI(`/v1/site-builder/pages/${page.id}/`, {
method: 'PATCH',
body: JSON.stringify({ order: index }),
})
)
);
toast.success('Page order saved');
setIsReordering(false);
} catch (error: any) {
toast.error(`Failed to save page order: ${error.message}`);
loadPages(); // Reload on error
}
};
const handleSelectPage = (pageId: number) => {
const newSelected = new Set(selectedPages);
if (newSelected.has(pageId)) {
newSelected.delete(pageId);
} else {
newSelected.add(pageId);
}
setSelectedPages(newSelected);
};
const handleSelectAll = () => {
if (selectedPages.size === pages.length) {
setSelectedPages(new Set());
} else {
setSelectedPages(new Set(pages.map((p) => p.id)));
}
};
const handleBulkDelete = async () => {
if (selectedPages.size === 0) {
toast.error('No pages selected');
return;
}
if (!confirm(`Are you sure you want to delete ${selectedPages.size} page(s)?`)) return;
try {
await Promise.all(
Array.from(selectedPages).map((id) =>
fetchAPI(`/v1/site-builder/pages/${id}/`, {
method: 'DELETE',
})
)
);
toast.success(`${selectedPages.size} page(s) deleted successfully`);
setSelectedPages(new Set());
loadPages();
} catch (error: any) {
toast.error(`Failed to delete pages: ${error.message}`);
}
};
const handleBulkStatusChange = async (newStatus: string) => {
if (selectedPages.size === 0) {
toast.error('No pages selected');
return;
}
try {
await Promise.all(
Array.from(selectedPages).map((id) =>
fetchAPI(`/v1/site-builder/pages/${id}/`, {
method: 'PATCH',
body: JSON.stringify({ status: newStatus }),
})
)
);
toast.success(`${selectedPages.size} page(s) updated`);
setSelectedPages(new Set());
loadPages();
} catch (error: any) {
toast.error(`Failed to update pages: ${error.message}`);
}
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Page Manager" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading pages...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Page Manager - IGNY8" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Page Manager
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage pages for your site
</p>
</div>
<Button onClick={handleAddPage} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Add Page
</Button>
</div>
{pages.length === 0 ? (
<Card className="p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
No pages created yet
</p>
<Button onClick={handleAddPage} variant="primary">
Add Your First Page
</Button>
</Card>
) : (
<>
{/* Bulk Actions Bar */}
{selectedPages.size > 0 && (
<Card className="p-4 mb-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{selectedPages.size} page(s) selected
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleBulkStatusChange('draft')}
>
Set to Draft
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkStatusChange('published')}
>
Set to Published
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBulkDelete}
className="text-red-600 hover:text-red-700"
>
<TrashIcon className="w-4 h-4 mr-1" />
Delete Selected
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPages(new Set())}>
Clear Selection
</Button>
</div>
</div>
</Card>
)}
{/* Reorder Save Button */}
{isReordering && (
<Card className="p-4 mb-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Page order changed. Save to apply changes.
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
loadPages();
setIsReordering(false);
}}>
Cancel
</Button>
<Button variant="primary" size="sm" onClick={savePageOrder}>
Save Order
</Button>
</div>
</div>
</Card>
)}
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<button
type="button"
onClick={handleSelectAll}
className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
{selectedPages.size === pages.length ? (
<CheckSquareIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
) : (
<SquareIcon className="w-5 h-5 text-gray-400" />
)}
<span>Select All</span>
</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
Drag and drop to reorder pages
</p>
</div>
<DndProvider backend={HTML5Backend}>
<div className="space-y-3">
{pages.map((page, index) => (
<DraggablePageItem
key={page.id}
page={page}
index={index}
isSelected={selectedPages.has(page.id)}
onSelect={handleSelectPage}
onEdit={handleEditPage}
onDelete={handleDeletePage}
movePage={movePage}
/>
))}
</div>
</DndProvider>
</Card>
</>
)}
</div>
);
}