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:
@@ -32,6 +32,15 @@ const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||
const Published = lazy(() => import("./pages/Writer/Published"));
|
||||
|
||||
// Linker Module - Lazy loaded
|
||||
const LinkerDashboard = lazy(() => import("./pages/Linker/Dashboard"));
|
||||
const LinkerContentList = lazy(() => import("./pages/Linker/ContentList"));
|
||||
|
||||
// Optimizer Module - Lazy loaded
|
||||
const OptimizerDashboard = lazy(() => import("./pages/Optimizer/Dashboard"));
|
||||
const OptimizerContentSelector = lazy(() => import("./pages/Optimizer/ContentSelector"));
|
||||
const AnalysisPreview = lazy(() => import("./pages/Optimizer/AnalysisPreview"));
|
||||
|
||||
// Thinker Module - Lazy loaded
|
||||
const ThinkerDashboard = lazy(() => import("./pages/Thinker/Dashboard"));
|
||||
const Prompts = lazy(() => import("./pages/Thinker/Prompts"));
|
||||
@@ -207,6 +216,45 @@ export default function App() {
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Linker Module */}
|
||||
<Route path="/linker" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="linker">
|
||||
<LinkerDashboard />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/linker/content" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="linker">
|
||||
<LinkerContentList />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Optimizer Module */}
|
||||
<Route path="/optimizer" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="optimizer">
|
||||
<OptimizerDashboard />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/optimizer/content" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="optimizer">
|
||||
<OptimizerContentSelector />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/optimizer/analyze/:id" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleGuard module="optimizer">
|
||||
<AnalysisPreview />
|
||||
</ModuleGuard>
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Thinker Module */}
|
||||
<Route path="/thinker" element={
|
||||
<Suspense fallback={null}>
|
||||
|
||||
32
frontend/src/api/linker.api.ts
Normal file
32
frontend/src/api/linker.api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fetchAPI } from '../services/api';
|
||||
|
||||
/**
|
||||
* Linker API Client
|
||||
* Functions for internal linking operations
|
||||
*/
|
||||
export const linkerApi = {
|
||||
/**
|
||||
* Process a single content item for internal linking
|
||||
* @param contentId - Content ID to process
|
||||
* @returns Link result with links added
|
||||
*/
|
||||
process: async (contentId: number) => {
|
||||
return await fetchAPI('/v1/linker/process/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content_id: contentId }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Batch process multiple content items for internal linking
|
||||
* @param contentIds - Array of content IDs to process
|
||||
* @returns Array of link results
|
||||
*/
|
||||
batchProcess: async (contentIds: number[]) => {
|
||||
return await fetchAPI('/v1/linker/batch_process/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content_ids: contentIds }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
86
frontend/src/api/optimizer.api.ts
Normal file
86
frontend/src/api/optimizer.api.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { fetchAPI } from '../services/api';
|
||||
|
||||
/**
|
||||
* Optimizer API Client
|
||||
* Functions for content optimization operations
|
||||
*/
|
||||
|
||||
export type EntryPoint = 'auto' | 'writer' | 'wordpress' | 'external' | 'manual';
|
||||
|
||||
export interface OptimizationResult {
|
||||
content_id: number;
|
||||
optimizer_version: number;
|
||||
scores_before: {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
};
|
||||
scores_after: {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
};
|
||||
task_id: number | null;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface AnalysisScores {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
word_count: number;
|
||||
has_meta_title: boolean;
|
||||
has_meta_description: boolean;
|
||||
has_primary_keyword: boolean;
|
||||
internal_links_count: number;
|
||||
}
|
||||
|
||||
export const optimizerApi = {
|
||||
/**
|
||||
* Optimize content (auto-detects entry point based on source)
|
||||
* @param contentId - Content ID to optimize
|
||||
* @param entryPoint - Optional entry point override (default: 'auto')
|
||||
* @returns Optimization result with scores
|
||||
*/
|
||||
optimize: async (contentId: number, entryPoint: EntryPoint = 'auto'): Promise<OptimizationResult> => {
|
||||
return await fetchAPI('/v1/optimizer/optimize/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content_id: contentId,
|
||||
entry_point: entryPoint,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Batch optimize multiple content items
|
||||
* @param contentIds - Array of content IDs to optimize
|
||||
* @param entryPoint - Optional entry point override (default: 'auto')
|
||||
* @returns Batch optimization results
|
||||
*/
|
||||
batchOptimize: async (contentIds: number[], entryPoint: EntryPoint = 'auto') => {
|
||||
return await fetchAPI('/v1/optimizer/batch_optimize/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
content_ids: contentIds,
|
||||
entry_point: entryPoint,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Analyze content without optimizing (preview scores)
|
||||
* @param contentId - Content ID to analyze
|
||||
* @returns Analysis scores
|
||||
*/
|
||||
analyze: async (contentId: number): Promise<{ content_id: number; scores: AnalysisScores }> => {
|
||||
return await fetchAPI('/v1/optimizer/analyze/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content_id: contentId }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
117
frontend/src/components/content/ContentFilter.tsx
Normal file
117
frontend/src/components/content/ContentFilter.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SourceBadge, ContentSource } from './SourceBadge';
|
||||
import { SyncStatusBadge, SyncStatus } from './SyncStatusBadge';
|
||||
|
||||
interface ContentFilterProps {
|
||||
onFilterChange: (filters: FilterState) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
source: ContentSource | 'all';
|
||||
syncStatus: SyncStatus | 'all';
|
||||
search: string;
|
||||
}
|
||||
|
||||
export const ContentFilter: React.FC<ContentFilterProps> = ({ onFilterChange, className = '' }) => {
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
source: 'all',
|
||||
syncStatus: 'all',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const handleSourceChange = (source: ContentSource | 'all') => {
|
||||
const newFilters = { ...filters, source };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
const handleSyncStatusChange = (syncStatus: SyncStatus | 'all') => {
|
||||
const newFilters = { ...filters, syncStatus };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const search = e.target.value;
|
||||
const newFilters = { ...filters, search };
|
||||
setFilters(newFilters);
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Search */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search content..."
|
||||
value={filters.search}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Source</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleSourceChange('all')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filters.source === 'all'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{(['igny8', 'wordpress', 'shopify', 'custom'] as ContentSource[]).map((source) => (
|
||||
<button
|
||||
key={source}
|
||||
onClick={() => handleSourceChange(source)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filters.source === source
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<SourceBadge source={source} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Sync Status</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleSyncStatusChange('all')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filters.syncStatus === 'all'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{(['native', 'imported', 'synced'] as SyncStatus[]).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleSyncStatusChange(status)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filters.syncStatus === status
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<SyncStatusBadge status={status} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
26
frontend/src/components/content/SourceBadge.tsx
Normal file
26
frontend/src/components/content/SourceBadge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ContentSource = 'igny8' | 'wordpress' | 'shopify' | 'custom';
|
||||
|
||||
interface SourceBadgeProps {
|
||||
source: ContentSource;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sourceConfig = {
|
||||
igny8: { label: 'IGNY8', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
wordpress: { label: 'WordPress', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||
shopify: { label: 'Shopify', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
custom: { label: 'Custom', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
|
||||
};
|
||||
|
||||
export const SourceBadge: React.FC<SourceBadgeProps> = ({ source, className = '' }) => {
|
||||
const config = sourceConfig[source] || sourceConfig.custom;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color} ${className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
25
frontend/src/components/content/SyncStatusBadge.tsx
Normal file
25
frontend/src/components/content/SyncStatusBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
export type SyncStatus = 'native' | 'imported' | 'synced';
|
||||
|
||||
interface SyncStatusBadgeProps {
|
||||
status: SyncStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
native: { label: 'Native', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
|
||||
imported: { label: 'Imported', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
synced: { label: 'Synced', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
|
||||
};
|
||||
|
||||
export const SyncStatusBadge: React.FC<SyncStatusBadgeProps> = ({ status, className = '' }) => {
|
||||
const config = statusConfig[status] || statusConfig.native;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color} ${className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Tests for ContentFilter component
|
||||
*/
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ContentFilter } from '../ContentFilter';
|
||||
|
||||
describe('ContentFilter', () => {
|
||||
const mockOnFilterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnFilterChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
expect(screen.getByPlaceholderText('Search content...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when search input changes', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
const searchInput = screen.getByPlaceholderText('Search content...');
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'test search' } });
|
||||
|
||||
expect(mockOnFilterChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: 'test search' })
|
||||
);
|
||||
});
|
||||
|
||||
it('renders source filter buttons', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
expect(screen.getByText('IGNY8')).toBeInTheDocument();
|
||||
expect(screen.getByText('WordPress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when source filter is clicked', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
const wordpressButton = screen.getByText('WordPress').closest('button');
|
||||
|
||||
if (wordpressButton) {
|
||||
fireEvent.click(wordpressButton);
|
||||
expect(mockOnFilterChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: 'wordpress' })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders sync status filter buttons', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
expect(screen.getByText('Native')).toBeInTheDocument();
|
||||
expect(screen.getByText('Imported')).toBeInTheDocument();
|
||||
expect(screen.getByText('Synced')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when sync status filter is clicked', () => {
|
||||
render(<ContentFilter onFilterChange={mockOnFilterChange} />);
|
||||
const syncedButton = screen.getByText('Synced').closest('button');
|
||||
|
||||
if (syncedButton) {
|
||||
fireEvent.click(syncedButton);
|
||||
expect(mockOnFilterChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ syncStatus: 'synced' })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tests for SourceBadge component
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SourceBadge } from '../SourceBadge';
|
||||
|
||||
describe('SourceBadge', () => {
|
||||
it('renders IGNY8 badge correctly', () => {
|
||||
render(<SourceBadge source="igny8" />);
|
||||
expect(screen.getByText('IGNY8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders WordPress badge correctly', () => {
|
||||
render(<SourceBadge source="wordpress" />);
|
||||
expect(screen.getByText('WordPress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Shopify badge correctly', () => {
|
||||
render(<SourceBadge source="shopify" />);
|
||||
expect(screen.getByText('Shopify')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Custom badge correctly', () => {
|
||||
render(<SourceBadge source="custom" />);
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<SourceBadge source="igny8" className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Tests for SyncStatusBadge component
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SyncStatusBadge } from '../SyncStatusBadge';
|
||||
|
||||
describe('SyncStatusBadge', () => {
|
||||
it('renders Native badge correctly', () => {
|
||||
render(<SyncStatusBadge status="native" />);
|
||||
expect(screen.getByText('Native')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Imported badge correctly', () => {
|
||||
render(<SyncStatusBadge status="imported" />);
|
||||
expect(screen.getByText('Imported')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Synced badge correctly', () => {
|
||||
render(<SyncStatusBadge status="synced" />);
|
||||
expect(screen.getByText('Synced')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<SyncStatusBadge status="native" className="custom-class" />);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
7
frontend/src/components/content/index.ts
Normal file
7
frontend/src/components/content/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { SourceBadge } from './SourceBadge';
|
||||
export { SyncStatusBadge } from './SyncStatusBadge';
|
||||
export { ContentFilter } from './ContentFilter';
|
||||
export type { ContentSource } from './SourceBadge';
|
||||
export type { SyncStatus } from './SyncStatusBadge';
|
||||
export type { FilterState } from './ContentFilter';
|
||||
|
||||
64
frontend/src/components/linker/LinkResults.tsx
Normal file
64
frontend/src/components/linker/LinkResults.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Link2, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface Link {
|
||||
anchor_text: string;
|
||||
target_content_id: number;
|
||||
target_url?: string;
|
||||
}
|
||||
|
||||
interface LinkResultsProps {
|
||||
contentId: number;
|
||||
links: Link[];
|
||||
linksAdded: number;
|
||||
linkerVersion: number;
|
||||
}
|
||||
|
||||
export const LinkResults: React.FC<LinkResultsProps> = ({
|
||||
contentId,
|
||||
links,
|
||||
linksAdded,
|
||||
linkerVersion,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Linking Results</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Version {linkerVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linksAdded > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{linksAdded} link{linksAdded !== 1 ? 's' : ''} added</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Added Links:</h4>
|
||||
<ul className="space-y-2">
|
||||
{links.map((link, index) => (
|
||||
<li key={index} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">"{link.anchor_text}"</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
Content #{link.target_content_id}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span>No links were added to this content.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
155
frontend/src/components/optimizer/OptimizationScores.tsx
Normal file
155
frontend/src/components/optimizer/OptimizationScores.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
|
||||
interface ScoreData {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
word_count?: number;
|
||||
has_meta_title?: boolean;
|
||||
has_meta_description?: boolean;
|
||||
has_primary_keyword?: boolean;
|
||||
internal_links_count?: number;
|
||||
}
|
||||
|
||||
interface OptimizationScoresProps {
|
||||
scores: ScoreData;
|
||||
before?: ScoreData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const OptimizationScores: React.FC<OptimizationScoresProps> = ({
|
||||
scores,
|
||||
before,
|
||||
className = '',
|
||||
}) => {
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600 dark:text-green-400';
|
||||
if (score >= 60) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
};
|
||||
|
||||
const getScoreBgColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-100 dark:bg-green-900';
|
||||
if (score >= 60) return 'bg-yellow-100 dark:bg-yellow-900';
|
||||
return 'bg-red-100 dark:bg-red-900';
|
||||
};
|
||||
|
||||
const getChangeIcon = (current: number, previous?: number) => {
|
||||
if (!previous) return null;
|
||||
const diff = current - previous;
|
||||
if (diff > 0) return <TrendingUp className="w-4 h-4 text-green-600" />;
|
||||
if (diff < 0) return <TrendingDown className="w-4 h-4 text-red-600" />;
|
||||
return <Minus className="w-4 h-4 text-gray-400" />;
|
||||
};
|
||||
|
||||
const getChangeText = (current: number, previous?: number) => {
|
||||
if (!previous) return null;
|
||||
const diff = current - previous;
|
||||
if (diff > 0) return `+${diff.toFixed(1)}`;
|
||||
if (diff < 0) return diff.toFixed(1);
|
||||
return '0.0';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-4 gap-4 ${className}`}>
|
||||
{/* Overall Score */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Overall</span>
|
||||
{before && getChangeIcon(scores.overall_score, before.overall_score)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(scores.overall_score)}`}>
|
||||
{scores.overall_score.toFixed(1)}
|
||||
</span>
|
||||
{before && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{getChangeText(scores.overall_score, before.overall_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-2 rounded-full ${getScoreBgColor(scores.overall_score)}`}>
|
||||
<div
|
||||
className={`h-2 rounded-full ${getScoreColor(scores.overall_score).replace('text-', 'bg-')}`}
|
||||
style={{ width: `${scores.overall_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Score */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">SEO</span>
|
||||
{before && getChangeIcon(scores.seo_score, before.seo_score)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(scores.seo_score)}`}>
|
||||
{scores.seo_score.toFixed(1)}
|
||||
</span>
|
||||
{before && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{getChangeText(scores.seo_score, before.seo_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-2 rounded-full ${getScoreBgColor(scores.seo_score)}`}>
|
||||
<div
|
||||
className={`h-2 rounded-full ${getScoreColor(scores.seo_score).replace('text-', 'bg-')}`}
|
||||
style={{ width: `${scores.seo_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Readability Score */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Readability</span>
|
||||
{before && getChangeIcon(scores.readability_score, before.readability_score)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(scores.readability_score)}`}>
|
||||
{scores.readability_score.toFixed(1)}
|
||||
</span>
|
||||
{before && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{getChangeText(scores.readability_score, before.readability_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-2 rounded-full ${getScoreBgColor(scores.readability_score)}`}>
|
||||
<div
|
||||
className={`h-2 rounded-full ${getScoreColor(scores.readability_score).replace('text-', 'bg-')}`}
|
||||
style={{ width: `${scores.readability_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engagement Score */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Engagement</span>
|
||||
{before && getChangeIcon(scores.engagement_score, before.engagement_score)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(scores.engagement_score)}`}>
|
||||
{scores.engagement_score.toFixed(1)}
|
||||
</span>
|
||||
{before && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{getChangeText(scores.engagement_score, before.engagement_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-2 h-2 rounded-full ${getScoreBgColor(scores.engagement_score)}`}>
|
||||
<div
|
||||
className={`h-2 rounded-full ${getScoreColor(scores.engagement_score).replace('text-', 'bg-')}`}
|
||||
style={{ width: `${scores.engagement_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
116
frontend/src/components/optimizer/ScoreComparison.tsx
Normal file
116
frontend/src/components/optimizer/ScoreComparison.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { OptimizationScores } from './OptimizationScores';
|
||||
|
||||
interface ScoreData {
|
||||
seo_score: number;
|
||||
readability_score: number;
|
||||
engagement_score: number;
|
||||
overall_score: number;
|
||||
}
|
||||
|
||||
interface ScoreComparisonProps {
|
||||
before: ScoreData;
|
||||
after: ScoreData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ScoreComparison: React.FC<ScoreComparisonProps> = ({
|
||||
before,
|
||||
after,
|
||||
className = '',
|
||||
}) => {
|
||||
const calculateImprovement = (before: number, after: number) => {
|
||||
const diff = after - before;
|
||||
const percent = before > 0 ? ((diff / before) * 100).toFixed(1) : '0.0';
|
||||
return { diff, percent };
|
||||
};
|
||||
|
||||
const overallImprovement = calculateImprovement(before.overall_score, after.overall_score);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Score Comparison</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Overall Improvement:</span>
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
overallImprovement.diff > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: overallImprovement.diff < 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{overallImprovement.diff > 0 ? '+' : ''}
|
||||
{overallImprovement.diff.toFixed(1)} ({overallImprovement.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Before Scores */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Before</h4>
|
||||
<OptimizationScores scores={before} />
|
||||
</div>
|
||||
|
||||
{/* After Scores */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">After</h4>
|
||||
<OptimizationScores scores={after} before={before} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Detailed Breakdown</h4>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ label: 'SEO Score', before: before.seo_score, after: after.seo_score },
|
||||
{ label: 'Readability Score', before: before.readability_score, after: after.readability_score },
|
||||
{ label: 'Engagement Score', before: before.engagement_score, after: after.engagement_score },
|
||||
{ label: 'Overall Score', before: before.overall_score, after: after.overall_score },
|
||||
].map(({ label, before: beforeScore, after: afterScore }) => {
|
||||
const improvement = calculateImprovement(beforeScore, afterScore);
|
||||
return (
|
||||
<div key={label} className="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{label}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">{beforeScore.toFixed(1)}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
improvement.diff > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: improvement.diff < 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{afterScore.toFixed(1)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
improvement.diff > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: improvement.diff < 0
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
({improvement.diff > 0 ? '+' : ''}
|
||||
{improvement.diff.toFixed(1)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import Badge from '../../components/ui/badge/Badge';
|
||||
import { formatRelativeDate } from '../../utils/date';
|
||||
import { Content } from '../../services/api';
|
||||
import { FileIcon, MoreDotIcon } from '../../icons';
|
||||
import { SourceBadge, ContentSource } from '../../components/content/SourceBadge';
|
||||
import { SyncStatusBadge, SyncStatus } from '../../components/content/SyncStatusBadge';
|
||||
|
||||
export interface ColumnConfig {
|
||||
key: string;
|
||||
@@ -192,6 +194,26 @@ export const createContentPageConfig = (
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
sortable: true,
|
||||
sortField: 'source',
|
||||
width: '120px',
|
||||
render: (_value: any, row: Content) => (
|
||||
<SourceBadge source={(row.source as ContentSource) || 'igny8'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sync_status',
|
||||
label: 'Sync Status',
|
||||
sortable: true,
|
||||
sortField: 'sync_status',
|
||||
width: '120px',
|
||||
render: (_value: any, row: Content) => (
|
||||
<SyncStatusBadge status={(row.sync_status as SyncStatus) || 'native'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
...createdColumn,
|
||||
sortable: true,
|
||||
@@ -327,6 +349,29 @@ export const createContentPageConfig = (
|
||||
{ value: 'publish', label: 'Publish' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Sources' },
|
||||
{ value: 'igny8', label: 'IGNY8' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'shopify', label: 'Shopify' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sync_status',
|
||||
label: 'Sync Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Sync Status' },
|
||||
{ value: 'native', label: 'Native' },
|
||||
{ value: 'imported', label: 'Imported' },
|
||||
{ value: 'synced', label: 'Synced' },
|
||||
],
|
||||
},
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
|
||||
@@ -51,6 +51,24 @@ export const routes: RouteConfig[] = [
|
||||
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/linker',
|
||||
label: 'Linker',
|
||||
icon: 'Link2',
|
||||
children: [
|
||||
{ path: '/linker', label: 'Dashboard', breadcrumb: 'Linker Dashboard' },
|
||||
{ path: '/linker/content', label: 'Content', breadcrumb: 'Link Content' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/optimizer',
|
||||
label: 'Optimizer',
|
||||
icon: 'Zap',
|
||||
children: [
|
||||
{ path: '/optimizer', label: 'Dashboard', breadcrumb: 'Optimizer Dashboard' },
|
||||
{ path: '/optimizer/content', label: 'Content', breadcrumb: 'Optimize Content' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
|
||||
|
||||
@@ -134,6 +134,30 @@ const AppSidebar: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Add Linker if enabled
|
||||
if (moduleEnabled('linker')) {
|
||||
workflowItems.push({
|
||||
icon: <PlugInIcon />,
|
||||
name: "Linker",
|
||||
subItems: [
|
||||
{ name: "Dashboard", path: "/linker" },
|
||||
{ name: "Content", path: "/linker/content" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Optimizer if enabled
|
||||
if (moduleEnabled('optimizer')) {
|
||||
workflowItems.push({
|
||||
icon: <BoltIcon />,
|
||||
name: "Optimizer",
|
||||
subItems: [
|
||||
{ name: "Dashboard", path: "/optimizer" },
|
||||
{ name: "Content", path: "/optimizer/content" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Automation if enabled
|
||||
if (moduleEnabled('automation')) {
|
||||
workflowItems.push({
|
||||
|
||||
230
frontend/src/pages/Linker/ContentList.tsx
Normal file
230
frontend/src/pages/Linker/ContentList.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
163
frontend/src/pages/Linker/Dashboard.tsx
Normal file
163
frontend/src/pages/Linker/Dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
104
frontend/src/pages/Linker/__tests__/ContentList.test.tsx
Normal file
104
frontend/src/pages/Linker/__tests__/ContentList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
82
frontend/src/pages/Linker/__tests__/Dashboard.test.tsx
Normal file
82
frontend/src/pages/Linker/__tests__/Dashboard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
148
frontend/src/pages/Optimizer/AnalysisPreview.tsx
Normal file
148
frontend/src/pages/Optimizer/AnalysisPreview.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
165
frontend/src/pages/Optimizer/Dashboard.tsx
Normal file
165
frontend/src/pages/Optimizer/Dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
155
frontend/src/pages/Optimizer/__tests__/ContentSelector.test.tsx
Normal file
155
frontend/src/pages/Optimizer/__tests__/ContentSelector.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
82
frontend/src/pages/Optimizer/__tests__/Dashboard.test.tsx
Normal file
82
frontend/src/pages/Optimizer/__tests__/Dashboard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user