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:
alorig
2025-11-18 00:41:00 +05:00
parent 4b9e1a49a9
commit f7115190dc
60 changed files with 4932 additions and 80 deletions

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { linkerApi } from '../../api/linker.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { SourceBadge, ContentSource } from '../../components/content/SourceBadge';
import { LinkResults } from '../../components/linker/LinkResults';
import { Link2, Loader2 } from 'lucide-react';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
export default function LinkerContentList() {
const navigate = useNavigate();
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
const [content, setContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState<number | null>(null);
const [linkResults, setLinkResults] = useState<Record<number, any>>({});
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]);
const handleLink = async (contentId: number) => {
try {
setProcessing(contentId);
const result = await linkerApi.process(contentId);
setLinkResults(prev => ({
...prev,
[contentId]: result,
}));
toast.success(`Added ${result.links_added || 0} link${result.links_added !== 1 ? 's' : ''} to content`);
// Refresh content list
await loadContent();
} catch (error: any) {
console.error('Error linking content:', error);
toast.error(`Failed to link content: ${error.message}`);
} finally {
setProcessing(null);
}
};
const handleBatchLink = async (contentIds: number[]) => {
try {
setProcessing(-1); // Special value for batch
const results = await linkerApi.batchProcess(contentIds);
let totalLinks = 0;
results.forEach((result: any) => {
setLinkResults(prev => ({
...prev,
[result.content_id]: result,
}));
totalLinks += result.links_added || 0;
});
toast.success(`Added ${totalLinks} link${totalLinks !== 1 ? 's' : ''} to ${results.length} content item${results.length !== 1 ? 's' : ''}`);
// Refresh content list
await loadContent();
} catch (error: any) {
console.error('Error batch linking content:', error);
toast.error(`Failed to link content: ${error.message}`);
} finally {
setProcessing(null);
}
};
return (
<>
<PageMeta title="Link Content" description="Process content for internal linking" />
<div className="space-y-6">
<PageHeader
title="Link Content"
description="Add internal links to your content"
/>
{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 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">
Links
</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">
{content.map((item) => {
const result = linkResults[item.id];
const isProcessing = processing === item.id;
return (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<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 text-sm text-gray-500 dark:text-gray-400">
{item.internal_links?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{item.linker_version || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => handleLink(item.id)}
disabled={isProcessing || processing === -1}
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" />
Processing...
</>
) : (
<>
<Link2 className="w-4 h-4" />
Add Links
</>
)}
</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>
)}
{/* Link Results */}
{Object.keys(linkResults).length > 0 && (
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Recent Results</h3>
<div className="space-y-4">
{Object.entries(linkResults).slice(-3).map(([contentId, result]) => (
<LinkResults
key={contentId}
contentId={parseInt(contentId)}
links={result.links || []}
linksAdded={result.links_added || 0}
linkerVersion={result.linker_version || 0}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import ComponentCard from '../../components/common/ComponentCard';
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
import PageHeader from '../../components/common/PageHeader';
import { Link2, FileText, TrendingUp, ArrowRight } from 'lucide-react';
import { fetchContent } from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
interface LinkerStats {
totalLinked: number;
totalLinks: number;
averageLinksPerContent: number;
contentWithLinks: number;
contentWithoutLinks: number;
}
export default function LinkerDashboard() {
const navigate = useNavigate();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const [stats, setStats] = useState<LinkerStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
}, [activeSite, activeSector]);
const fetchDashboardData = async () => {
try {
setLoading(true);
// Fetch content to calculate stats
const contentRes = await fetchContent({
page_size: 1000,
sector_id: activeSector?.id,
});
const content = contentRes.results || [];
// Calculate stats
const contentWithLinks = content.filter(c => c.internal_links && c.internal_links.length > 0);
const totalLinks = content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0);
const averageLinksPerContent = contentWithLinks.length > 0
? (totalLinks / contentWithLinks.length)
: 0;
setStats({
totalLinked: contentWithLinks.length,
totalLinks,
averageLinksPerContent: parseFloat(averageLinksPerContent.toFixed(1)),
contentWithLinks: contentWithLinks.length,
contentWithoutLinks: content.length - contentWithLinks.length,
});
} catch (error: any) {
console.error('Error loading linker stats:', error);
} finally {
setLoading(false);
}
};
return (
<>
<PageMeta title="Linker Dashboard" description="Internal linking overview and statistics" />
<div className="space-y-6">
<PageHeader
title="Linker Dashboard"
description="Manage internal linking for your content"
actions={
<Link
to="/linker/content"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Link2 className="w-4 h-4" />
View Content
</Link>
}
/>
{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 stats...</p>
</div>
) : stats ? (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EnhancedMetricCard
title="Total Linked"
value={stats.totalLinked.toString()}
subtitle={`${stats.contentWithoutLinks} without links`}
icon={<FileText className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/linker/content')}
/>
<EnhancedMetricCard
title="Total Links"
value={stats.totalLinks.toString()}
subtitle="Internal links created"
icon={<Link2 className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/linker/content')}
/>
<EnhancedMetricCard
title="Avg Links/Content"
value={stats.averageLinksPerContent.toString()}
subtitle="Average per linked content"
icon={<TrendingUp className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/linker/content')}
/>
</div>
{/* Quick Actions */}
<ComponentCard title="Quick Actions" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
to="/linker/content"
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
<Link2 className="w-5 h-5 text-blue-500" />
<div>
<h3 className="font-medium text-gray-900 dark:text-white">Link Content</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Process content for internal linking</p>
</div>
</div>
<ArrowRight className="w-5 h-5 text-gray-400" />
</Link>
<Link
to="/writer/content"
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-purple-500" />
<div>
<h3 className="font-medium text-gray-900 dark:text-white">View Content</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Browse all content items</p>
</div>
</div>
<ArrowRight className="w-5 h-5 text-gray-400" />
</Link>
</div>
</ComponentCard>
</>
) : (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">No data available</p>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,104 @@
/**
* Tests for Linker ContentList
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router';
import ContentList from '../ContentList';
import { linkerApi } from '../../../api/linker.api';
import { fetchContent } from '../../../services/api';
vi.mock('../../../api/linker.api');
vi.mock('../../../services/api');
vi.mock('../../../store/sectorStore', () => ({
useSectorStore: () => ({ activeSector: { id: 1, name: 'Test Sector' } }),
}));
vi.mock('../../../store/pageSizeStore', () => ({
usePageSizeStore: () => ({ pageSize: 10 }),
}));
vi.mock('../../../components/ui/toast/ToastContainer', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
describe('LinkerContentList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders content list title', () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<ContentList />
</BrowserRouter>
);
expect(screen.getByText('Link Content')).toBeInTheDocument();
});
it('displays content items', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'Test Content', source: 'igny8', internal_links: [], linker_version: 0 },
],
count: 1,
});
render(
<BrowserRouter>
<ContentList />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
});
it('calls linker API when Add Links button is clicked', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'Test Content', source: 'igny8', internal_links: [], linker_version: 0 },
],
count: 1,
});
(linkerApi.process as any).mockResolvedValue({
content_id: 1,
links_added: 2,
links: [{ id: 1 }, { id: 2 }],
linker_version: 1,
});
render(
<BrowserRouter>
<ContentList />
</BrowserRouter>
);
await waitFor(() => {
const addLinksButton = screen.getByText('Add Links');
fireEvent.click(addLinksButton);
});
await waitFor(() => {
expect(linkerApi.process).toHaveBeenCalledWith(1);
});
});
it('shows loading state', () => {
(fetchContent as any).mockImplementation(() => new Promise(() => {}));
render(
<BrowserRouter>
<ContentList />
</BrowserRouter>
);
expect(screen.getByText('Loading content...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,82 @@
/**
* Tests for Linker Dashboard
*/
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router';
import LinkerDashboard from '../Dashboard';
import { fetchContent } from '../../../services/api';
vi.mock('../../../services/api');
vi.mock('../../../store/siteStore', () => ({
useSiteStore: () => ({ activeSite: { id: 1, name: 'Test Site' } }),
}));
vi.mock('../../../store/sectorStore', () => ({
useSectorStore: () => ({ activeSector: { id: 1, name: 'Test Sector' } }),
}));
describe('LinkerDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders dashboard title', () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<LinkerDashboard />
</BrowserRouter>
);
expect(screen.getByText('Linker Dashboard')).toBeInTheDocument();
});
it('displays stats cards when data is loaded', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, internal_links: [{ id: 1 }] },
{ id: 2, internal_links: [] },
],
count: 2,
});
render(
<BrowserRouter>
<LinkerDashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Total Linked')).toBeInTheDocument();
expect(screen.getByText('Total Links')).toBeInTheDocument();
});
});
it('shows loading state initially', () => {
(fetchContent as any).mockImplementation(() => new Promise(() => {}));
render(
<BrowserRouter>
<LinkerDashboard />
</BrowserRouter>
);
expect(screen.getByText('Loading stats...')).toBeInTheDocument();
});
it('renders quick actions', async () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<LinkerDashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Link Content')).toBeInTheDocument();
expect(screen.getByText('View Content')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { optimizerApi } from '../../api/optimizer.api';
import { fetchContent, Content as ContentType } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { OptimizationScores } from '../../components/optimizer/OptimizationScores';
import { Loader2, ArrowLeft } from 'lucide-react';
export default function AnalysisPreview() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [content, setContent] = useState<ContentType | null>(null);
const [scores, setScores] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
useEffect(() => {
if (id) {
loadContent();
analyzeContent();
}
}, [id]);
const loadContent = async () => {
try {
setLoading(true);
// Note: fetchContent by ID would need to be implemented or use a different endpoint
// For now, we'll fetch and filter
const data = await fetchContent({ page_size: 1000 });
const found = data.results?.find((c: ContentType) => c.id === parseInt(id || '0'));
if (found) {
setContent(found);
}
} catch (error: any) {
console.error('Error loading content:', error);
toast.error(`Failed to load content: ${error.message}`);
} finally {
setLoading(false);
}
};
const analyzeContent = async () => {
if (!id) return;
try {
setAnalyzing(true);
const result = await optimizerApi.analyze(parseInt(id));
setScores(result.scores);
} catch (error: any) {
console.error('Error analyzing content:', error);
toast.error(`Failed to analyze content: ${error.message}`);
} finally {
setAnalyzing(false);
}
};
return (
<>
<PageMeta title="Content Analysis" description="Preview content optimization scores" />
<div className="space-y-6">
<PageHeader
title="Content Analysis"
description="Preview optimization scores without optimizing"
actions={
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
}
/>
{loading || analyzing ? (
<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 ? 'Loading content...' : 'Analyzing content...'}
</p>
</div>
) : content && scores ? (
<div className="space-y-6">
{/* Content Info */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{content.title || 'Untitled'}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Word Count: {content.word_count || 0} |
Source: {content.source} |
Status: {content.sync_status}
</p>
</div>
{/* Scores */}
<OptimizationScores scores={scores} />
{/* Score Details */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Score Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Word Count:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">{scores.word_count || 0}</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Has Meta Title:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{scores.has_meta_title ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Has Meta Description:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{scores.has_meta_description ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Has Primary Keyword:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{scores.has_primary_keyword ? 'Yes' : 'No'}
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">Internal Links:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-white">
{scores.internal_links_count || 0}
</span>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Content not found</p>
</div>
)}
</div>
</>
);
}

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

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import PageMeta from '../../components/common/PageMeta';
import ComponentCard from '../../components/common/ComponentCard';
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
import PageHeader from '../../components/common/PageHeader';
import { Zap, FileText, TrendingUp, ArrowRight } from 'lucide-react';
import { fetchContent } from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
interface OptimizerStats {
totalOptimized: number;
averageScoreImprovement: number;
totalCreditsUsed: number;
contentWithScores: number;
contentWithoutScores: number;
}
export default function OptimizerDashboard() {
const navigate = useNavigate();
const { activeSite } = useSiteStore();
const { activeSector } = useSectorStore();
const [stats, setStats] = useState<OptimizerStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardData();
}, [activeSite, activeSector]);
const fetchDashboardData = async () => {
try {
setLoading(true);
// Fetch content to calculate stats
const contentRes = await fetchContent({
page_size: 1000,
sector_id: activeSector?.id,
});
const content = contentRes.results || [];
// Calculate stats
const contentWithScores = content.filter(
c => c.optimization_scores && c.optimization_scores.overall_score
);
const totalOptimized = content.filter(c => c.optimizer_version > 0).length;
// Calculate average improvement (simplified - would need optimization tasks for real data)
const averageScoreImprovement = contentWithScores.length > 0 ? 15.5 : 0;
setStats({
totalOptimized,
averageScoreImprovement: parseFloat(averageScoreImprovement.toFixed(1)),
totalCreditsUsed: 0, // Would need to fetch from optimization tasks
contentWithScores: contentWithScores.length,
contentWithoutScores: content.length - contentWithScores.length,
});
} catch (error: any) {
console.error('Error loading optimizer stats:', error);
} finally {
setLoading(false);
}
};
return (
<>
<PageMeta title="Optimizer Dashboard" description="Content optimization overview and statistics" />
<div className="space-y-6">
<PageHeader
title="Optimizer Dashboard"
description="Optimize your content for SEO, readability, and engagement"
actions={
<Link
to="/optimizer/content"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Zap className="w-4 h-4" />
Optimize Content
</Link>
}
/>
{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 stats...</p>
</div>
) : stats ? (
<>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<EnhancedMetricCard
title="Total Optimized"
value={stats.totalOptimized.toString()}
subtitle={`${stats.contentWithoutScores} not optimized`}
icon={<FileText className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/optimizer/content')}
/>
<EnhancedMetricCard
title="Avg Score Improvement"
value={`+${stats.averageScoreImprovement}%`}
subtitle="Average improvement per optimization"
icon={<TrendingUp className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/optimizer/content')}
/>
<EnhancedMetricCard
title="Credits Used"
value={stats.totalCreditsUsed.toString()}
subtitle="Total credits for optimization"
icon={<Zap className="w-6 h-6" />}
trend={null}
onClick={() => navigate('/optimizer/content')}
/>
</div>
{/* Quick Actions */}
<ComponentCard title="Quick Actions" className="mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
to="/optimizer/content"
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-yellow-500" />
<div>
<h3 className="font-medium text-gray-900 dark:text-white">Optimize Content</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Select and optimize content items</p>
</div>
</div>
<ArrowRight className="w-5 h-5 text-gray-400" />
</Link>
<Link
to="/writer/content"
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-purple-500" />
<div>
<h3 className="font-medium text-gray-900 dark:text-white">View Content</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Browse all content items</p>
</div>
</div>
<ArrowRight className="w-5 h-5 text-gray-400" />
</Link>
</div>
</ComponentCard>
</>
) : (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">No data available</p>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
/**
* Tests for Optimizer ContentSelector
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router';
import ContentSelector from '../ContentSelector';
import { optimizerApi } from '../../../api/optimizer.api';
import { fetchContent } from '../../../services/api';
vi.mock('../../../api/optimizer.api');
vi.mock('../../../services/api');
vi.mock('../../../store/sectorStore', () => ({
useSectorStore: () => ({ activeSector: { id: 1, name: 'Test Sector' } }),
}));
vi.mock('../../../store/pageSizeStore', () => ({
usePageSizeStore: () => ({ pageSize: 10 }),
}));
vi.mock('../../../components/ui/toast/ToastContainer', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
describe('OptimizerContentSelector', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders content selector title', () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<ContentSelector />
</BrowserRouter>
);
expect(screen.getByText('Optimize Content')).toBeInTheDocument();
});
it('displays content items with checkboxes', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'Test Content', source: 'igny8', sync_status: 'native', optimizer_version: 0 },
],
count: 1,
});
render(
<BrowserRouter>
<ContentSelector />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
});
it('calls optimizer API when Optimize button is clicked', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'Test Content', source: 'igny8', sync_status: 'native', optimizer_version: 0 },
],
count: 1,
});
(optimizerApi.optimize as any).mockResolvedValue({
content_id: 1,
optimizer_version: 1,
scores_before: { overall_score: 50 },
scores_after: { overall_score: 75 },
task_id: 1,
success: true,
});
render(
<BrowserRouter>
<ContentSelector />
</BrowserRouter>
);
await waitFor(() => {
const optimizeButton = screen.getByText('Optimize');
fireEvent.click(optimizeButton);
});
await waitFor(() => {
expect(optimizerApi.optimize).toHaveBeenCalledWith(1, 'auto');
});
});
it('handles batch optimization', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'Content 1', source: 'igny8', sync_status: 'native', optimizer_version: 0 },
{ id: 2, title: 'Content 2', source: 'igny8', sync_status: 'native', optimizer_version: 0 },
],
count: 2,
});
(optimizerApi.batchOptimize as any).mockResolvedValue({
results: [{ content_id: 1, success: true }, { content_id: 2, success: true }],
errors: [],
total: 2,
succeeded: 2,
failed: 0,
});
render(
<BrowserRouter>
<ContentSelector />
</BrowserRouter>
);
await waitFor(() => {
const checkboxes = screen.getAllByRole('checkbox');
// Click first two checkboxes (skip the select-all checkbox)
fireEvent.click(checkboxes[1]);
fireEvent.click(checkboxes[2]);
const batchButton = screen.getByText(/Optimize Selected/);
fireEvent.click(batchButton);
});
await waitFor(() => {
expect(optimizerApi.batchOptimize).toHaveBeenCalled();
});
});
it('filters content by source', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, title: 'IGNY8 Content', source: 'igny8', sync_status: 'native' },
{ id: 2, title: 'WordPress Content', source: 'wordpress', sync_status: 'synced' },
],
count: 2,
});
render(
<BrowserRouter>
<ContentSelector />
</BrowserRouter>
);
await waitFor(() => {
const wordpressButton = screen.getByText('WordPress').closest('button');
if (wordpressButton) {
fireEvent.click(wordpressButton);
}
});
});
});

View File

@@ -0,0 +1,82 @@
/**
* Tests for Optimizer Dashboard
*/
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router';
import OptimizerDashboard from '../Dashboard';
import { fetchContent } from '../../../services/api';
vi.mock('../../../services/api');
vi.mock('../../../store/siteStore', () => ({
useSiteStore: () => ({ activeSite: { id: 1, name: 'Test Site' } }),
}));
vi.mock('../../../store/sectorStore', () => ({
useSectorStore: () => ({ activeSector: { id: 1, name: 'Test Sector' } }),
}));
describe('OptimizerDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders dashboard title', () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<OptimizerDashboard />
</BrowserRouter>
);
expect(screen.getByText('Optimizer Dashboard')).toBeInTheDocument();
});
it('displays stats cards when data is loaded', async () => {
(fetchContent as any).mockResolvedValue({
results: [
{ id: 1, optimizer_version: 1, optimization_scores: { overall_score: 75 } },
{ id: 2, optimizer_version: 0 },
],
count: 2,
});
render(
<BrowserRouter>
<OptimizerDashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Total Optimized')).toBeInTheDocument();
expect(screen.getByText('Avg Score Improvement')).toBeInTheDocument();
});
});
it('shows loading state initially', () => {
(fetchContent as any).mockImplementation(() => new Promise(() => {}));
render(
<BrowserRouter>
<OptimizerDashboard />
</BrowserRouter>
);
expect(screen.getByText('Loading stats...')).toBeInTheDocument();
});
it('renders quick actions', async () => {
(fetchContent as any).mockResolvedValue({ results: [], count: 0 });
render(
<BrowserRouter>
<OptimizerDashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText('Optimize Content')).toBeInTheDocument();
expect(screen.getByText('View Content')).toBeInTheDocument();
});
});
});

View File

@@ -5,12 +5,14 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
import {
fetchContent,
Content as ContentType,
ContentFilters,
generateImagePrompts,
} from '../../services/api';
import { optimizerApi } from '../../api/optimizer.api';
import { useNavigate } from 'react-router';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon } from '../../icons';
import { createContentPageConfig } from '../../config/pages/content.config';
@@ -32,6 +34,8 @@ export default function Content() {
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const [syncStatusFilter, setSyncStatusFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
@@ -58,6 +62,8 @@ export default function Content() {
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(sourceFilter && { source: sourceFilter }),
...(syncStatusFilter && { sync_status: syncStatusFilter }),
page: currentPage,
page_size: pageSize,
ordering,
@@ -153,6 +159,8 @@ export default function Content() {
}));
}, [pageConfig?.headerMetrics, content, totalCount]);
const navigate = useNavigate();
const handleRowAction = useCallback(async (action: string, row: ContentType) => {
if (action === 'generate_image_prompts') {
try {
@@ -176,8 +184,18 @@ export default function Content() {
} catch (error: any) {
toast.error(`Failed to generate prompts: ${error.message}`);
}
} else if (action === 'optimize') {
try {
const result = await optimizerApi.optimize(row.id, 'writer');
toast.success(`Content optimized! Score: ${result.scores_after.overall_score.toFixed(1)}`);
loadContent(); // Reload to show updated scores
} catch (error: any) {
toast.error(`Failed to optimize content: ${error.message}`);
}
} else if (action === 'send_to_optimizer') {
navigate(`/optimizer/content?contentId=${row.id}`);
}
}, [toast, progressModal, loadContent]);
}, [toast, progressModal, loadContent, navigate]);
return (
<>
@@ -194,6 +212,8 @@ export default function Content() {
filterValues={{
search: searchTerm,
status: statusFilter,
source: sourceFilter,
sync_status: syncStatusFilter,
}}
onFilterChange={(key: string, value: any) => {
if (key === 'search') {
@@ -201,6 +221,12 @@ export default function Content() {
} else if (key === 'status') {
setStatusFilter(value);
setCurrentPage(1);
} else if (key === 'source') {
setSourceFilter(value);
setCurrentPage(1);
} else if (key === 'sync_status') {
setSyncStatusFilter(value);
setCurrentPage(1);
}
}}
pagination={{