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

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;