tasks to published refactor

This commit is contained in:
alorig
2025-11-28 15:25:19 +05:00
parent 8103c20341
commit 1aead06939
10 changed files with 3330 additions and 12 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
/**
* ContentViewerModal - Display content HTML in a modal
*/
import { Modal } from '../ui/modal';
import { CloseIcon } from '../../icons';
interface ContentViewerModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
contentHtml: string;
}
export default function ContentViewerModal({
isOpen,
onClose,
title,
contentHtml,
}: ContentViewerModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="max-w-4xl w-full mx-4"
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
<CloseIcon className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="px-6 py-6 max-h-[70vh] overflow-y-auto">
<div
className="prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: contentHtml }}
/>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -82,6 +82,7 @@ export const createContentPageConfig = (
statusFilter: string;
setStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
onViewContent?: (row: Content) => void;
}
): ContentPageConfig => {
const showSectorColumn = !handlers.activeSector;
@@ -103,9 +104,18 @@ export const createContentPageConfig = (
toggleContentLabel: 'Generated Content',
render: (value: string, row: Content) => (
<div>
<div className="font-medium text-gray-900 dark:text-white">
{row.title || `Content #${row.id}`}
</div>
{handlers.onViewContent ? (
<button
onClick={() => handlers.onViewContent!(row)}
className="font-medium text-blue-500 hover:text-blue-600 hover:underline text-left transition-colors"
>
{row.title || `Content #${row.id}`}
</button>
) : (
<div className="font-medium text-gray-900 dark:text-white">
{row.title || `Content #${row.id}`}
</div>
)}
</div>
),
},
@@ -197,6 +207,52 @@ export const createContentPageConfig = (
);
},
},
{
key: 'prompts_status',
label: 'Prompts',
sortable: false,
width: '110px',
render: (_value: any, row: Content) => {
const hasPrompts = row.has_image_prompts;
return (
<Badge color={hasPrompts ? 'success' : 'warning'} size="sm" variant="light">
{hasPrompts ? (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Ready
</span>
) : (
'No Prompts'
)}
</Badge>
);
},
},
{
key: 'images_status',
label: 'Images',
sortable: false,
width: '110px',
render: (_value: any, row: Content) => {
const hasImages = row.has_generated_images;
return (
<Badge color={hasImages ? 'success' : 'warning'} size="sm" variant="light">
{hasImages ? (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Generated
</span>
) : (
'No Images'
)}
</Badge>
);
},
},
{
key: 'source',
label: 'Source',

View File

@@ -0,0 +1,229 @@
/**
* Published Page Configuration
* Centralized config for Published page table, filters, and actions
*/
import { Content } from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { CheckCircleIcon, ArrowRightIcon } from '../../icons';
import { STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping';
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
numeric?: boolean;
date?: boolean;
render?: (value: any, row: any) => React.ReactNode;
toggleable?: boolean;
toggleContentKey?: string;
toggleContentLabel?: string;
defaultVisible?: boolean;
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
}
export interface HeaderMetricConfig {
label: string;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { content: Content[]; totalCount: number }) => number;
}
export interface PublishedPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
headerMetrics: HeaderMetricConfig[];
}
export function createPublishedPageConfig(params: {
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
publishStatusFilter: string;
setPublishStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
activeSector: { id: number; name: string } | null;
}): PublishedPageConfig {
const showSectorColumn = !params.activeSector;
const columns: ColumnConfig[] = [
{
key: 'title',
label: 'Title',
sortable: true,
sortField: 'title',
toggleable: true,
toggleContentKey: 'content_html',
toggleContentLabel: 'Generated Content',
render: (value: string, row: Content) => (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{value || `Content #${row.id}`}
</span>
{row.external_url && (
<a
href={row.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 transition-colors"
title="View on WordPress"
>
<ArrowRightIcon className="w-4 h-4" />
</a>
)}
</div>
),
},
{
key: 'status',
label: 'Content Status',
sortable: true,
sortField: 'status',
width: '120px',
render: (value: string) => {
const statusConfig: Record<string, { color: 'warning' | 'success'; label: string }> = {
draft: { color: 'warning', label: 'Draft' },
published: { color: 'success', label: 'Published' },
};
const config = statusConfig[value] || { color: 'warning' as const, label: value };
return (
<Badge color={config.color} size="sm" variant="light">
{config.label}
</Badge>
);
},
},
{
key: 'wordpress_status',
label: 'WordPress',
sortable: false,
width: '140px',
render: (_value: any, row: Content) => {
if (row.external_id && row.external_url) {
return (
<Badge color="success" size="sm" variant="light">
<CheckCircleIcon className="w-3 h-3 mr-1" />
Published
</Badge>
);
}
return (
<Badge color="warning" size="sm" variant="light">
Not Published
</Badge>
);
},
},
{
key: 'content_type',
label: 'Type',
sortable: true,
sortField: 'content_type',
width: '120px',
render: (value: string) => (
<Badge color="primary" size="sm" variant="light">
{TYPE_LABELS[value] || value || '-'}
</Badge>
),
},
{
key: 'content_structure',
label: 'Structure',
sortable: true,
sortField: 'content_structure',
width: '150px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{STRUCTURE_LABELS[value] || value || '-'}
</Badge>
),
},
{
key: 'word_count',
label: 'Words',
sortable: true,
sortField: 'word_count',
numeric: true,
width: '100px',
align: 'right' as const,
render: (value: number) => (
<span className="text-gray-900 dark:text-white">
{value ? value.toLocaleString() : '-'}
</span>
),
},
{
key: 'created_at',
label: 'Created',
sortable: true,
sortField: 'created_at',
date: true,
width: '140px',
render: (value: string) => (
<span className="text-gray-600 dark:text-gray-400">
{formatRelativeDate(value)}
</span>
),
},
];
const filters: FilterConfig[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search published content...',
},
{
key: 'status',
label: 'Content Status',
type: 'select',
options: [
{ value: '', label: 'All Statuses' },
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
],
},
{
key: 'publishStatus',
label: 'WordPress Status',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'published', label: 'Published to WP' },
{ value: 'not_published', label: 'Not Published' },
],
},
];
const headerMetrics: HeaderMetricConfig[] = [
{
label: 'Total Published',
accentColor: 'green',
calculate: (data: { totalCount: number }) => data.totalCount,
},
{
label: 'On WordPress',
accentColor: 'blue',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.external_id).length,
},
];
return {
columns,
filters,
headerMetrics,
};
}

View File

@@ -299,12 +299,32 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
rowActions: [
{
key: 'edit',
label: 'Edit',
label: 'Edit Content',
icon: EditIcon,
variant: 'primary',
},
{
key: 'publish_wordpress',
label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success',
shouldShow: (row: any) => !row.external_id, // Only show if not published
},
{
key: 'view_on_wordpress',
label: 'View on WordPress',
icon: <CheckCircleIcon className="w-5 h-5 text-blue-500" />,
variant: 'secondary',
shouldShow: (row: any) => !!row.external_id, // Only show if published
},
],
bulkActions: [
{
key: 'bulk_publish_wordpress',
label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-4 h-4" />,
variant: 'success',
},
{
key: 'update_status',
label: 'Update Status',

View File

@@ -20,6 +20,7 @@ import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import ContentViewerModal from '../../components/common/ContentViewerModal';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
@@ -49,6 +50,10 @@ export default function Content() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Content viewer modal state
const [isViewerModalOpen, setIsViewerModalOpen] = useState(false);
const [viewerContent, setViewerContent] = useState<ContentType | null>(null);
// Progress modal for AI functions
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
@@ -133,6 +138,12 @@ export default function Content() {
setCurrentPage(1);
};
// Handle view content
const handleViewContent = useCallback((row: ContentType) => {
setViewerContent(row);
setIsViewerModalOpen(true);
}, []);
// Create page config
const pageConfig = useMemo(() => {
return createContentPageConfig({
@@ -142,11 +153,13 @@ export default function Content() {
statusFilter,
setStatusFilter,
setCurrentPage,
onViewContent: handleViewContent,
});
}, [
activeSector,
searchTerm,
statusFilter,
handleViewContent,
]);
// Calculate header metrics
@@ -286,6 +299,17 @@ export default function Content() {
}}
/>
{/* Content Viewer Modal */}
<ContentViewerModal
isOpen={isViewerModalOpen}
onClose={() => {
setIsViewerModalOpen(false);
setViewerContent(null);
}}
title={viewerContent?.title || 'Content'}
contentHtml={viewerContent?.content_html || ''}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}

View File

@@ -14,6 +14,8 @@ import {
bulkUpdateImagesStatus,
ContentImage,
fetchAPI,
deleteContent,
bulkDeleteContent,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon, BoltIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons';
@@ -135,7 +137,13 @@ export default function Images() {
const endIndex = startIndex + pageSize;
const paginatedResults = filteredResults.slice(startIndex, endIndex);
setImages(paginatedResults);
// Transform data to add 'id' field for TablePageTemplate selection
const transformedResults = paginatedResults.map(group => ({
...group,
id: group.content_id // Add id field that mirrors content_id
}));
setImages(transformedResults);
setTotalCount(filteredResults.length);
setTotalPages(Math.ceil(filteredResults.length / pageSize));
@@ -205,6 +213,31 @@ export default function Images() {
}
}, [toast]);
// Delete handler for single content
const handleDelete = useCallback(async (id: number) => {
try {
await deleteContent(id);
toast.success('Content and images deleted successfully');
loadImages();
} catch (error: any) {
toast.error(`Failed to delete: ${error.message}`);
throw error;
}
}, [loadImages, toast]);
// Bulk delete handler
const handleBulkDelete = useCallback(async (ids: number[]) => {
try {
const result = await bulkDeleteContent(ids);
toast.success(`Deleted ${result.deleted_count} content item(s) and their images`);
loadImages();
return result;
} catch (error: any) {
toast.error(`Failed to bulk delete: ${error.message}`);
throw error;
}
}, [loadImages, toast]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_wordpress') {
@@ -575,6 +608,8 @@ export default function Images() {
}}
onBulkExport={handleBulkExport}
onBulkAction={handleBulkAction}
onDelete={handleDelete}
onBulkDelete={handleBulkDelete}
getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`}
onExport={async () => {
toast.info('Export functionality coming soon');

View File

@@ -1,13 +1,359 @@
/**
* Published Page - Filtered Tasks with status='published'
* Consistent with Keywords page layout, structure and design
* Published Page - Built with TablePageTemplate
* Shows published/review content with WordPress publishing capabilities
*/
import Tasks from './Tasks';
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContent,
Content,
ContentListResponse,
ContentFilters,
fetchAPI,
} from '../../services/api';
import { useNavigate } from 'react-router';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons';
import { createPublishedPageConfig } from '../../config/pages/published.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
export default function Published() {
// Published is just Tasks with status='published' filter applied
// For now, we'll use the Tasks component but could enhance it later
// to show only published status tasks by default
return <Tasks />;
const toast = useToast();
const navigate = useNavigate();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
// Filter state - default to published/review status
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('published'); // Default to published
const [publishStatusFilter, setPublishStatusFilter] = 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>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load content - filtered for published/review
const loadContent = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
page: currentPage,
page_size: pageSize,
ordering,
};
const data: ContentListResponse = await fetchContent(filters);
// Client-side filter for WordPress publish status if needed
let filteredResults = data.results || [];
if (publishStatusFilter === 'published') {
filteredResults = filteredResults.filter(c => c.external_id);
} else if (publishStatusFilter === 'not_published') {
filteredResults = filteredResults.filter(c => !c.external_id);
}
setContent(filteredResults);
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, publishStatusFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
useEffect(() => {
loadContent();
}, [loadContent]);
// 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 || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'publish_wordpress') {
try {
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: row.id,
destinations: ['wordpress']
})
});
if (response.success) {
toast.success(`Published "${row.title}" to WordPress`);
loadContent();
} else {
toast.error(response.error || 'Failed to publish');
}
} catch (error: any) {
console.error('WordPress publish error:', error);
toast.error(`Failed to publish: ${error.message || 'Network error'}`);
}
} else if (action === 'view_on_wordpress') {
if (row.external_url) {
window.open(row.external_url, '_blank');
} else {
toast.warning('WordPress URL not available');
}
} else if (action === 'edit') {
// Navigate to content editor (if exists) or show edit modal
navigate(`/writer/content?id=${row.id}`);
}
}, [toast, loadContent, navigate]);
// Bulk WordPress publish
const handleBulkPublishWordPress = useCallback(async (ids: string[]) => {
try {
const contentIds = ids.map(id => parseInt(id));
let successCount = 0;
let failedCount = 0;
// Publish each item individually
for (const contentId of contentIds) {
try {
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: contentId,
destinations: ['wordpress']
})
});
if (response.success) {
successCount++;
} else {
failedCount++;
console.warn(`Failed to publish content ${contentId}:`, response.error);
}
} catch (error) {
failedCount++;
console.error(`Error publishing content ${contentId}:`, error);
}
}
if (successCount > 0) {
toast.success(`Published ${successCount} item(s) to WordPress`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to publish`);
}
loadContent();
} catch (error: any) {
toast.error(`Failed to bulk publish: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_wordpress') {
await handleBulkPublishWordPress(ids);
}
}, [handleBulkPublishWordPress]);
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
// Note: This would need a backend endpoint like /v1/writer/content/bulk_update/
// For now, just show a toast
toast.info('Bulk status update functionality coming soon');
} catch (error: any) {
throw error;
}
}, [toast]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, [toast]);
// Create page config
const pageConfig = useMemo(() => {
return createPublishedPageConfig({
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
publishStatusFilter,
setPublishStatusFilter,
setCurrentPage,
activeSector,
});
}, [searchTerm, statusFilter, publishStatusFilter, activeSector]);
// 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]);
// Writer navigation tabs
const writerTabs = [
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
];
return (
<>
<PageHeader
title="Published Content"
badge={{ icon: <CheckCircleIcon />, color: 'green' }}
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
/>
<TablePageTemplate
columns={pageConfig.columns}
data={content}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
publishStatus: publishStatusFilter,
}}
onFilterChange={(key: string, value: any) => {
if (key === 'search') {
setSearchTerm(value);
} else if (key === 'status') {
setStatusFilter(value);
setCurrentPage(1);
} else if (key === 'publishStatus') {
setPublishStatusFilter(value);
setCurrentPage(1);
}
}}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onRowAction={handleRowAction}
onBulkAction={handleBulkAction}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkExport={handleBulkExport}
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
/>
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Published Content',
value: content.filter(c => c.status === 'published').length.toLocaleString(),
subtitle: `${content.filter(c => c.external_id).length} on WordPress`,
icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'green',
href: '/writer/published',
},
{
title: 'Draft Content',
value: content.filter(c => c.status === 'draft').length.toLocaleString(),
subtitle: 'Not yet published',
icon: <FileIcon className="w-5 h-5" />,
accentColor: 'blue',
},
]}
progress={{
label: 'WordPress Publishing Progress',
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
color: 'success',
}}
/>
</>
);
}

View File

@@ -2119,6 +2119,19 @@ export async function unpublishContent(id: number): Promise<UnpublishContentResu
});
}
export async function deleteContent(id: number): Promise<void> {
return fetchAPI(`/v1/writer/content/${id}/`, {
method: 'DELETE',
});
}
export async function bulkDeleteContent(ids: number[]): Promise<{ deleted_count: number }> {
return fetchAPI(`/v1/writer/content/bulk_delete/`, {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
// Stage 3: Content Validation API
export interface ContentValidationResult {
content_id: number;