Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,482 @@
/**
* Clusters Page - Refactored to use TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchClusters,
createCluster,
updateCluster,
deleteCluster,
bulkDeleteClusters,
bulkUpdateClustersStatus,
autoGenerateIdeas,
Cluster,
ClusterFilters,
ClusterCreateData,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { GroupIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createClustersPageConfig } from '../../config/pages/clusters.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
export default function Clusters() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingCluster, setEditingCluster] = useState<Cluster | null>(null);
const [formData, setFormData] = useState<ClusterCreateData>({
name: '',
description: '',
status: 'active',
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters - wrapped in useCallback to prevent infinite loops
const loadClusters = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : 'name';
const filters: ClusterFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(activeSector?.id && { sector_id: activeSector.id }),
page: currentPage,
page_size: pageSize,
ordering,
};
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
}
// Add volume range filters
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filters.volume_min = Number(volumeMin);
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filters.volume_max = Number(volumeMax);
}
const data = await fetchClusters(filters);
setClusters(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading clusters:', error);
toast.error(`Failed to load clusters: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, difficultyFilter, volumeMin, volumeMax, activeSector, pageSize]);
// Load data on mount and when filters change
useEffect(() => {
loadClusters();
}, [loadClusters]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadClusters();
};
const handleSectorChange = () => {
loadClusters();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadClusters]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadClusters();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadClusters]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'name');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateClustersStatus(numIds, status);
await loadClusters();
} catch (error: any) {
throw error;
}
}, [loadClusters]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
// TODO: Implement bulk export endpoint
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Cluster) => {
if (action === 'generate_ideas') {
try {
const result = await autoGenerateIdeas([row.id]);
if (result.success && result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Ideas');
} else if (result.success && result.ideas_created) {
// Synchronous completion
toast.success(result.message || 'Ideas generated successfully');
await loadClusters();
} else {
toast.error(result.error || 'Failed to generate ideas');
}
} catch (error: any) {
toast.error(`Failed to generate ideas: ${error.message}`);
}
}
}, [toast, progressModal, loadClusters]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'auto_generate_ideas') {
if (ids.length === 0) {
toast.error('Please select at least one cluster to generate ideas');
return;
}
if (ids.length > 5) {
toast.error('Maximum 5 clusters allowed for idea generation');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await autoGenerateIdeas(numIds);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Content Ideas');
// Don't show toast - progress modal will show status
} else {
// Synchronous completion
toast.success(`Ideas generation complete: ${result.ideas_created || 0} ideas created`);
await loadClusters();
}
} else {
toast.error(result.error || 'Failed to generate ideas');
}
} catch (error: any) {
toast.error(`Failed to generate ideas: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadClusters, progressModal]);
// Close volume dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
volumeDropdownRef.current &&
!volumeDropdownRef.current.contains(event.target as Node) &&
volumeButtonRef.current &&
!volumeButtonRef.current.contains(event.target as Node)
) {
setIsVolumeDropdownOpen(false);
}
};
if (isVolumeDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVolumeDropdownOpen]);
// Create page config
const pageConfig = useMemo(() => {
return createClustersPageConfig({
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
difficultyFilter,
setDifficultyFilter,
volumeMin,
volumeMax,
setVolumeMin,
setVolumeMax,
isVolumeDropdownOpen,
setIsVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
setTempVolumeMin,
setTempVolumeMax,
volumeButtonRef,
volumeDropdownRef,
setCurrentPage,
loadClusters,
});
}, [
activeSector,
formData,
searchTerm,
statusFilter,
difficultyFilter,
volumeMin,
volumeMax,
isVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
loadClusters,
]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ clusters, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, clusters, totalCount]);
const resetForm = useCallback(() => {
setFormData({
name: '',
description: '',
status: 'active',
});
setIsEditMode(false);
setEditingCluster(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingCluster) {
await updateCluster(editingCluster.id, formData);
toast.success('Cluster updated successfully');
} else {
await createCluster(formData);
toast.success('Cluster created successfully');
}
setIsModalOpen(false);
resetForm();
loadClusters();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Keyword Clusters"
titleIcon={<GroupIcon className="text-success-500 size-5" />}
subtitle="Organize keywords into content clusters for better SEO strategy"
columns={pageConfig.columns}
data={clusters}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
difficulty: difficultyFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingCluster(row);
setFormData({
name: row.name || '',
description: row.description || '',
status: row.status || 'active',
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Create Cluster"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteCluster(id);
loadClusters();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteClusters(ids);
loadClusters();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: Cluster) => row.name}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="cluster"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadClusters();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Cluster' : 'Add Cluster'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields()}
/>
</>
);
}

View File

@@ -0,0 +1,308 @@
import { Link } from "react-router";
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
import { ProgressBar } from "../../components/ui/progress";
import { ListIcon, GroupIcon, BoltIcon, PieChartIcon, ArrowRightIcon, CheckCircleIcon, TimeIcon } from "../../icons";
export default function PlannerDashboard() {
// Mock data - will be replaced with API calls
const stats = {
keywords: 245,
clusters: 18,
ideas: 52,
mappedKeywords: 180,
clustersWithIdeas: 12,
queuedIdeas: 35,
};
const keywordMappingPct = stats.keywords > 0 ? Math.round((stats.mappedKeywords / stats.keywords) * 100) : 0;
const clustersIdeasPct = stats.clusters > 0 ? Math.round((stats.clustersWithIdeas / stats.clusters) * 100) : 0;
const ideasQueuedPct = stats.ideas > 0 ? Math.round((stats.queuedIdeas / stats.ideas) * 100) : 0;
const workflowSteps = [
{ number: 1, title: "Add Keywords", status: "completed", count: stats.keywords, path: "/planner/keywords" },
{ number: 2, title: "Select Sector", status: "completed", count: null, path: "/planner" },
{ number: 3, title: "Auto Cluster", status: "pending", count: stats.clusters, path: "/planner/clusters" },
{ number: 4, title: "Generate Ideas", status: "pending", count: stats.ideas, path: "/planner/ideas" },
];
const topClusters = [
{ name: "SEO Optimization", volume: 45800, keywords: 24 },
{ name: "Content Marketing", volume: 32100, keywords: 18 },
{ name: "Link Building", volume: 28700, keywords: 15 },
{ name: "Keyword Research", volume: 24100, keywords: 12 },
{ name: "Analytics", volume: 18900, keywords: 9 },
];
const ideasByStatus = [
{ status: "New", count: 20, color: "blue" },
{ status: "Scheduled", count: 15, color: "amber" },
{ status: "Published", count: 17, color: "green" },
];
const nextActions = [
{ text: "65 keywords unmapped", action: "Map Keywords", path: "/planner/keywords" },
{ text: "6 clusters without ideas", action: "Generate Ideas", path: "/planner/ideas" },
{ text: "17 ideas not queued to writer", action: "Queue to Writer", path: "/writer/tasks" },
];
return (
<>
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
<div className="space-y-5 sm:space-y-6">
{/* Top Status Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 md:gap-6">
<Link
to="/planner/keywords"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-brand-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Keywords Ready</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.keywords.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Research, analyze, and manage keywords strategy
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-blue-50 rounded-xl dark:bg-blue-500/10 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
<ListIcon className="text-brand-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/clusters"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-success-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Clusters Built</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.clusters.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Organize keywords into strategic topical clusters
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-green-50 rounded-xl dark:bg-green-500/10 group-hover:bg-green-100 dark:group-hover:bg-green-500/20 transition-colors">
<GroupIcon className="text-success-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/ideas"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-warning-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Ideas Generated</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.ideas.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Generate creative content ideas based on semantic strategy
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-amber-50 rounded-xl dark:bg-amber-500/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-500/20 transition-colors">
<BoltIcon className="text-warning-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/keywords"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-purple-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Mapped Keywords</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.mappedKeywords.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Keywords successfully mapped to content pages
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-purple-50 rounded-xl dark:bg-purple-500/10 group-hover:bg-purple-100 dark:group-hover:bg-purple-500/20 transition-colors">
<PieChartIcon className="text-purple-500 size-6" />
</div>
</div>
</Link>
</div>
{/* Planner Workflow Steps */}
<ComponentCard title="Planner Workflow Steps" desc="Track your planning progress">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{workflowSteps.map((step) => (
<Link
key={step.number}
to={step.path}
className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 hover:border-brand-300 hover:bg-brand-50 dark:hover:bg-brand-500/10 transition-colors"
>
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center w-8 h-8 bg-white border-2 border-gray-300 rounded-full text-sm font-semibold text-gray-600 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
{step.number}
</div>
<h4 className="font-medium text-gray-800 dark:text-white/90">{step.title}</h4>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
{step.status === "completed" ? (
<>
<CheckCircleIcon className="size-4 text-success-500" />
<span className="text-gray-600 dark:text-gray-300 font-medium">Completed</span>
</>
) : (
<>
<TimeIcon className="size-4 text-amber-500" />
<span className="text-gray-600 dark:text-gray-300 font-medium">Pending</span>
</>
)}
</div>
</div>
{step.count !== null && (
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{step.count} {step.title.includes("Keywords") ? "keywords" : step.title.includes("Clusters") ? "clusters" : "ideas"}{" "}
{step.status === "completed" ? "added" : ""}
</p>
)}
{step.status === "pending" && (
<Link
to={step.path}
className="mt-3 inline-block text-xs font-medium text-brand-500 hover:text-brand-600"
onClick={(e) => e.stopPropagation()}
>
Start Now
</Link>
)}
</Link>
))}
</div>
</ComponentCard>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Progress Summary */}
<ComponentCard title="Progress & Readiness Summary" desc="Planning workflow progress tracking" className="lg:col-span-1">
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Keyword Mapping</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{keywordMappingPct}%</span>
</div>
<ProgressBar value={keywordMappingPct} color="primary" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.mappedKeywords} of {stats.keywords} keywords mapped
</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Clusters With Ideas</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{clustersIdeasPct}%</span>
</div>
<ProgressBar value={clustersIdeasPct} color="success" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.clustersWithIdeas} of {stats.clusters} clusters have ideas
</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Ideas Queued to Writer</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{ideasQueuedPct}%</span>
</div>
<ProgressBar value={ideasQueuedPct} color="warning" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.queuedIdeas} of {stats.ideas} ideas queued
</p>
</div>
</div>
</ComponentCard>
{/* Top 5 Clusters */}
<ComponentCard title="Top 5 Clusters by Volume" desc="Highest volume keyword clusters" className="lg:col-span-1">
<div className="space-y-4">
{topClusters.map((cluster, index) => {
const maxVolume = topClusters[0].volume;
const percentage = Math.round((cluster.volume / maxVolume) * 100);
return (
<div key={index}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{cluster.name}</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
{cluster.volume.toLocaleString()}
</span>
</div>
<ProgressBar
value={percentage}
color={index % 2 === 0 ? "primary" : "success"}
size="sm"
/>
</div>
);
})}
</div>
</ComponentCard>
{/* Ideas by Status */}
<ComponentCard title="Ideas by Status" desc="Content ideas workflow status" className="lg:col-span-1">
<div className="space-y-4">
{ideasByStatus.map((item, index) => {
const total = ideasByStatus.reduce((sum, i) => sum + i.count, 0);
const percentage = Math.round((item.count / total) * 100);
return (
<div key={index}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{item.status}</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{item.count}</span>
</div>
<ProgressBar
value={percentage}
color={
item.color === "blue"
? "primary"
: item.color === "amber"
? "warning"
: "success"
}
size="sm"
/>
</div>
);
})}
</div>
</ComponentCard>
</div>
{/* Next Actions */}
<ComponentCard title="Next Actions" desc="Actionable items requiring attention">
<div className="space-y-3">
{nextActions.map((action, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800"
>
<span className="text-sm text-gray-700 dark:text-gray-300">{action.text}</span>
<Link
to={action.path}
className="inline-flex items-center gap-1 text-sm font-medium text-brand-500 hover:text-brand-600"
>
{action.action}
<ArrowRightIcon className="size-4" />
</Link>
</div>
))}
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,427 @@
/**
* Ideas Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContentIdeas,
createContentIdea,
updateContentIdea,
deleteContentIdea,
bulkDeleteContentIdeas,
bulkUpdateContentIdeasStatus,
bulkQueueIdeasToWriter,
ContentIdea,
ContentIdeasFilters,
ContentIdeaCreateData,
fetchClusters,
Cluster,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { BoltIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
export default function Ideas() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [ideas, setIdeas] = useState<ContentIdea[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [structureFilter, setStructureFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingIdea, setEditingIdea] = useState<ContentIdea | null>(null);
const [formData, setFormData] = useState<ContentIdeaCreateData>({
idea_title: '',
description: '',
content_structure: 'blog_post',
content_type: 'blog_post',
target_keywords: '',
keyword_cluster_id: null,
status: 'new',
estimated_word_count: 1000,
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load ideas - wrapped in useCallback
const loadIdeas = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: ContentIdeasFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { keyword_cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
...(typeFilter && { content_type: typeFilter }),
page: currentPage,
page_size: pageSize,
ordering,
};
const data = await fetchContentIdeas(filters);
setIdeas(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading ideas:', error);
toast.error(`Failed to load ideas: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, structureFilter, typeFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize]);
useEffect(() => {
loadIdeas();
}, [loadIdeas]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadIdeas();
};
const handleSectorChange = () => {
loadIdeas();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadIdeas]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadIdeas();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadIdeas]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateContentIdeasStatus(numIds, status);
await loadIdeas();
} catch (error: any) {
throw error;
}
}, [loadIdeas]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: ContentIdea) => {
if (action === 'queue_to_writer') {
if (row.status !== 'new') {
toast.error(`Only ideas with status "new" can be queued. Current status: ${row.status}`);
return;
}
try {
const result = await bulkQueueIdeasToWriter([row.id]);
toast.success(`Queue complete: ${result.created_count || 0} task created`);
await loadIdeas();
} catch (error: any) {
toast.error(`Failed to queue idea: ${error.message}`);
}
}
}, [toast, loadIdeas]);
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'queue_to_writer') {
if (ids.length === 0) {
toast.error('Please select at least one idea to queue');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await bulkQueueIdeasToWriter(numIds);
toast.success(`Queue complete: ${result.created_count || 0} tasks created from ${ids.length} ideas`);
await loadIdeas();
} catch (error: any) {
toast.error(`Failed to queue ideas: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadIdeas]);
// Create page config
const pageConfig = useMemo(() => {
return createIdeasPageConfig({
clusters,
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
clusterFilter,
setClusterFilter,
structureFilter,
setStructureFilter,
typeFilter,
setTypeFilter,
setCurrentPage,
});
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ ideas, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, ideas, totalCount]);
const resetForm = useCallback(() => {
setFormData({
idea_title: '',
description: '',
content_structure: 'blog_post',
content_type: 'blog_post',
target_keywords: '',
keyword_cluster_id: null,
status: 'new',
estimated_word_count: 1000,
});
setIsEditMode(false);
setEditingIdea(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingIdea) {
await updateContentIdea(editingIdea.id, formData);
toast.success('Idea updated successfully');
} else {
await createContentIdea(formData);
toast.success('Idea created successfully');
}
setIsModalOpen(false);
resetForm();
loadIdeas();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Content Ideas"
titleIcon={<BoltIcon className="text-warning-500 size-5" />}
subtitle="Generate and organize content ideas based on keyword research"
columns={pageConfig.columns}
data={ideas}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
keyword_cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'keyword_cluster_id') {
setClusterFilter(stringValue);
} else if (key === 'content_structure') {
setStructureFilter(stringValue);
} else if (key === 'content_type') {
setTypeFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingIdea(row);
setFormData({
idea_title: row.idea_title || '',
description: row.description || '',
content_structure: row.content_structure || 'blog_post',
content_type: row.content_type || 'blog_post',
target_keywords: row.target_keywords || '',
keyword_cluster_id: row.keyword_cluster_id || null,
status: row.status || 'new',
estimated_word_count: row.estimated_word_count || 1000,
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Idea"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteContentIdea(id);
loadIdeas();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteContentIdeas(ids);
loadIdeas();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: ContentIdea) => row.idea_title}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="idea"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setStructureFilter('');
setTypeFilter('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadIdeas();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Idea' : 'Add Idea'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
</>
);
}

View File

@@ -0,0 +1,625 @@
/**
* Keyword Opportunities Page
* Shows available SeedKeywords for the active site/sectors
* Allows users to add keywords to their workflow
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchSeedKeywords,
SeedKeyword,
SeedKeywordResponse,
addSeedKeywordsToWorkflow,
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { getDifficultyLabelFromNumber, getDifficultyRange, getDifficultyNumber } from '../../utils/difficulty';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { BoltIcon, PlusIcon } from '../../icons';
export default function KeywordOpportunities() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
const [loading, setLoading] = useState(true);
const [showContent, setShowContent] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('keyword');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
// Load sectors for active site
useEffect(() => {
if (activeSite?.id) {
loadSectorsForSite(activeSite.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSite?.id]); // loadSectorsForSite is stable from Zustand store, no need to include it
// Load seed keywords
const loadSeedKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
setLoading(false);
return;
}
setLoading(true);
setShowContent(false);
try {
// Get already-attached keywords across ALL sectors for this site
let attachedSeedKeywordIds = new Set<number>();
try {
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
// Get all sectors for the site
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000, // Get all to check which are attached
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
// If keywords fetch fails for a sector, continue with others
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
} catch (err) {
// If sectors fetch fails, continue without filtering
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Build filters - fetch ALL results by paginating through all pages
const baseFilters: any = {
industry: activeSite.industry,
page_size: 1000, // Use reasonable page size (API might have max limit)
};
// Add sector filter if active sector is selected
// IMPORTANT: Filter by industry_sector (IndustrySector ID) which is what SeedKeyword.sector references
if (activeSector && activeSector.industry_sector) {
baseFilters.sector = activeSector.industry_sector;
}
if (searchTerm) baseFilters.search = searchTerm;
if (intentFilter) baseFilters.intent = intentFilter;
// Fetch ALL pages to get complete dataset
let allResults: SeedKeyword[] = [];
let currentPageNum = 1;
let hasMore = true;
while (hasMore) {
const filters = { ...baseFilters, page: currentPageNum };
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
if (data.results && data.results.length > 0) {
allResults = [...allResults, ...data.results];
}
// Check if there are more pages
hasMore = data.next !== null && data.next !== undefined;
currentPageNum++;
// Safety limit to prevent infinite loops
if (currentPageNum > 100) {
console.warn('Reached maximum page limit (100) while fetching seed keywords');
break;
}
}
// Mark already-attached keywords instead of filtering them out
// Also check recentlyAddedRef to preserve state for keywords just added
let filteredResults = allResults.map(sk => {
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
return {
...sk,
isAdded: Boolean(isAdded) // Explicitly convert to boolean true/false
};
});
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filteredResults = filteredResults.filter(
sk => sk.difficulty >= range.min && sk.difficulty <= range.max
);
}
}
}
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume >= Number(volumeMin));
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume <= Number(volumeMax));
}
// Apply client-side sorting
if (sortBy) {
filteredResults.sort((a, b) => {
let aVal: any;
let bVal: any;
if (sortBy === 'keyword') {
aVal = a.keyword.toLowerCase();
bVal = b.keyword.toLowerCase();
} else if (sortBy === 'volume') {
aVal = a.volume;
bVal = b.volume;
} else if (sortBy === 'difficulty') {
aVal = a.difficulty;
bVal = b.difficulty;
} else if (sortBy === 'intent') {
aVal = a.intent.toLowerCase();
bVal = b.intent.toLowerCase();
} else {
return 0;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
// Calculate total count and pages from filtered results
const totalFiltered = filteredResults.length;
const pageSizeNum = pageSize || 10;
// Apply client-side pagination
const startIndex = (currentPage - 1) * pageSizeNum;
const endIndex = startIndex + pageSizeNum;
const paginatedResults = filteredResults.slice(startIndex, endIndex);
setSeedKeywords(paginatedResults);
setTotalCount(totalFiltered);
setTotalPages(Math.ceil(totalFiltered / pageSizeNum));
setShowContent(true);
} catch (error: any) {
console.error('Error loading seed keywords:', error);
toast.error(`Failed to load keyword opportunities: ${error.message}`);
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, intentFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection]);
// Load data on mount and when filters change (excluding search - handled separately)
useEffect(() => {
loadSeedKeywords();
}, [loadSeedKeywords]);
// Debounced search - reset to page 1 when search term changes
useEffect(() => {
const timer = setTimeout(() => {
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]); // Only depend on searchTerm
// Handle pageSize changes - reload data when pageSize changes
// Note: loadSeedKeywords will be recreated when pageSize changes (it's in its dependencies)
// The effect that depends on loadSeedKeywords will handle the reload
// We just need to reset to page 1
useEffect(() => {
setCurrentPage(1);
}, [pageSize]); // Only depend on pageSize
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'keyword');
setSortDirection(direction);
setCurrentPage(1);
};
// Handle adding keywords to workflow
const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
// Get sector to use - use activeSector if available, otherwise get first available sector
let sectorToUse = activeSector;
if (!sectorToUse) {
try {
const { fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
if (sectors.length === 0) {
toast.error('No sectors available for this site. Please create a sector first.');
return;
}
sectorToUse = {
id: sectors[0].id,
name: sectors[0].name,
slug: sectors[0].slug,
site_id: activeSite.id,
is_active: sectors[0].is_active !== false,
industry_sector: sectors[0].industry_sector || null,
};
} catch (error: any) {
toast.error(`Failed to get sectors: ${error.message}`);
return;
}
}
try {
const result = await addSeedKeywordsToWorkflow(
seedKeywordIds,
activeSite.id,
sectorToUse.id
);
if (result.success) {
toast.success(`Successfully added ${result.created} keyword(s) to workflow`);
// Track these as recently added to preserve state during reload
seedKeywordIds.forEach(id => {
recentlyAddedRef.current.add(id);
});
// Clear selection
setSelectedIds([]);
// Immediately update state to mark keywords as added - this gives instant feedback
setSeedKeywords(prevKeywords =>
prevKeywords.map(kw =>
seedKeywordIds.includes(kw.id)
? { ...kw, isAdded: true }
: kw
)
);
// Don't reload immediately - the state is already updated
// The recentlyAddedRef will ensure they stay marked as added
// Only reload if user changes filters/pagination
} else {
toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`);
}
} catch (error: any) {
toast.error(`Failed to add keywords: ${error.message}`);
}
}, [activeSite, activeSector, toast]);
// Handle bulk add selected - filter out already added keywords
const handleBulkAddSelected = useCallback(async (ids: string[]) => {
if (ids.length === 0) {
toast.error('Please select at least one keyword');
return;
}
// Filter out already added keywords
const availableIds = ids.filter(id => {
const keyword = seedKeywords.find(sk => String(sk.id) === id);
return keyword && !keyword.isAdded;
});
if (availableIds.length === 0) {
toast.error('All selected keywords are already added to workflow');
return;
}
if (availableIds.length < ids.length) {
toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableIds.map(id => parseInt(id));
await handleAddToWorkflow(seedKeywordIds);
}, [handleAddToWorkflow, toast, seedKeywords]);
// Handle add all - fetch all keywords for site/sectors, not just current page
const handleAddAll = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
toast.error('Please select an active site first');
return;
}
try {
// Fetch ALL seed keywords for the site/sectors (no pagination)
const filters: any = {
industry: activeSite.industry,
page_size: 1000, // Large page size to get all
};
if (activeSector?.industry_sector) {
filters.sector = activeSector.industry_sector;
}
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
const allSeedKeywords = data.results || [];
if (allSeedKeywords.length === 0) {
toast.error('No keywords available to add');
return;
}
// Get already-added keywords to filter them out
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
let attachedSeedKeywordIds = new Set<number>();
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
// Filter out already added keywords
const availableKeywords = allSeedKeywords.filter(sk => !attachedSeedKeywordIds.has(sk.id));
if (availableKeywords.length === 0) {
toast.error('All keywords are already added to workflow');
return;
}
if (availableKeywords.length < allSeedKeywords.length) {
toast.info(`${allSeedKeywords.length - availableKeywords.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableKeywords.map(sk => sk.id);
await handleAddToWorkflow(seedKeywordIds);
} catch (error: any) {
toast.error(`Failed to load all keywords: ${error.message}`);
}
}, [activeSite, activeSector, handleAddToWorkflow, toast]);
// Page config
const pageConfig = useMemo(() => {
const showSectorColumn = !activeSector; // Show when viewing all sectors
return {
columns: [
{
key: 'keyword',
label: 'Keyword',
sortable: true,
sortField: 'keyword',
},
...(showSectorColumn ? [{
key: 'sector_name',
label: 'Sector',
sortable: false,
render: (_value: string, row: SeedKeyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'volume',
label: 'Volume',
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
key: 'difficulty',
label: 'Difficulty',
sortable: true,
sortField: 'difficulty',
align: 'center' as const,
render: (value: number) => {
const difficultyNum = getDifficultyNumber(value);
const difficultyBadgeVariant =
typeof difficultyNum === 'number' && difficultyNum === 5
? 'solid'
: typeof difficultyNum === 'number' &&
(difficultyNum === 2 || difficultyNum === 3 || difficultyNum === 4)
? 'light'
: typeof difficultyNum === 'number' && difficultyNum === 1
? 'solid'
: 'light';
const difficultyBadgeColor =
typeof difficultyNum === 'number' && difficultyNum === 1
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 2
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 3
? 'warning'
: typeof difficultyNum === 'number' && difficultyNum === 4
? 'error'
: typeof difficultyNum === 'number' && difficultyNum === 5
? 'error'
: 'light';
return typeof difficultyNum === 'number' ? (
<Badge
color={difficultyBadgeColor}
variant={difficultyBadgeVariant}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
{
key: 'intent',
label: 'Intent',
sortable: true,
sortField: 'intent',
render: (value: string) => {
const getIntentColor = (intent: string) => {
const lowerIntent = intent?.toLowerCase() || '';
if (lowerIntent === 'transactional' || lowerIntent === 'commercial') {
return 'success';
} else if (lowerIntent === 'navigational') {
return 'warning';
}
return 'info';
};
return (
<Badge
color={getIntentColor(value)}
size="sm"
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
>
{value}
</Badge>
);
},
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'intent',
label: 'Intent',
type: 'select',
options: [
{ value: '', label: 'All Intent' },
{ value: 'informational', label: 'Informational' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'commercial', label: 'Commercial' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
],
},
],
};
}, [activeSector]);
return (
<>
<TablePageTemplate
title="Keyword Opportunities"
titleIcon={<BoltIcon className="text-brand-500 size-5" />}
subtitle="Discover and add keywords to your workflow"
columns={pageConfig.columns}
data={seedKeywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
intent: intentFilter,
difficulty: difficultyFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'intent') {
setIntentFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
}
}}
onRowAction={async (actionKey: string, row: SeedKeyword & { isAdded?: boolean }) => {
if (actionKey === 'add_to_workflow') {
// Don't allow adding already-added keywords
if (row.isAdded) {
toast.info('This keyword is already added to workflow');
return;
}
await handleAddToWorkflow([row.id]);
}
}}
onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') {
await handleBulkAddSelected(ids);
}
}}
onCreate={handleAddAll}
createLabel="Add All to Workflow"
onCreateIcon={<PlusIcon />}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
/>
</>
);
}

View File

@@ -0,0 +1,718 @@
/**
* Keywords Page - Refactored to use TablePageTemplate
* This demonstrates how to use the config-driven template system
* while maintaining all existing functionality.
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchKeywords,
createKeyword,
updateKeyword,
deleteKeyword,
bulkDeleteKeywords,
bulkUpdateKeywordsStatus,
Keyword,
KeywordFilters,
KeywordCreateData,
fetchClusters,
Cluster,
API_BASE_URL,
autoClusterKeywords,
fetchSeedKeywords,
SeedKeyword,
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { ArrowUpIcon, PlusIcon, ListIcon, DownloadIcon } from '../../icons';
import { useKeywordsImportExport } from '../../config/import-export.config';
import { createKeywordsPageConfig } from '../../config/pages/keywords.config';
export default function Keywords() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [keywords, setKeywords] = useState<Keyword[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [availableSeedKeywords, setAvailableSeedKeywords] = useState<SeedKeyword[]>([]);
const [loading, setLoading] = useState(true);
const [loadingSeedKeywords, setLoadingSeedKeywords] = useState(false);
// Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state (global - not page-specific)
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null);
const [formData, setFormData] = useState<KeywordCreateData>({
seed_keyword_id: 0,
volume_override: null,
difficulty_override: null,
cluster_id: null,
status: 'pending',
});
// Progress modal for AI functions
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load sectors for active site using sector store
useEffect(() => {
if (activeSite) {
loadSectorsForSite(activeSite.id);
}
}, [activeSite, loadSectorsForSite]);
// Load available SeedKeywords when site and sector are selected
useEffect(() => {
const loadAvailableSeedKeywords = async () => {
if (!activeSite || !activeSector || !activeSite.industry) {
setAvailableSeedKeywords([]);
return;
}
try {
setLoadingSeedKeywords(true);
// Fetch SeedKeywords for the site's industry and sector's industry_sector
const response = await fetchSeedKeywords({
industry: activeSite.industry,
sector: activeSector.industry_sector || undefined,
});
// Filter out SeedKeywords that are already attached to this site/sector
const attachedSeedKeywordIds = new Set(
keywords.map(k => k.seed_keyword_id)
);
const available = (response.results || []).filter(
sk => !attachedSeedKeywordIds.has(sk.id)
);
setAvailableSeedKeywords(available);
} catch (error: any) {
console.error('Failed to load available seed keywords:', error);
setAvailableSeedKeywords([]);
} finally {
setLoadingSeedKeywords(false);
}
};
loadAvailableSeedKeywords();
}, [activeSite, activeSector, keywords]);
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load keywords - wrapped in useCallback to prevent infinite loops
const loadKeywords = useCallback(async () => {
setLoading(true);
setShowContent(false); // Reset showContent to show loading state
try {
// Build ordering parameter from sort state
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
// Build filters object from individual filter states
// Only include filters that have actual values (not empty strings)
// Use activeSector from store for sector filtering
const filters: KeywordFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { cluster_id: clusterFilter }),
...(intentFilter && { intent: intentFilter }),
...(activeSector?.id && { sector_id: activeSector.id }),
page: currentPage,
page_size: pageSize || 10, // Ensure we always send a page_size
ordering,
};
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
}
// Add volume range filters
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filters.volume_min = Number(volumeMin);
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filters.volume_max = Number(volumeMax);
}
const data = await fetchKeywords(filters);
setKeywords(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
// Show content after data loads (smooth reveal)
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading keywords:', error);
toast.error(`Failed to load keywords: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, intentFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection, searchTerm, activeSite, activeSector, pageSize]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
// Reload keywords when site changes (sector store will auto-select first sector)
loadKeywords();
// Reload clusters for new site
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
};
const handleSectorChange = () => {
// Reload keywords when sector changes
loadKeywords();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadKeywords]);
// Handle click outside volume dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
volumeDropdownRef.current &&
!volumeDropdownRef.current.contains(event.target as Node) &&
volumeButtonRef.current &&
!volumeButtonRef.current.contains(event.target as Node)
) {
setIsVolumeDropdownOpen(false);
setTempVolumeMin(volumeMin);
setTempVolumeMax(volumeMax);
}
};
if (isVolumeDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isVolumeDropdownOpen, volumeMin, volumeMax]);
// Load data on mount and when filters change (excluding search - handled separately)
useEffect(() => {
loadKeywords();
}, [loadKeywords]);
// Debounced search - reset to page 1 when search term changes
useEffect(() => {
const timer = setTimeout(() => {
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]); // Only depend on searchTerm
// Handle pageSize changes - reload data when pageSize changes
// Note: TablePageTemplate already calls onPageChange(1), but we need to ensure reload happens
useEffect(() => {
// When pageSize changes:
// 1. Reset to page 1 (TablePageTemplate does this, but we ensure it)
// 2. loadKeywords will be recreated because pageSize is in its dependency array
// 3. The useEffect that depends on loadKeywords will fire and reload data
// But we need to ensure reload happens even if currentPage is already 1
const wasOnPage1 = currentPage === 1;
setCurrentPage(1);
// If we were already on page 1, explicitly trigger reload
// Otherwise, the currentPage change will trigger reload via loadKeywords dependency
if (wasOnPage1) {
// Use a small timeout to ensure state has updated
setTimeout(() => {
loadKeywords();
}, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize]); // Only depend on pageSize - loadKeywords will be recreated when pageSize changes
// Handle sorting (global)
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1); // Reset to first page when sorting changes
};
// Import/Export handlers
const { handleExport, handleImportClick, ImportModal } = useKeywordsImportExport(
() => {
toast.success('Import successful', 'Keywords imported successfully.');
loadKeywords();
},
(error) => {
toast.error('Import failed', error.message);
},
// Pass active site_id and active sector_id for import
activeSite && activeSector
? { site_id: activeSite.id, sector_id: activeSector.id }
: undefined
);
// Handle bulk actions (delete, export, update_status are now handled by TablePageTemplate)
// This is only for actions that don't have modals (like auto_cluster)
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'auto_cluster') {
if (ids.length === 0) {
toast.error('Please select at least one keyword to cluster');
return;
}
if (ids.length > 20) {
toast.error('Maximum 20 keywords allowed for clustering');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const sectorId = activeSector?.id;
const result = await autoClusterKeywords(numIds, sectorId);
// Check if result has success field - if false, it's an error response
if (result && result.success === false) {
// Error response from API
const errorMsg = result.error || 'Failed to cluster keywords';
toast.error(errorMsg);
return;
}
if (result && result.success) {
if (result.task_id) {
// Async task - open progress modal
hasReloadedRef.current = false;
progressModal.openModal(result.task_id, 'Auto-Clustering Keywords');
// Don't show toast - progress modal will show status
} else {
// Synchronous completion
toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`);
if (!hasReloadedRef.current) {
hasReloadedRef.current = true;
loadKeywords();
}
}
} else {
// Unexpected response format - show error
const errorMsg = result?.error || 'Unexpected response format';
toast.error(errorMsg);
}
} catch (error: any) {
// API error (network error, parse error, etc.)
let errorMsg = 'Failed to cluster keywords';
if (error.message) {
// Extract clean error message from API error format
errorMsg = error.message.replace(/^API Error \(\d+\): [^-]+ - /, '').trim();
if (!errorMsg || errorMsg === error.message) {
errorMsg = error.message;
}
}
toast.error(errorMsg);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, activeSector, loadKeywords, progressModal]);
const resetForm = useCallback(() => {
setFormData({
seed_keyword_id: 0,
volume_override: null,
difficulty_override: null,
cluster_id: null,
status: 'pending',
});
setIsEditMode(false);
setEditingKeyword(null);
}, []);
// Bulk export handler for selected items (used by TablePageTemplate modal)
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
// Ensure we have valid IDs
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
// For bulk export, ONLY export selected IDs - ignore ALL other filters
// Build URL directly to ensure only 'ids' parameter is sent
const idsParam = ids.join(',');
const exportUrl = `${API_BASE_URL}/v1/planner/keywords/export/?ids=${encodeURIComponent(idsParam)}`;
const response = await fetch(exportUrl, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Export failed: ${response.statusText} - ${errorText}`);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'keywords.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error: any) {
throw error; // Let TablePageTemplate handle toast
}
}, []);
// Bulk status update handler (used by TablePageTemplate modal)
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateKeywordsStatus(numIds, status);
await loadKeywords(); // Reload data after status update
} catch (error: any) {
throw error; // Let TablePageTemplate handle toast
}
}, [loadKeywords]);
// Create page config using factory function - all config comes from keywords.config.tsx
const pageConfig = useMemo(() => {
return createKeywordsPageConfig({
clusters,
activeSector,
availableSeedKeywords,
formData,
setFormData,
// Filter state handlers
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
intentFilter,
setIntentFilter,
difficultyFilter,
setDifficultyFilter,
clusterFilter,
setClusterFilter,
volumeMin,
volumeMax,
setVolumeMin,
setVolumeMax,
isVolumeDropdownOpen,
setIsVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
setTempVolumeMin,
setTempVolumeMax,
volumeButtonRef,
volumeDropdownRef,
setCurrentPage,
loadKeywords,
});
}, [
clusters,
activeSector,
availableSeedKeywords,
formData,
searchTerm,
statusFilter,
intentFilter,
difficultyFilter,
clusterFilter,
volumeMin,
volumeMax,
isVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
loadKeywords,
activeSite,
]);
// Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php)
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ keywords, totalCount, clusters }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
// Handle create/edit
const handleSave = async () => {
try {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
if (isEditMode && editingKeyword) {
await updateKeyword(editingKeyword.id, formData);
toast.success('Keyword updated successfully');
} else {
// For new keywords, add site_id and sector_id
// Use active sector from store
if (!activeSector) {
toast.error('Please select a sector for this site first');
return;
}
if (!formData.seed_keyword_id) {
toast.error('Please select a seed keyword');
return;
}
const sectorId = activeSector.id;
const keywordData: any = {
...formData,
site_id: activeSite.id,
sector_id: sectorId,
};
await createKeyword(keywordData);
toast.success('Keyword attached successfully');
}
setIsModalOpen(false);
resetForm();
loadKeywords();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
// Handle edit - populate form with existing keyword data
const handleEdit = useCallback((keyword: Keyword) => {
setEditingKeyword(keyword);
setIsEditMode(true);
setFormData({
seed_keyword_id: keyword.seed_keyword_id,
volume_override: keyword.volume_override || null,
difficulty_override: keyword.difficulty_override || null,
cluster_id: keyword.cluster_id,
status: keyword.status,
});
setIsModalOpen(true);
}, []);
return (
<>
<TablePageTemplate
title="Keywords"
titleIcon={<ListIcon className="text-brand-500 size-5" />}
subtitle="Manage and organize SEO keywords for content planning"
columns={pageConfig.columns}
data={keywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
intent: intentFilter,
difficulty: difficultyFilter,
cluster_id: clusterFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
onFilterChange={(key, value) => {
// Normalize value to string, preserving empty strings
const stringValue = value === null || value === undefined ? '' : String(value);
// Map filter keys to state setters
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
setCurrentPage(1);
} else if (key === 'intent') {
setIntentFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
} else if (key === 'cluster_id') {
setClusterFilter(stringValue);
setCurrentPage(1);
}
// Note: volume filter is handled by custom render, cluster options updated dynamically
}}
onEdit={handleEdit}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Keyword"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteKeyword(id);
loadKeywords();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteKeywords(ids);
loadKeywords();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
getItemDisplayName={(row: Keyword) => row.keyword}
onExport={async () => {
try {
const filterValues = {
search: searchTerm,
status: statusFilter,
cluster_id: clusterFilter,
intent: intentFilter,
difficulty: difficultyFilter,
};
await handleExport('csv', filterValues);
toast.success('Export successful', 'Keywords exported successfully.');
} catch (error: any) {
toast.error('Export failed', error.message);
}
}}
onExportIcon={<DownloadIcon />}
onImport={handleImportClick}
onImportIcon={<ArrowUpIcon />}
selectionLabel="keyword"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: (page: number) => {
setCurrentPage(page);
},
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setIntentFilter('');
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
setTempVolumeMin('');
setTempVolumeMax('');
setIsVolumeDropdownOpen(false);
setCurrentPage(1);
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Keyword' : 'Add Keyword'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
{/* Import Modal */}
<ImportModal />
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
progressModal.closeModal();
// Reload once when modal closes if task was completed
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) {
hasReloadedRef.current = true;
loadKeywords();
}
}}
/>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Mapping() {
return (
<>
<PageMeta title="Content Mapping - IGNY8" description="Keyword to content mapping" />
<ComponentCard title="Coming Soon" desc="Keyword to content mapping">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Content Mapping - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Map keywords and clusters to existing pages and content
</p>
</div>
</ComponentCard>
</>
);
}