Add Linker and Optimizer modules with API integration and frontend components
- Added Linker and Optimizer apps to `INSTALLED_APPS` in `settings.py`. - Configured API endpoints for Linker and Optimizer in `urls.py`. - Implemented `OptimizeContentFunction` for content optimization in the AI module. - Created prompts for content optimization and site structure generation. - Updated `OptimizerService` to utilize the new AI function for content optimization. - Developed frontend components including dashboards and content lists for Linker and Optimizer. - Integrated new routes and sidebar navigation for Linker and Optimizer in the frontend. - Enhanced content management with source and sync status filters in the Writer module. - Comprehensive test coverage added for new features and components.
This commit is contained in:
326
frontend/src/pages/Optimizer/ContentSelector.tsx
Normal file
326
frontend/src/pages/Optimizer/ContentSelector.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import PageMeta from '../../components/common/PageMeta';
|
||||
import PageHeader from '../../components/common/PageHeader';
|
||||
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 { SyncStatusBadge, SyncStatus } from '../../components/content/SyncStatusBadge';
|
||||
import { ContentFilter, FilterState } from '../../components/content/ContentFilter';
|
||||
import { OptimizationScores } from '../../components/optimizer/OptimizationScores';
|
||||
import { Zap, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
|
||||
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',
|
||||
syncStatus: '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);
|
||||
}
|
||||
|
||||
// Sync status filter
|
||||
if (filters.syncStatus !== 'all') {
|
||||
filtered = filtered.filter(item => item.sync_status === filters.syncStatus);
|
||||
}
|
||||
|
||||
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="Optimize Content" description="Select and optimize content for SEO and engagement" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Optimize Content"
|
||||
description="Select content to optimize for SEO, readability, and engagement"
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={entryPoint}
|
||||
onChange={(e) => setEntryPoint(e.target.value as EntryPoint)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="writer">From Writer</option>
|
||||
<option value="wordpress">From WordPress</option>
|
||||
<option value="external">From External</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleBatchOptimize}
|
||||
disabled={selectedIds.length === 0 || processing.length > 0}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{processing.length > 0 ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Optimizing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4" />
|
||||
Optimize Selected ({selectedIds.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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-blue-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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === filteredContent.length && filteredContent.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</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">
|
||||
Status
|
||||
</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-blue-50 dark:bg-blue-900/20' : ''}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelection(item.id)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</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">
|
||||
<SyncStatusBadge status={(item.sync_status as SyncStatus) || 'native'} />
|
||||
</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
|
||||
onClick={() => handleOptimize(item.id)}
|
||||
disabled={isProcessing || processing.length > 0}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Optimizing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap 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
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => prev + 1)}
|
||||
disabled={currentPage * pageSize >= totalCount}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user