Enhance Content Management: Add sector name to ContentSerializer, improve Content view with pagination and search filters, and refactor Content page for better data handling and display.
This commit is contained in:
@@ -1,232 +1,191 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
||||
/**
|
||||
* Content Page - Built with TablePageTemplate
|
||||
* Displays content from Content table with filters and pagination
|
||||
*/
|
||||
|
||||
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||
draft: 'warning',
|
||||
review: 'info',
|
||||
publish: 'success',
|
||||
};
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchContent,
|
||||
Content as ContentType,
|
||||
ContentFilters,
|
||||
} from '../../services/api';
|
||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { FileIcon } from '../../icons';
|
||||
import { createContentPageConfig } from '../../config/pages/content.config';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
|
||||
export default function Content() {
|
||||
const toast = useToast();
|
||||
const { activeSector } = useSectorStore();
|
||||
const { pageSize } = usePageSizeStore();
|
||||
|
||||
// Data state
|
||||
const [content, setContent] = useState<ContentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Sorting state
|
||||
const [sortBy, setSortBy] = useState<string>('generated_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
// Load content - wrapped in useCallback
|
||||
const loadContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setShowContent(false);
|
||||
try {
|
||||
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-generated_at';
|
||||
|
||||
const filters: ContentFilters = {
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
...(statusFilter && { status: statusFilter }),
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
ordering,
|
||||
};
|
||||
|
||||
const data = await fetchContent(filters);
|
||||
setContent(data.results || []);
|
||||
setTotalCount(data.count || 0);
|
||||
setTotalPages(Math.ceil((data.count || 0) / pageSize));
|
||||
|
||||
setTimeout(() => {
|
||||
setShowContent(true);
|
||||
setLoading(false);
|
||||
}, 100);
|
||||
} catch (error: any) {
|
||||
console.error('Error loading content:', error);
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
setShowContent(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
}, []);
|
||||
}, [loadContent]);
|
||||
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchContent();
|
||||
setContent(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Listen for site and sector changes and refresh data
|
||||
useEffect(() => {
|
||||
const handleSiteChange = () => {
|
||||
loadContent();
|
||||
};
|
||||
|
||||
const handleSectorChange = () => {
|
||||
loadContent();
|
||||
};
|
||||
|
||||
window.addEventListener('siteChanged', handleSiteChange);
|
||||
window.addEventListener('sectorChanged', handleSectorChange);
|
||||
return () => {
|
||||
window.removeEventListener('siteChanged', handleSiteChange);
|
||||
window.removeEventListener('sectorChanged', handleSectorChange);
|
||||
};
|
||||
}, [loadContent]);
|
||||
|
||||
// Reset to page 1 when pageSize changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentPage === 1) {
|
||||
loadContent();
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, currentPage, loadContent]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string, direction: 'asc' | 'desc') => {
|
||||
setSortBy(field || 'generated_at');
|
||||
setSortDirection(direction);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const formatDate = (value: string) =>
|
||||
new Date(value).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
// Create page config
|
||||
const pageConfig = useMemo(() => {
|
||||
return createContentPageConfig({
|
||||
activeSector,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
setCurrentPage,
|
||||
});
|
||||
}, [
|
||||
activeSector,
|
||||
searchTerm,
|
||||
statusFilter,
|
||||
]);
|
||||
|
||||
const getList = (primary?: string[], fallback?: any): string[] => {
|
||||
if (primary && primary.length > 0) return primary;
|
||||
if (!fallback) return [];
|
||||
if (Array.isArray(fallback)) return fallback;
|
||||
return [];
|
||||
};
|
||||
|
||||
const renderBadgeList = (items: string[], emptyLabel = '-') => {
|
||||
if (!items || items.length === 0) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">{emptyLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item, index) => (
|
||||
<Badge key={`${item}-${index}`} color="light" size="sm" variant="light">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Calculate header metrics
|
||||
const headerMetrics = useMemo(() => {
|
||||
if (!pageConfig?.headerMetrics) return [];
|
||||
return pageConfig.headerMetrics.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.calculate({ content, totalCount }),
|
||||
accentColor: metric.accentColor,
|
||||
}));
|
||||
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Review AI-generated drafts and metadata</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
) : content.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
No content generated yet. Run an AI content job to see drafts here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Primary Keyword
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Secondary Keywords
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Categories
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Word Count
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Generated
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Content
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
{content.map((item) => {
|
||||
const isExpanded = expandedId === item.id;
|
||||
const secondaryKeywords = getList(
|
||||
item.secondary_keywords,
|
||||
item.metadata?.secondary_keywords
|
||||
);
|
||||
const tags = getList(item.tags, item.metadata?.tags);
|
||||
const categories = getList(item.categories, item.metadata?.categories);
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="bg-white dark:bg-gray-900">
|
||||
<td className="px-5 py-4 align-top">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || item.task_title || `Task #${item.task}`}
|
||||
</div>
|
||||
{(() => {
|
||||
let metaDesc = item.meta_description;
|
||||
// If meta_description is a JSON string, extract the actual value
|
||||
if (metaDesc && typeof metaDesc === 'string' && metaDesc.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(metaDesc);
|
||||
metaDesc = parsed.meta_description || metaDesc;
|
||||
} catch {
|
||||
// Not valid JSON, use as-is
|
||||
}
|
||||
}
|
||||
return metaDesc ? (
|
||||
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{metaDesc}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{item.primary_keyword ? (
|
||||
<Badge color="info" size="sm" variant="light">
|
||||
{item.primary_keyword}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(secondaryKeywords)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(tags)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(categories)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-700 dark:text-gray-300">
|
||||
{item.word_count?.toLocaleString?.() ?? '-'}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
<Badge
|
||||
color={statusColors[item.status] || 'primary'}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{(item.status || 'draft').replace('_', ' ').replace(/^\w/, (c) => c.toUpperCase())}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-600 dark:text-gray-400">
|
||||
{formatDate(item.generated_at)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-right">
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.map((item) =>
|
||||
expandedId === item.id ? (
|
||||
<div
|
||||
key={`expanded-${item.id}`}
|
||||
className="mt-6 rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900 p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || `Task #${item.task}`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Generated {formatDate(item.generated_at)} • Task #{item.task}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpandedId(null)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<HTMLContentRenderer
|
||||
content={item.html_content}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<TablePageTemplate
|
||||
title="Content"
|
||||
titleIcon={<FileIcon className="text-brand-500 size-5" />}
|
||||
subtitle="Review AI-generated content and metadata"
|
||||
columns={pageConfig.columns}
|
||||
data={content}
|
||||
loading={loading}
|
||||
showContent={showContent}
|
||||
filters={pageConfig.filters}
|
||||
filterValues={{
|
||||
search: searchTerm,
|
||||
status: statusFilter,
|
||||
}}
|
||||
onFilterChange={(key: string, value: any) => {
|
||||
if (key === 'search') {
|
||||
setSearchTerm(value);
|
||||
} else if (key === 'status') {
|
||||
setStatusFilter(value);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalCount,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
sorting={{
|
||||
sortBy,
|
||||
sortDirection,
|
||||
onSort: handleSort,
|
||||
}}
|
||||
selection={{
|
||||
selectedIds,
|
||||
onSelectionChange: setSelectedIds,
|
||||
}}
|
||||
headerMetrics={headerMetrics}
|
||||
getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export default function Tasks() {
|
||||
return (
|
||||
<>
|
||||
<TablePageTemplate
|
||||
title="Content Tasks"
|
||||
title="Tasks"
|
||||
titleIcon={<TaskIcon className="text-brand-500 size-5" />}
|
||||
subtitle="Manage content generation queue and tasks"
|
||||
columns={pageConfig.columns}
|
||||
|
||||
Reference in New Issue
Block a user