tasks to published refactor
This commit is contained in:
1228
WRITER_IMAGES_PAGE_SYSTEM_DESIGN.md
Normal file
1228
WRITER_IMAGES_PAGE_SYSTEM_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
1306
WRITER_MODULE_REFACTORING_PLAN.md
Normal file
1306
WRITER_MODULE_REFACTORING_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
61
frontend/src/components/common/ContentViewerModal.tsx
Normal file
61
frontend/src/components/common/ContentViewerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ export const createContentPageConfig = (
|
|||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
setStatusFilter: (value: string) => void;
|
setStatusFilter: (value: string) => void;
|
||||||
setCurrentPage: (page: number) => void;
|
setCurrentPage: (page: number) => void;
|
||||||
|
onViewContent?: (row: Content) => void;
|
||||||
}
|
}
|
||||||
): ContentPageConfig => {
|
): ContentPageConfig => {
|
||||||
const showSectorColumn = !handlers.activeSector;
|
const showSectorColumn = !handlers.activeSector;
|
||||||
@@ -103,9 +104,18 @@ export const createContentPageConfig = (
|
|||||||
toggleContentLabel: 'Generated Content',
|
toggleContentLabel: 'Generated Content',
|
||||||
render: (value: string, row: Content) => (
|
render: (value: string, row: Content) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
{handlers.onViewContent ? (
|
||||||
{row.title || `Content #${row.id}`}
|
<button
|
||||||
</div>
|
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>
|
</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',
|
key: 'source',
|
||||||
label: 'Source',
|
label: 'Source',
|
||||||
|
|||||||
229
frontend/src/config/pages/published.config.tsx
Normal file
229
frontend/src/config/pages/published.config.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -299,12 +299,32 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
rowActions: [
|
rowActions: [
|
||||||
{
|
{
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
label: 'Edit',
|
label: 'Edit Content',
|
||||||
icon: EditIcon,
|
icon: EditIcon,
|
||||||
variant: 'primary',
|
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: [
|
bulkActions: [
|
||||||
|
{
|
||||||
|
key: 'bulk_publish_wordpress',
|
||||||
|
label: 'Publish to WordPress',
|
||||||
|
icon: <ArrowRightIcon className="w-4 h-4" />,
|
||||||
|
variant: 'success',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'update_status',
|
key: 'update_status',
|
||||||
label: 'Update Status',
|
label: 'Update Status',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useSectorStore } from '../../store/sectorStore';
|
|||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import ProgressModal from '../../components/common/ProgressModal';
|
import ProgressModal from '../../components/common/ProgressModal';
|
||||||
import { useProgressModal } from '../../hooks/useProgressModal';
|
import { useProgressModal } from '../../hooks/useProgressModal';
|
||||||
|
import ContentViewerModal from '../../components/common/ContentViewerModal';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||||
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
||||||
@@ -49,6 +50,10 @@ export default function Content() {
|
|||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showContent, setShowContent] = useState(false);
|
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
|
// Progress modal for AI functions
|
||||||
const progressModal = useProgressModal();
|
const progressModal = useProgressModal();
|
||||||
const hasReloadedRef = useRef(false);
|
const hasReloadedRef = useRef(false);
|
||||||
@@ -133,6 +138,12 @@ export default function Content() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle view content
|
||||||
|
const handleViewContent = useCallback((row: ContentType) => {
|
||||||
|
setViewerContent(row);
|
||||||
|
setIsViewerModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Create page config
|
// Create page config
|
||||||
const pageConfig = useMemo(() => {
|
const pageConfig = useMemo(() => {
|
||||||
return createContentPageConfig({
|
return createContentPageConfig({
|
||||||
@@ -142,11 +153,13 @@ export default function Content() {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
onViewContent: handleViewContent,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
activeSector,
|
activeSector,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
handleViewContent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate header metrics
|
// 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 */}
|
{/* Progress Modal for AI Functions */}
|
||||||
<ProgressModal
|
<ProgressModal
|
||||||
isOpen={progressModal.isOpen}
|
isOpen={progressModal.isOpen}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
bulkUpdateImagesStatus,
|
bulkUpdateImagesStatus,
|
||||||
ContentImage,
|
ContentImage,
|
||||||
fetchAPI,
|
fetchAPI,
|
||||||
|
deleteContent,
|
||||||
|
bulkDeleteContent,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { FileIcon, DownloadIcon, BoltIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons';
|
import { FileIcon, DownloadIcon, BoltIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons';
|
||||||
@@ -135,7 +137,13 @@ export default function Images() {
|
|||||||
const endIndex = startIndex + pageSize;
|
const endIndex = startIndex + pageSize;
|
||||||
const paginatedResults = filteredResults.slice(startIndex, endIndex);
|
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);
|
setTotalCount(filteredResults.length);
|
||||||
setTotalPages(Math.ceil(filteredResults.length / pageSize));
|
setTotalPages(Math.ceil(filteredResults.length / pageSize));
|
||||||
|
|
||||||
@@ -205,6 +213,31 @@ export default function Images() {
|
|||||||
}
|
}
|
||||||
}, [toast]);
|
}, [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
|
// Bulk action handler
|
||||||
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||||
if (action === 'bulk_publish_wordpress') {
|
if (action === 'bulk_publish_wordpress') {
|
||||||
@@ -575,6 +608,8 @@ export default function Images() {
|
|||||||
}}
|
}}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkExport={handleBulkExport}
|
||||||
onBulkAction={handleBulkAction}
|
onBulkAction={handleBulkAction}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`}
|
getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`}
|
||||||
onExport={async () => {
|
onExport={async () => {
|
||||||
toast.info('Export functionality coming soon');
|
toast.info('Export functionality coming soon');
|
||||||
|
|||||||
@@ -1,13 +1,359 @@
|
|||||||
/**
|
/**
|
||||||
* Published Page - Filtered Tasks with status='published'
|
* Published Page - Built with TablePageTemplate
|
||||||
* Consistent with Keywords page layout, structure and design
|
* 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() {
|
export default function Published() {
|
||||||
// Published is just Tasks with status='published' filter applied
|
const toast = useToast();
|
||||||
// For now, we'll use the Tasks component but could enhance it later
|
const navigate = useNavigate();
|
||||||
// to show only published status tasks by default
|
const { activeSector } = useSectorStore();
|
||||||
return <Tasks />;
|
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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Stage 3: Content Validation API
|
||||||
export interface ContentValidationResult {
|
export interface ContentValidationResult {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user