Files
igny8/frontend/src/pages/Optimizer/ContentSelector.tsx
2026-01-01 21:42:04 +00:00

329 lines
13 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
import { optimizerApi, EntryPoint } from '../../api/optimizer.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { SourceBadge, ContentSource } from '../../components/content/SourceBadge';
import { ContentFilter, FilterState } from '../../components/content/ContentFilter';
import { OptimizationScores } from '../../components/optimizer/OptimizationScores';
import { BoltIcon, CheckCircleIcon, FileIcon } from '../../icons';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import Select from '../../components/form/Select';
import Checkbox from '../../components/form/input/Checkbox';
import Button from '../../components/ui/button/Button';
export default function OptimizerContentSelector() {
const navigate = useNavigate();
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
const [content, setContent] = useState<ContentType[]>([]);
const [filteredContent, setFilteredContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<number[]>([]);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [filters, setFilters] = useState<FilterState>({
source: 'all',
search: '',
});
const [entryPoint, setEntryPoint] = useState<EntryPoint>('auto');
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const loadContent = useCallback(async () => {
setLoading(true);
try {
const data = await fetchContent({
page: currentPage,
page_size: pageSize,
sector_id: activeSector?.id,
});
setContent(data.results || []);
setTotalCount(data.count || 0);
} catch (error: any) {
console.error('Error loading content:', error);
toast.error(`Failed to load content: ${error.message}`);
} finally {
setLoading(false);
}
}, [currentPage, pageSize, activeSector, toast]);
useEffect(() => {
loadContent();
}, [loadContent]);
// Apply filters
useEffect(() => {
let filtered = [...content];
// Search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(
item =>
item.title?.toLowerCase().includes(searchLower) ||
item.meta_title?.toLowerCase().includes(searchLower) ||
item.primary_keyword?.toLowerCase().includes(searchLower)
);
}
// Source filter
if (filters.source !== 'all') {
filtered = filtered.filter(item => item.source === filters.source);
}
setFilteredContent(filtered);
}, [content, filters]);
const handleOptimize = async (contentId: number) => {
try {
setProcessing(prev => [...prev, contentId]);
const result = await optimizerApi.optimize(contentId, entryPoint);
toast.success(`Content optimized! Score: ${result.scores_after.overall_score.toFixed(1)}`);
// Refresh content list
await loadContent();
} catch (error: any) {
console.error('Error optimizing content:', error);
toast.error(`Failed to optimize content: ${error.message}`);
} finally {
setProcessing(prev => prev.filter(id => id !== contentId));
}
};
const handleBatchOptimize = async () => {
if (selectedIds.length === 0) {
toast.error('Please select at least one content item');
return;
}
try {
setProcessing(selectedIds);
const result = await optimizerApi.batchOptimize(selectedIds, entryPoint);
toast.success(
`Optimized ${result.succeeded} content item${result.succeeded !== 1 ? 's' : ''}. ` +
`${result.failed > 0 ? `${result.failed} failed.` : ''}`
);
setSelectedIds([]);
await loadContent();
} catch (error: any) {
console.error('Error batch optimizing content:', error);
toast.error(`Failed to optimize content: ${error.message}`);
} finally {
setProcessing([]);
}
};
const toggleSelection = (contentId: number) => {
setSelectedIds(prev =>
prev.includes(contentId)
? prev.filter(id => id !== contentId)
: [...prev, contentId]
);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredContent.length) {
setSelectedIds([]);
} else {
setSelectedIds(filteredContent.map(item => item.id));
}
};
return (
<>
<PageMeta title="Improve Your Articles" description="Optimize your content for better SEO and engagement" />
<div className="space-y-6">
<PageHeader
title="Improve Your Articles"
lastUpdated={new Date()}
badge={{
icon: <BoltIcon />,
color: 'orange',
}}
navigation={<ModuleNavigationTabs tabs={[
{ label: 'Content', path: '/optimizer/content', icon: <FileIcon /> },
]} />}
/>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Select
options={[
{ value: 'auto', label: 'Auto-detect' },
{ value: 'writer', label: 'From Writer' },
{ value: 'wordpress', label: 'From WordPress' },
{ value: 'external', label: 'From External' },
{ value: 'manual', label: 'Manual' },
]}
defaultValue={entryPoint}
onChange={(val) => setEntryPoint(val as EntryPoint)}
/>
<Button
variant="primary"
onClick={handleBatchOptimize}
disabled={selectedIds.length === 0 || processing.length > 0}
>
{processing.length > 0 ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Optimizing...
</>
) : (
<>
<BoltIcon className="w-4 h-4" />
Optimize Selected ({selectedIds.length})
</>
)}
</Button>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Select content to optimize for SEO, readability, and engagement
</p>
{/* Filters */}
<ContentFilter onFilterChange={setFilters} />
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading content...</p>
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left">
<Checkbox
checked={selectedIds.length === filteredContent.length && filteredContent.length > 0}
onChange={toggleSelectAll}
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Score
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredContent.map((item) => {
const isSelected = selectedIds.includes(item.id);
const isProcessing = processing.includes(item.id);
const scores = item.optimization_scores;
return (
<tr
key={item.id}
className={`hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-brand-50 dark:bg-brand-900/20' : ''}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<Checkbox
checked={isSelected}
onChange={() => toggleSelection(item.id)}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{item.title || 'Untitled'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<SourceBadge source={(item.source as ContentSource) || 'igny8'} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
{scores?.overall_score ? (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{scores.overall_score.toFixed(1)}
</span>
) : (
<span className="text-sm text-gray-400">N/A</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{item.optimizer_version || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<Button
variant="primary"
size="sm"
onClick={() => handleOptimize(item.id)}
disabled={isProcessing || processing.length > 0}
>
{isProcessing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Optimizing...
</>
) : (
<>
<BoltIcon className="w-4 h-4" />
Optimize
</>
)}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalCount > pageSize && (
<div className="bg-gray-50 dark:bg-gray-900 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalCount)} of {totalCount} results
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => prev + 1)}
disabled={currentPage * pageSize >= totalCount}
>
Next
</Button>
</div>
</div>
)}
</div>
)}
{/* Module footer placeholder - module on hold */}
</div>
</>
);
}