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:
@@ -144,6 +144,7 @@ class ImagesSerializer(serializers.ModelSerializer):
|
|||||||
class ContentSerializer(serializers.ModelSerializer):
|
class ContentSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Content model"""
|
"""Serializer for Content model"""
|
||||||
task_title = serializers.SerializerMethodField()
|
task_title = serializers.SerializerMethodField()
|
||||||
|
sector_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Content
|
model = Content
|
||||||
@@ -151,6 +152,7 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'id',
|
'id',
|
||||||
'task_id',
|
'task_id',
|
||||||
'task_title',
|
'task_title',
|
||||||
|
'sector_name',
|
||||||
'html_content',
|
'html_content',
|
||||||
'word_count',
|
'word_count',
|
||||||
'metadata',
|
'metadata',
|
||||||
@@ -177,4 +179,15 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
except Tasks.DoesNotExist:
|
except Tasks.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_sector_name(self, obj):
|
||||||
|
"""Get sector name from Sector model"""
|
||||||
|
if obj.sector_id:
|
||||||
|
try:
|
||||||
|
from igny8_core.auth.models import Sector
|
||||||
|
sector = Sector.objects.get(id=obj.sector_id)
|
||||||
|
return sector.name
|
||||||
|
except Sector.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -440,11 +440,13 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Content.objects.all()
|
queryset = Content.objects.all()
|
||||||
serializer_class = ContentSerializer
|
serializer_class = ContentSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
ordering_fields = ['generated_at', 'updated_at']
|
search_fields = ['title', 'meta_title', 'primary_keyword']
|
||||||
|
ordering_fields = ['generated_at', 'updated_at', 'word_count', 'status']
|
||||||
ordering = ['-generated_at']
|
ordering = ['-generated_at']
|
||||||
filterset_fields = ['task_id']
|
filterset_fields = ['task_id', 'status']
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Override to automatically set account"""
|
"""Override to automatically set account"""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportuni
|
|||||||
// Writer Module - Lazy loaded
|
// Writer Module - Lazy loaded
|
||||||
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
||||||
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
||||||
|
const Content = lazy(() => import("./pages/Writer/Content"));
|
||||||
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||||
const Published = lazy(() => import("./pages/Writer/Published"));
|
const Published = lazy(() => import("./pages/Writer/Published"));
|
||||||
@@ -160,7 +161,7 @@ export default function App() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/writer/content" element={
|
<Route path="/writer/content" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Drafts />
|
<Content />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
|
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
|
||||||
|
|||||||
@@ -109,7 +109,13 @@ const ToggleTableRow: React.FC<ToggleTableRowProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden bg-white dark:bg-gray-900"
|
||||||
|
style={{
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: 'auto',
|
||||||
|
padding: '25px',
|
||||||
|
marginTop: '50px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="py-4 px-2">
|
<div className="py-4 px-2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -117,6 +123,14 @@ const ToggleTableRow: React.FC<ToggleTableRowProps> = ({
|
|||||||
{contentLabel}
|
{contentLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Show idea title if available (for Tasks page) */}
|
||||||
|
{row.idea_title && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-1">Idea:</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{row.idea_title}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metadata badges */}
|
{/* Metadata badges */}
|
||||||
<ToggleMetadata row={row} contentMetadata={contentMetadata} />
|
<ToggleMetadata row={row} contentMetadata={contentMetadata} />
|
||||||
|
|
||||||
|
|||||||
247
frontend/src/config/pages/content.config.tsx
Normal file
247
frontend/src/config/pages/content.config.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Content Page Configuration
|
||||||
|
* Centralized config for Content page table, filters, and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
titleColumn,
|
||||||
|
statusColumn,
|
||||||
|
createdColumn,
|
||||||
|
wordCountColumn,
|
||||||
|
sectorColumn,
|
||||||
|
} from '../snippets/columns.snippets';
|
||||||
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
import { formatRelativeDate } from '../../utils/date';
|
||||||
|
import { Content } from '../../services/api';
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortField?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
width?: string;
|
||||||
|
render?: (value: any, row: any) => React.ReactNode;
|
||||||
|
toggleable?: boolean;
|
||||||
|
toggleContentKey?: string;
|
||||||
|
toggleContentLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'select';
|
||||||
|
placeholder?: string;
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderMetricConfig {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||||
|
calculate: (data: { content: any[]; totalCount: number }) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentPageConfig {
|
||||||
|
columns: ColumnConfig[];
|
||||||
|
filters: FilterConfig[];
|
||||||
|
headerMetrics: HeaderMetricConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createContentPageConfig = (
|
||||||
|
handlers: {
|
||||||
|
activeSector: { id: number; name: string } | null;
|
||||||
|
searchTerm: string;
|
||||||
|
setSearchTerm: (value: string) => void;
|
||||||
|
statusFilter: string;
|
||||||
|
setStatusFilter: (value: string) => void;
|
||||||
|
setCurrentPage: (page: number) => void;
|
||||||
|
}
|
||||||
|
): ContentPageConfig => {
|
||||||
|
const showSectorColumn = !handlers.activeSector;
|
||||||
|
|
||||||
|
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||||
|
draft: 'warning',
|
||||||
|
review: 'info',
|
||||||
|
publish: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
...titleColumn,
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'title',
|
||||||
|
toggleable: true,
|
||||||
|
toggleContentKey: 'html_content',
|
||||||
|
toggleContentLabel: 'Generated Content',
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{row.meta_title || row.title || row.task_title || `Task #${row.task}`}
|
||||||
|
</div>
|
||||||
|
{row.meta_description && (
|
||||||
|
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{row.meta_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(showSectorColumn ? [{
|
||||||
|
...sectorColumn,
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
{row.sector_name || '-'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
key: 'primary_keyword',
|
||||||
|
label: 'Primary Keyword',
|
||||||
|
sortable: false,
|
||||||
|
width: '150px',
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
row.primary_keyword ? (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
{row.primary_keyword}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'secondary_keywords',
|
||||||
|
label: 'Secondary Keywords',
|
||||||
|
sortable: false,
|
||||||
|
width: '200px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const secondaryKeywords = getList(
|
||||||
|
row.secondary_keywords,
|
||||||
|
row.metadata?.secondary_keywords
|
||||||
|
);
|
||||||
|
return renderBadgeList(secondaryKeywords);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
sortable: false,
|
||||||
|
width: '150px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const tags = getList(row.tags, row.metadata?.tags);
|
||||||
|
return renderBadgeList(tags);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'categories',
|
||||||
|
label: 'Categories',
|
||||||
|
sortable: false,
|
||||||
|
width: '150px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const categories = getList(row.categories, row.metadata?.categories);
|
||||||
|
return renderBadgeList(categories);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...wordCountColumn,
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'word_count',
|
||||||
|
render: (value: number) => value?.toLocaleString() ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...statusColumn,
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'status',
|
||||||
|
render: (value: string) => {
|
||||||
|
const status = value || 'draft';
|
||||||
|
const color = statusColors[status] || 'primary';
|
||||||
|
const label = status.replace('_', ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||||||
|
return (
|
||||||
|
<Badge color={color} size="sm" variant="light">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createdColumn,
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'generated_at',
|
||||||
|
label: 'Generated',
|
||||||
|
render: (value: string) => formatRelativeDate(value),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search content...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'All Status' },
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
|
{ value: 'publish', label: 'Publish' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
headerMetrics: [
|
||||||
|
{
|
||||||
|
label: 'Total Content',
|
||||||
|
value: 0,
|
||||||
|
accentColor: 'blue' as const,
|
||||||
|
calculate: (data) => data.totalCount || 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Draft',
|
||||||
|
value: 0,
|
||||||
|
accentColor: 'warning' as const,
|
||||||
|
calculate: (data) => data.content.filter((c: Content) => c.status === 'draft').length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Review',
|
||||||
|
value: 0,
|
||||||
|
accentColor: 'info' as const,
|
||||||
|
calculate: (data) => data.content.filter((c: Content) => c.status === 'review').length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Published',
|
||||||
|
value: 0,
|
||||||
|
accentColor: 'success' as const,
|
||||||
|
calculate: (data) => data.content.filter((c: Content) => c.status === 'publish').length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ export const createTasksPageConfig = (
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
sortField: 'title',
|
sortField: 'title',
|
||||||
toggleable: true,
|
toggleable: true,
|
||||||
toggleContentKey: 'content_html',
|
toggleContentKey: 'description',
|
||||||
toggleContentLabel: 'Generated Content',
|
toggleContentLabel: 'Idea & Content Outline',
|
||||||
},
|
},
|
||||||
// Sector column - only show when viewing all sectors
|
// Sector column - only show when viewing all sectors
|
||||||
...(showSectorColumn ? [{
|
...(showSectorColumn ? [{
|
||||||
|
|||||||
@@ -1,232 +1,191 @@
|
|||||||
import { useState, useEffect } from 'react';
|
/**
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
* Content Page - Built with TablePageTemplate
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
* Displays content from Content table with filters and pagination
|
||||||
import { fetchContent, Content as ContentType } from '../../services/api';
|
*/
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
|
||||||
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
|
||||||
|
|
||||||
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
draft: 'warning',
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
review: 'info',
|
import {
|
||||||
publish: 'success',
|
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() {
|
export default function Content() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { activeSector } = useSectorStore();
|
||||||
|
const { pageSize } = usePageSizeStore();
|
||||||
|
|
||||||
|
// Data state
|
||||||
const [content, setContent] = useState<ContentType[]>([]);
|
const [content, setContent] = useState<ContentType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
loadContent();
|
loadContent();
|
||||||
}, []);
|
}, [loadContent]);
|
||||||
|
|
||||||
const loadContent = async () => {
|
// Listen for site and sector changes and refresh data
|
||||||
try {
|
useEffect(() => {
|
||||||
setLoading(true);
|
const handleSiteChange = () => {
|
||||||
const response = await fetchContent();
|
loadContent();
|
||||||
setContent(response.results || []);
|
};
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to load content: ${error.message}`);
|
const handleSectorChange = () => {
|
||||||
} finally {
|
loadContent();
|
||||||
setLoading(false);
|
};
|
||||||
}
|
|
||||||
|
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) =>
|
// Create page config
|
||||||
new Date(value).toLocaleString(undefined, {
|
const pageConfig = useMemo(() => {
|
||||||
year: 'numeric',
|
return createContentPageConfig({
|
||||||
month: 'short',
|
activeSector,
|
||||||
day: 'numeric',
|
searchTerm,
|
||||||
hour: 'numeric',
|
setSearchTerm,
|
||||||
minute: 'numeric',
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
setCurrentPage,
|
||||||
});
|
});
|
||||||
|
}, [
|
||||||
|
activeSector,
|
||||||
|
searchTerm,
|
||||||
|
statusFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
const getList = (primary?: string[], fallback?: any): string[] => {
|
// Calculate header metrics
|
||||||
if (primary && primary.length > 0) return primary;
|
const headerMetrics = useMemo(() => {
|
||||||
if (!fallback) return [];
|
if (!pageConfig?.headerMetrics) return [];
|
||||||
if (Array.isArray(fallback)) return fallback;
|
return pageConfig.headerMetrics.map((metric) => ({
|
||||||
return [];
|
label: metric.label,
|
||||||
};
|
value: metric.calculate({ content, totalCount }),
|
||||||
|
accentColor: metric.accentColor,
|
||||||
const renderBadgeList = (items: string[], emptyLabel = '-') => {
|
}));
|
||||||
if (!items || items.length === 0) {
|
}, [pageConfig?.headerMetrics, content, totalCount]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<>
|
||||||
<PageMeta title="Content" />
|
<TablePageTemplate
|
||||||
<div className="mb-6">
|
title="Content"
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
titleIcon={<FileIcon className="text-brand-500 size-5" />}
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Review AI-generated drafts and metadata</p>
|
subtitle="Review AI-generated content and metadata"
|
||||||
</div>
|
columns={pageConfig.columns}
|
||||||
|
data={content}
|
||||||
{loading ? (
|
loading={loading}
|
||||||
<div className="flex items-center justify-center h-64">
|
showContent={showContent}
|
||||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
filters={pageConfig.filters}
|
||||||
</div>
|
filterValues={{
|
||||||
) : content.length === 0 ? (
|
search: searchTerm,
|
||||||
<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">
|
status: statusFilter,
|
||||||
No content generated yet. Run an AI content job to see drafts here.
|
}}
|
||||||
</div>
|
onFilterChange={(key: string, value: any) => {
|
||||||
) : (
|
if (key === 'search') {
|
||||||
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900">
|
setSearchTerm(value);
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.05]">
|
} else if (key === 'status') {
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
setStatusFilter(value);
|
||||||
<tr>
|
setCurrentPage(1);
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
}
|
||||||
Title
|
}}
|
||||||
</th>
|
pagination={{
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
currentPage,
|
||||||
Primary Keyword
|
totalPages,
|
||||||
</th>
|
totalCount,
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
onPageChange: setCurrentPage,
|
||||||
Secondary Keywords
|
}}
|
||||||
</th>
|
sorting={{
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
sortBy,
|
||||||
Tags
|
sortDirection,
|
||||||
</th>
|
onSort: handleSort,
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
}}
|
||||||
Categories
|
selection={{
|
||||||
</th>
|
selectedIds,
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
onSelectionChange: setSelectedIds,
|
||||||
Word Count
|
}}
|
||||||
</th>
|
headerMetrics={headerMetrics}
|
||||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ export default function Tasks() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TablePageTemplate
|
<TablePageTemplate
|
||||||
title="Content Tasks"
|
title="Tasks"
|
||||||
titleIcon={<TaskIcon className="text-brand-500 size-5" />}
|
titleIcon={<TaskIcon className="text-brand-500 size-5" />}
|
||||||
subtitle="Manage content generation queue and tasks"
|
subtitle="Manage content generation queue and tasks"
|
||||||
columns={pageConfig.columns}
|
columns={pageConfig.columns}
|
||||||
|
|||||||
@@ -1451,10 +1451,22 @@ export async function deleteAuthorProfile(id: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Content API
|
// Content API
|
||||||
|
export interface ContentFilters {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
task_id?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
ordering?: string;
|
||||||
|
site_id?: number;
|
||||||
|
sector_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Content {
|
export interface Content {
|
||||||
id: number;
|
id: number;
|
||||||
task: number;
|
task: number;
|
||||||
task_title?: string | null;
|
task_title?: string | null;
|
||||||
|
sector_name?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
meta_title?: string | null;
|
meta_title?: string | null;
|
||||||
meta_description?: string | null;
|
meta_description?: string | null;
|
||||||
@@ -1477,13 +1489,35 @@ export interface ContentResponse {
|
|||||||
results: Content[];
|
results: Content[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchContent(filters?: {
|
export async function fetchContent(filters: ContentFilters = {}): Promise<ContentResponse> {
|
||||||
task_id?: number;
|
|
||||||
page?: number;
|
|
||||||
}): Promise<ContentResponse> {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.task_id) params.append('task_id', filters.task_id.toString());
|
|
||||||
if (filters?.page) params.append('page', filters.page.toString());
|
// Automatically add active site filter if not explicitly provided
|
||||||
|
if (!filters.site_id) {
|
||||||
|
const activeSiteId = getActiveSiteId();
|
||||||
|
if (activeSiteId) {
|
||||||
|
filters.site_id = activeSiteId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically add active sector filter if not explicitly provided
|
||||||
|
if (filters.sector_id === undefined) {
|
||||||
|
const activeSectorId = getActiveSectorId();
|
||||||
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
||||||
|
filters.sector_id = activeSectorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.status) params.append('status', filters.status);
|
||||||
|
if (filters.task_id) params.append('task_id', filters.task_id.toString());
|
||||||
|
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
||||||
|
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
||||||
|
if (filters.page) params.append('page', filters.page.toString());
|
||||||
|
if (filters.page_size !== undefined && filters.page_size !== null) {
|
||||||
|
params.append('page_size', filters.page_size.toString());
|
||||||
|
}
|
||||||
|
if (filters.ordering) params.append('ordering', filters.ordering);
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
|
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user