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:
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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user