From 618ed0543dad0c88d5ac272563eb0384021a7b55 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Tue, 11 Nov 2025 15:55:32 +0000 Subject: [PATCH] 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. --- .../igny8_core/modules/writer/serializers.py | 13 + backend/igny8_core/modules/writer/views.py | 8 +- frontend/src/App.tsx | 3 +- .../src/components/common/ToggleTableRow.tsx | 16 +- frontend/src/config/pages/content.config.tsx | 247 +++++++++++ frontend/src/config/pages/tasks.config.tsx | 4 +- frontend/src/pages/Writer/Content.tsx | 387 ++++++++---------- frontend/src/pages/Writer/Tasks.tsx | 2 +- frontend/src/services/api.ts | 46 ++- 9 files changed, 498 insertions(+), 228 deletions(-) create mode 100644 frontend/src/config/pages/content.config.tsx diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index 9ead523d..a1930af7 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -144,6 +144,7 @@ class ImagesSerializer(serializers.ModelSerializer): class ContentSerializer(serializers.ModelSerializer): """Serializer for Content model""" task_title = serializers.SerializerMethodField() + sector_name = serializers.SerializerMethodField() class Meta: model = Content @@ -151,6 +152,7 @@ class ContentSerializer(serializers.ModelSerializer): 'id', 'task_id', 'task_title', + 'sector_name', 'html_content', 'word_count', 'metadata', @@ -177,4 +179,15 @@ class ContentSerializer(serializers.ModelSerializer): except Tasks.DoesNotExist: 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 diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index f9494a82..bfc79ea3 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -440,11 +440,13 @@ class ContentViewSet(SiteSectorModelViewSet): """ queryset = Content.objects.all() serializer_class = ContentSerializer + pagination_class = CustomPageNumberPagination - filter_backends = [DjangoFilterBackend, filters.OrderingFilter] - ordering_fields = ['generated_at', 'updated_at'] + filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] + search_fields = ['title', 'meta_title', 'primary_keyword'] + ordering_fields = ['generated_at', 'updated_at', 'word_count', 'status'] ordering = ['-generated_at'] - filterset_fields = ['task_id'] + filterset_fields = ['task_id', 'status'] def perform_create(self, serializer): """Override to automatically set account""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1f960a6..476a45fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportuni // Writer Module - Lazy loaded const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard")); const Tasks = lazy(() => import("./pages/Writer/Tasks")); +const Content = lazy(() => import("./pages/Writer/Content")); const Drafts = lazy(() => import("./pages/Writer/Drafts")); const Images = lazy(() => import("./pages/Writer/Images")); const Published = lazy(() => import("./pages/Writer/Published")); @@ -160,7 +161,7 @@ export default function App() { } /> - + } /> } /> diff --git a/frontend/src/components/common/ToggleTableRow.tsx b/frontend/src/components/common/ToggleTableRow.tsx index ee966a0f..52a8feba 100644 --- a/frontend/src/components/common/ToggleTableRow.tsx +++ b/frontend/src/components/common/ToggleTableRow.tsx @@ -109,7 +109,13 @@ const ToggleTableRow: React.FC = ({ >
@@ -117,6 +123,14 @@ const ToggleTableRow: React.FC = ({ {contentLabel}
+ {/* Show idea title if available (for Tasks page) */} + {row.idea_title && ( +
+
Idea:
+
{row.idea_title}
+
+ )} + {/* Metadata badges */} diff --git a/frontend/src/config/pages/content.config.tsx b/frontend/src/config/pages/content.config.tsx new file mode 100644 index 00000000..bfd673e7 --- /dev/null +++ b/frontend/src/config/pages/content.config.tsx @@ -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 {emptyLabel}; + } + + return ( +
+ {items.map((item, index) => ( + + {item} + + ))} +
+ ); +}; + +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 = { + 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) => ( +
+
+ {row.meta_title || row.title || row.task_title || `Task #${row.task}`} +
+ {row.meta_description && ( +
+ {row.meta_description} +
+ )} +
+ ), + }, + ...(showSectorColumn ? [{ + ...sectorColumn, + render: (value: string, row: Content) => ( + + {row.sector_name || '-'} + + ), + }] : []), + { + key: 'primary_keyword', + label: 'Primary Keyword', + sortable: false, + width: '150px', + render: (value: string, row: Content) => ( + row.primary_keyword ? ( + + {row.primary_keyword} + + ) : ( + - + ) + ), + }, + { + 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 ( + + {label} + + ); + }, + }, + { + ...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, + }, + ], + }; +}; + diff --git a/frontend/src/config/pages/tasks.config.tsx b/frontend/src/config/pages/tasks.config.tsx index bac44f7c..c191b4f3 100644 --- a/frontend/src/config/pages/tasks.config.tsx +++ b/frontend/src/config/pages/tasks.config.tsx @@ -100,8 +100,8 @@ export const createTasksPageConfig = ( sortable: true, sortField: 'title', toggleable: true, - toggleContentKey: 'content_html', - toggleContentLabel: 'Generated Content', + toggleContentKey: 'description', + toggleContentLabel: 'Idea & Content Outline', }, // Sector column - only show when viewing all sectors ...(showSectorColumn ? [{ diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx index ec5a77b3..a12fd292 100644 --- a/frontend/src/pages/Writer/Content.tsx +++ b/frontend/src/pages/Writer/Content.tsx @@ -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 = { - 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([]); const [loading, setLoading] = useState(true); - const [expandedId, setExpandedId] = useState(null); + + // Filter state + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + + // Sorting state + const [sortBy, setSortBy] = useState('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 {emptyLabel}; - } - - return ( -
- {items.map((item, index) => ( - - {item} - - ))} -
- ); - }; + // 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 ( -
- -
-

Content

-

Review AI-generated drafts and metadata

-
- - {loading ? ( -
-
Loading...
-
- ) : content.length === 0 ? ( -
- No content generated yet. Run an AI content job to see drafts here. -
- ) : ( -
- - - - - - - - - - - - - - - - {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 ( - - - - - - - - - - - - ); - })} - -
- Title - - Primary Keyword - - Secondary Keywords - - Tags - - Categories - - Word Count - - Status - - Generated - - Content -
-
- {item.meta_title || item.title || item.task_title || `Task #${item.task}`} -
- {(() => { - 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 ? ( -
- {metaDesc} -
- ) : null; - })()} -
- {item.primary_keyword ? ( - - {item.primary_keyword} - - ) : ( - - - )} - - {renderBadgeList(secondaryKeywords)} - - {renderBadgeList(tags)} - - {renderBadgeList(categories)} - - {item.word_count?.toLocaleString?.() ?? '-'} - - - {(item.status || 'draft').replace('_', ' ').replace(/^\w/, (c) => c.toUpperCase())} - - - {formatDate(item.generated_at)} - - -
-
- )} - - {content.map((item) => - expandedId === item.id ? ( -
-
-
-

- {item.meta_title || item.title || `Task #${item.task}`} -

-

- Generated {formatDate(item.generated_at)} • Task #{item.task} -

-
- -
- -
- ) : null - )} -
+ <> + } + 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}`} + /> + ); } diff --git a/frontend/src/pages/Writer/Tasks.tsx b/frontend/src/pages/Writer/Tasks.tsx index e195339a..6bf03530 100644 --- a/frontend/src/pages/Writer/Tasks.tsx +++ b/frontend/src/pages/Writer/Tasks.tsx @@ -548,7 +548,7 @@ export default function Tasks() { return ( <> } subtitle="Manage content generation queue and tasks" columns={pageConfig.columns} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f788e59b..a783e0fa 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1451,10 +1451,22 @@ export async function deleteAuthorProfile(id: number): Promise { } // 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 { id: number; task: number; task_title?: string | null; + sector_name?: string | null; title?: string | null; meta_title?: string | null; meta_description?: string | null; @@ -1477,13 +1489,35 @@ export interface ContentResponse { results: Content[]; } -export async function fetchContent(filters?: { - task_id?: number; - page?: number; -}): Promise { +export async function fetchContent(filters: ContentFilters = {}): Promise { 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(); return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);