Refactor content handling in GenerateContentFunction and update related models and serializers
- Enhanced GenerateContentFunction to save content in a dedicated Content model, separating it from the Tasks model. - Updated Tasks model to remove SEO-related fields, now managed in the Content model. - Modified TasksSerializer to include new content fields and adjusted the API to reflect these changes. - Improved the auto_generate_content_task method to utilize the new save_output method for better content management. - Updated frontend components to display new content structure and metadata effectively.
This commit is contained in:
@@ -169,33 +169,6 @@ export default function ProgressModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{details && (
|
||||
<div className="mb-6 space-y-2">
|
||||
{details.currentItem && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">Current:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{details.currentItem}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{details.total > 0 && (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium">Progress:</span>{' '}
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{details.current} of {details.total} completed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{details.phase && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Phase: {details.phase}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Function ID and Task ID (for debugging) */}
|
||||
{(fullFunctionId || taskId) && import.meta.env.DEV && (
|
||||
<div className="mb-4 space-y-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
|
||||
@@ -97,18 +97,11 @@ export const createTasksPageConfig = (
|
||||
columns: [
|
||||
{
|
||||
...titleColumn,
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
sortField: 'title',
|
||||
toggleable: true, // Enable toggle for this column
|
||||
toggleContentKey: 'content', // Use content field for toggle (fallback to description if content not available)
|
||||
toggleContentLabel: 'Generated Content', // Label for expanded content
|
||||
render: (_value: string, row: Task) => (
|
||||
<span className="text-gray-800 dark:text-white font-medium">
|
||||
{row.meta_title || row.title || '-'}
|
||||
</span>
|
||||
),
|
||||
toggleable: true,
|
||||
toggleContentKey: 'content_html',
|
||||
toggleContentLabel: 'Generated Content',
|
||||
},
|
||||
// Sector column - only show when viewing all sectors
|
||||
...(showSectorColumn ? [{
|
||||
@@ -173,85 +166,6 @@ export const createTasksPageConfig = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'keywords',
|
||||
label: 'Keywords',
|
||||
sortable: false,
|
||||
width: '250px',
|
||||
render: (_value: any, row: Task) => {
|
||||
const keywords: React.ReactNode[] = [];
|
||||
|
||||
// Primary keyword as info badge
|
||||
if (row.primary_keyword) {
|
||||
keywords.push(
|
||||
<Badge key="primary" color="info" size="sm" variant="light" className="mr-1 mb-1">
|
||||
{row.primary_keyword}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary keywords as light badges
|
||||
if (row.secondary_keywords && Array.isArray(row.secondary_keywords) && row.secondary_keywords.length > 0) {
|
||||
row.secondary_keywords.forEach((keyword, index) => {
|
||||
if (keyword) {
|
||||
keywords.push(
|
||||
<Badge key={`secondary-${index}`} color="light" size="sm" variant="light" className="mr-1 mb-1">
|
||||
{keyword}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return keywords.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{keywords}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
sortable: false,
|
||||
width: '200px',
|
||||
render: (_value: any, row: Task) => {
|
||||
if (row.tags && Array.isArray(row.tags) && row.tags.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.tags.map((tag, index) => (
|
||||
<Badge key={index} color="light" size="sm" variant="light">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: 'Categories',
|
||||
sortable: false,
|
||||
width: '200px',
|
||||
render: (_value: any, row: Task) => {
|
||||
if (row.categories && Array.isArray(row.categories) && row.categories.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.categories.map((category, index) => (
|
||||
<Badge key={index} color="light" size="sm" variant="light">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...wordCountColumn,
|
||||
sortable: true,
|
||||
|
||||
@@ -2,13 +2,21 @@ 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 { Card } from '../../components/ui/card';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
||||
|
||||
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
||||
draft: 'warning',
|
||||
review: 'info',
|
||||
published: 'success',
|
||||
completed: 'success',
|
||||
};
|
||||
|
||||
export default function Content() {
|
||||
const toast = useToast();
|
||||
const [content, setContent] = useState<ContentType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -26,44 +34,174 @@ export default function Content() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value: string) =>
|
||||
new Date(value).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
});
|
||||
|
||||
const renderBadgeList = (items?: string[], emptyLabel = '-') => {
|
||||
if (!items || items.length === 0) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">{emptyLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item, index) => (
|
||||
<Badge key={`${item}-${index}`} color="light" size="sm" variant="light">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">View all generated content</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Review AI-generated drafts and metadata</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
||||
</div>
|
||||
) : content.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-12 text-center text-gray-500 dark:text-gray-400">
|
||||
No content generated yet. Run an AI content job to see drafts here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{content.map((item: ContentType) => (
|
||||
<Card key={item.id} className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Task #{item.task}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Generated: {new Date(item.generated_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.word_count} words
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: item.html_content }}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Primary Keyword
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Secondary Keywords
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Tags
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Categories
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Word Count
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Generated
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Content
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-white/[0.05]">
|
||||
{content.map((item) => {
|
||||
const isExpanded = expandedId === item.id;
|
||||
return (
|
||||
<tr key={item.id} className="bg-white dark:bg-gray-900">
|
||||
<td className="px-5 py-4 align-top">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || `Task #${item.task}`}
|
||||
</div>
|
||||
{item.meta_description && (
|
||||
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{item.meta_description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{item.primary_keyword ? (
|
||||
<Badge color="info" size="sm" variant="light">
|
||||
{item.primary_keyword}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.secondary_keywords)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.tags)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
{renderBadgeList(item.categories)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-700 dark:text-gray-300">
|
||||
{item.word_count?.toLocaleString?.() ?? '-'}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top">
|
||||
<Badge
|
||||
color={statusColors[item.status] || 'primary'}
|
||||
size="sm"
|
||||
variant="light"
|
||||
>
|
||||
{item.status?.replace('_', ' ') || 'draft'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-gray-600 dark:text-gray-400">
|
||||
{formatDate(item.generated_at)}
|
||||
</td>
|
||||
<td className="px-5 py-4 align-top text-right">
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'View'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.map((item) =>
|
||||
expandedId === item.id ? (
|
||||
<div
|
||||
key={`expanded-${item.id}`}
|
||||
className="mt-6 rounded-xl border border-gray-200 dark:border-white/[0.05] bg-white dark:bg-gray-900 p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{item.meta_title || item.title || `Task #${item.task}`}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Generated {formatDate(item.generated_at)} • Task #{item.task}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpandedId(null)}
|
||||
className="text-sm font-medium text-blue-light-500 hover:text-blue-light-600 dark:text-blue-light-400 dark:hover:text-blue-light-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<HTMLContentRenderer
|
||||
content={item.html_content}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1071,10 +1071,11 @@ export interface Task {
|
||||
word_count: number;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
secondary_keywords?: string[] | null;
|
||||
tags?: string[] | null;
|
||||
categories?: string[] | null;
|
||||
content_html?: string | null;
|
||||
content_primary_keyword?: string | null;
|
||||
content_secondary_keywords?: string[];
|
||||
content_tags?: string[];
|
||||
content_categories?: string[];
|
||||
assigned_post_id?: number | null;
|
||||
post_url?: string | null;
|
||||
created_at: string;
|
||||
@@ -1841,6 +1842,15 @@ export async function deleteAuthorProfile(id: number): Promise<void> {
|
||||
export interface Content {
|
||||
id: number;
|
||||
task: number;
|
||||
task_title?: string | null;
|
||||
title?: string | null;
|
||||
meta_title?: string | null;
|
||||
meta_description?: string | null;
|
||||
primary_keyword?: string | null;
|
||||
secondary_keywords?: string[];
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
status: string;
|
||||
html_content: string;
|
||||
word_count: number;
|
||||
metadata: Record<string, any>;
|
||||
|
||||
Reference in New Issue
Block a user