- Introduced STATUS_CHOICES in the Content model to restrict the status field to 'draft', 'review', and 'published'. - Created a new migration to reflect the updated status choices without altering existing data. - Removed 'completed' status from the frontend status color mapping for consistency.
233 lines
9.6 KiB
TypeScript
233 lines
9.6 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import PageMeta from '../../components/common/PageMeta';
|
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
import { fetchContent, Content as ContentType } from '../../services/api';
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import HTMLContentRenderer from '../../components/common/HTMLContentRenderer';
|
|
|
|
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
|
draft: 'warning',
|
|
review: 'info',
|
|
published: 'success',
|
|
};
|
|
|
|
export default function Content() {
|
|
const toast = useToast();
|
|
const [content, setContent] = useState<ContentType[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadContent();
|
|
}, []);
|
|
|
|
const loadContent = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetchContent();
|
|
setContent(response.results || []);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to load content: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (value: string) =>
|
|
new Date(value).toLocaleString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
});
|
|
|
|
const getList = (primary?: string[], fallback?: any): string[] => {
|
|
if (primary && primary.length > 0) return primary;
|
|
if (!fallback) return [];
|
|
if (Array.isArray(fallback)) return fallback;
|
|
return [];
|
|
};
|
|
|
|
const renderBadgeList = (items: string[], emptyLabel = '-') => {
|
|
if (!items || items.length === 0) {
|
|
return <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">Review AI-generated drafts and metadata</p>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<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="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;
|
|
const secondaryKeywords = getList(
|
|
item.secondary_keywords,
|
|
item.metadata?.secondary_keywords
|
|
);
|
|
const tags = getList(item.tags, item.metadata?.tags);
|
|
const categories = getList(item.categories, item.metadata?.categories);
|
|
|
|
return (
|
|
<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 || item.task_title || `Task #${item.task}`}
|
|
</div>
|
|
{(() => {
|
|
let metaDesc = item.meta_description;
|
|
// If meta_description is a JSON string, extract the actual value
|
|
if (metaDesc && typeof metaDesc === 'string' && metaDesc.trim().startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(metaDesc);
|
|
metaDesc = parsed.meta_description || metaDesc;
|
|
} catch {
|
|
// Not valid JSON, use as-is
|
|
}
|
|
}
|
|
return metaDesc ? (
|
|
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
|
{metaDesc}
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
</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(secondaryKeywords)}
|
|
</td>
|
|
<td className="px-5 py-4 align-top">
|
|
{renderBadgeList(tags)}
|
|
</td>
|
|
<td className="px-5 py-4 align-top">
|
|
{renderBadgeList(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>
|
|
);
|
|
}
|