283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import PageMeta from '../../components/common/PageMeta';
|
|
import PageHeader from '../../components/common/PageHeader';
|
|
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
|
import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter';
|
|
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 { PlugInIcon, CheckCircleIcon, FileIcon } from '../../icons';
|
|
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"
|
|
navigation={<ModuleNavigationTabs tabs={[
|
|
{ label: 'Content', path: '/linker/content', icon: <FileIcon /> },
|
|
]} />}
|
|
/>
|
|
|
|
{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">
|
|
Cluster
|
|
</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">
|
|
{item.cluster_name ? (
|
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
{item.cluster_name}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400 dark:text-gray-500">-</span>
|
|
)}
|
|
</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 ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Processing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlugInIcon 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>
|
|
)}
|
|
|
|
{/* Module Metrics Footer */}
|
|
<ModuleMetricsFooter
|
|
metrics={[
|
|
{
|
|
title: 'Total Content',
|
|
value: totalCount.toLocaleString(),
|
|
subtitle: `${content.filter(c => (c.internal_links?.length || 0) > 0).length} with links`,
|
|
icon: <FileIcon className="w-5 h-5" />,
|
|
accentColor: 'blue',
|
|
href: '/linker/content',
|
|
},
|
|
{
|
|
title: 'Links Added',
|
|
value: content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0).toLocaleString(),
|
|
subtitle: `${Object.keys(linkResults).length} processed`,
|
|
icon: <PlugInIcon className="w-5 h-5" />,
|
|
accentColor: 'purple',
|
|
},
|
|
{
|
|
title: 'Avg Links/Content',
|
|
value: content.length > 0
|
|
? (content.reduce((sum, c) => sum + (c.internal_links?.length || 0), 0) / content.length).toFixed(1)
|
|
: '0',
|
|
subtitle: `${content.filter(c => c.linker_version && c.linker_version > 0).length} optimized`,
|
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
|
accentColor: 'green',
|
|
},
|
|
]}
|
|
progress={{
|
|
label: 'Content Linking Progress',
|
|
value: totalCount > 0 ? Math.round((content.filter(c => (c.internal_links?.length || 0) > 0).length / totalCount) * 100) : 0,
|
|
color: 'primary',
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|