Files
igny8/frontend/src/pages/Writer/Content.tsx
IGNY8 VPS (Salman) bbf0aedfdc Update Content model to enforce status choices and add migration for status field changes
- 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.
2025-11-10 14:28:13 +00:00

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>
);
}