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,95 @@
/**
* Keyword Form Configuration
* Field definitions for Add/Edit Keyword modal
*/
import { FormField } from '../../components/common/FormModal';
export interface KeywordFormConfig {
fields: FormField[];
}
/**
* Get keyword form fields configuration
* Can be customized with dynamic options (e.g., clusters)
*/
export const getKeywordFormConfig = (options?: {
clusters?: Array<{ id: number; name: string }>;
}): FormField[] => {
const clusterOptions = options?.clusters
? [
{ value: '', label: 'No Cluster' },
...options.clusters.map((c) => ({
value: c.id.toString(),
label: c.name,
})),
]
: [{ value: '', label: 'No Cluster' }];
return [
{
key: 'keyword',
label: 'Keyword',
type: 'text',
placeholder: 'Enter keyword',
value: '',
onChange: () => {},
required: true,
},
{
key: 'volume',
label: 'Volume',
type: 'number',
value: 0,
onChange: () => {},
required: false,
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'number',
value: 0,
onChange: () => {},
min: 1,
max: 5,
required: false,
},
{
key: 'intent',
label: 'Intent',
type: 'select',
value: 'informational',
onChange: () => {},
options: [
{ value: 'informational', label: 'Informational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'commercial', label: 'Commercial' },
],
required: false,
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
value: '',
onChange: () => {},
options: clusterOptions,
required: false,
},
{
key: 'status',
label: 'Status',
type: 'select',
value: 'pending',
onChange: () => {},
options: [
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
required: false,
},
];
};

View File

@@ -0,0 +1,217 @@
/**
* Global Import/Export Configuration System
* Provides reusable import/export configs for table pages
*
* Usage:
* ```typescript
* import { useImportExport } from '@/config/import-export.config';
*
* const { handleExport, handleImport, ImportModal } = useImportExport({
* exportEndpoint: '/v1/planner/keywords/export/',
* importEndpoint: '/v1/planner/keywords/import_keywords/',
* filename: 'keywords',
* formats: ['csv', 'json'],
* onImportSuccess: () => loadData(),
* });
* ```
*/
import React, { useState, useCallback } from 'react';
import { exportTableData, importTableData, ExportConfig, ImportConfig } from '../utils/table-import-export';
import { Modal } from '../components/ui/modal';
import Button from '../components/ui/button/Button';
import FileInput from '../components/form/input/FileInput';
import Label from '../components/form/Label';
export interface ImportExportConfigOptions {
exportEndpoint: string;
importEndpoint: string;
filename: string;
formats?: ('csv' | 'json')[];
acceptedFormats?: string[];
maxFileSize?: number;
importQueryParams?: Record<string, any>; // Query params for import (e.g., site_id, sector_id)
onImportSuccess?: (result: any) => void;
onExportSuccess?: () => void;
onError?: (error: Error) => void;
}
export interface ImportExportHandlers {
handleExport: (format?: 'csv' | 'json', filters?: Record<string, any>) => Promise<void>;
handleImportClick: () => void;
ImportModal: React.FC;
}
/**
* React hook for import/export functionality
*/
export function useImportExport(
options: ImportExportConfigOptions
): ImportExportHandlers {
const {
exportEndpoint,
importEndpoint,
filename,
formats = ['csv'],
acceptedFormats = ['.csv'],
maxFileSize = 5 * 1024 * 1024, // 5MB default
onImportSuccess,
onExportSuccess,
onError,
} = options;
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const handleExport = useCallback(async (
format: 'csv' | 'json' = 'csv',
filters: Record<string, any> = {}
) => {
setIsProcessing(true);
try {
const exportConfig: ExportConfig = {
endpoint: exportEndpoint,
filename,
format,
filters,
};
await exportTableData(
exportConfig,
(progress) => console.log(progress),
(error) => {
onError?.(error);
throw error;
}
);
onExportSuccess?.();
} catch (error) {
const err = error instanceof Error ? error : new Error('Export failed');
onError?.(err);
throw err;
} finally {
setIsProcessing(false);
}
}, [exportEndpoint, filename, onError, onExportSuccess]);
const handleImport = useCallback(async (file: File) => {
setIsProcessing(true);
try {
const importConfig: ImportConfig = {
endpoint: importEndpoint,
acceptedFormats,
maxFileSize,
queryParams: options.importQueryParams,
onSuccess: (result) => {
onImportSuccess?.(result);
},
};
const result = await importTableData(
file,
importConfig,
(progress) => console.log(progress),
(error) => {
onError?.(error);
throw error;
}
);
setIsImportModalOpen(false);
return result;
} catch (error) {
const err = error instanceof Error ? error : new Error('Import failed');
onError?.(err);
throw err;
} finally {
setIsProcessing(false);
}
}, [importEndpoint, acceptedFormats, maxFileSize, onImportSuccess, onError]);
const handleImportFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await handleImport(file);
// Reset file input
e.target.value = '';
}, [handleImport]);
const ImportModalComponent: React.FC = () => (
<Modal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
className="max-w-md"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-6 text-gray-800 dark:text-white">
Import {filename.charAt(0).toUpperCase() + filename.slice(1)}
</h2>
<div className="space-y-4">
<div>
<Label>CSV File</Label>
<FileInput
onChange={handleImportFileChange}
accept={acceptedFormats.join(',')}
disabled={isProcessing}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Upload a CSV file (max {maxFileSize / 1024 / 1024}MB)
</p>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button
variant="outline"
onClick={() => setIsImportModalOpen(false)}
disabled={isProcessing}
>
Close
</Button>
</div>
</div>
</div>
</Modal>
);
return {
handleExport,
handleImportClick: () => setIsImportModalOpen(true),
ImportModal: ImportModalComponent,
};
}
/**
* Pre-configured import/export hooks for common modules
*/
export const useKeywordsImportExport = (
onImportSuccess?: () => void,
onError?: (error: Error) => void,
importQueryParams?: Record<string, any>
) => {
return useImportExport({
exportEndpoint: '/v1/planner/keywords/export/',
importEndpoint: '/v1/planner/keywords/import_keywords/',
filename: 'keywords',
formats: ['csv', 'json'],
acceptedFormats: ['.csv'],
maxFileSize: 5 * 1024 * 1024,
importQueryParams,
onImportSuccess,
onError,
});
};
export const useClustersImportExport = (onImportSuccess?: () => void, onError?: (error: Error) => void) => {
return useImportExport({
exportEndpoint: '/v1/planner/clusters/export/',
importEndpoint: '/v1/planner/clusters/import_clusters/',
filename: 'clusters',
formats: ['csv', 'json'],
acceptedFormats: ['.csv'],
maxFileSize: 5 * 1024 * 1024,
onImportSuccess,
onError,
});
};

View File

@@ -0,0 +1,147 @@
/**
* Bulk Action Modal Configuration
* Dynamic bulk action confirmation modal content based on current page/table type
* Used for: Export Selected, Update Status, and other bulk operations
*/
export interface BulkActionModalConfig {
// Export modal config
export: {
title: string; // e.g., "Export Selected Keywords"
message: (count: number) => string; // Message for export confirmation
confirmText: string; // Button text, e.g., "Export"
itemNamePlural: string; // e.g., "keywords"
};
// Update Status modal config
updateStatus: {
title: string; // e.g., "Update Status"
message: (count: number) => string; // Message for status update confirmation
confirmText: string; // Button text, e.g., "Update Status"
itemNamePlural: string; // e.g., "keywords"
statusOptions: Array<{ value: string; label: string }>; // Available status options
};
}
export const bulkActionModalConfigs: Record<string, BulkActionModalConfig> = {
'/planner/keywords': {
export: {
title: 'Export Selected Keywords',
message: (count: number) => `You are about to export ${count} selected keyword${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'keywords',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected keyword${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'keywords',
statusOptions: [
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'archived', label: 'Archived' },
],
},
},
'/planner/clusters': {
export: {
title: 'Export Selected Clusters',
message: (count: number) => `You are about to export ${count} selected cluster${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'clusters',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected cluster${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'clusters',
statusOptions: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
],
},
},
'/planner/ideas': {
export: {
title: 'Export Selected Ideas',
message: (count: number) => `You are about to export ${count} selected idea${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'ideas',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected idea${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'ideas',
statusOptions: [
{ value: 'new', label: 'New' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'published', label: 'Published' },
],
},
},
'/writer/tasks': {
export: {
title: 'Export Selected Tasks',
message: (count: number) => `You are about to export ${count} selected task${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'tasks',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected task${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'tasks',
statusOptions: [
{ value: 'queued', label: 'Queued' },
{ value: 'draft', label: 'Draft' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'completed', label: 'Completed' },
],
},
},
'/writer/drafts': {
export: {
title: 'Export Selected Drafts',
message: (count: number) => `You are about to export ${count} selected draft${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'drafts',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected draft${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'drafts',
statusOptions: [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'published', label: 'Published' },
],
},
},
'/writer/published': {
export: {
title: 'Export Selected Published Content',
message: (count: number) => `You are about to export ${count} selected published content item${count !== 1 ? 's' : ''}. The export will be downloaded as a CSV file.`,
confirmText: 'Export',
itemNamePlural: 'published content items',
},
updateStatus: {
title: 'Update Status',
message: (count: number) => `You are about to update the status of ${count} selected published content item${count !== 1 ? 's' : ''}. Select the new status below.`,
confirmText: 'Update Status',
itemNamePlural: 'published content items',
statusOptions: [
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
],
},
},
};
/**
* Get bulk action modal config for a given route
*/
export function getBulkActionModalConfig(route: string): BulkActionModalConfig | null {
return bulkActionModalConfigs[route] || null;
}

View File

@@ -0,0 +1,410 @@
/**
* Clusters Page Configuration
* Centralized config for Clusters page table, filters, and actions
*/
import React from 'react';
import {
titleColumn,
sectorColumn,
difficultyColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { getDifficultyNumber } from '../../utils/difficulty';
import { Cluster } from '../../services/api';
import Input from '../../components/form/input/InputField';
import Label from '../../components/form/Label';
import Button from '../../components/ui/button/Button';
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
}
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'select' | 'textarea';
placeholder?: string;
required?: boolean;
value: any;
onChange: (value: any) => void;
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select' | 'custom';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
customRender?: () => React.ReactNode;
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { clusters: any[]; totalCount: number }) => number;
}
export interface ClustersPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
formFields: () => FormFieldConfig[];
headerMetrics: HeaderMetricConfig[];
}
export const createClustersPageConfig = (
handlers: {
activeSector: { id: number; name: string } | null;
formData: {
name: string;
description?: string | null;
status: string;
};
setFormData: React.Dispatch<React.SetStateAction<any>>;
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
difficultyFilter: string;
setDifficultyFilter: (value: string) => void;
volumeMin: number | '';
volumeMax: number | '';
setVolumeMin: (value: number | '') => void;
setVolumeMax: (value: number | '') => void;
isVolumeDropdownOpen: boolean;
setIsVolumeDropdownOpen: (value: boolean) => void;
tempVolumeMin: number | '';
tempVolumeMax: number | '';
setTempVolumeMin: (value: number | '') => void;
setTempVolumeMax: (value: number | '') => void;
volumeButtonRef: React.RefObject<HTMLButtonElement | null>;
volumeDropdownRef: React.RefObject<HTMLDivElement | null>;
setCurrentPage: (page: number) => void;
loadClusters: () => Promise<void>;
}
): ClustersPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
return {
columns: [
{
...titleColumn,
key: 'name',
label: 'Cluster Name',
sortable: true,
sortField: 'name',
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
...sectorColumn,
render: (value: string, row: Cluster) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'keywords_count',
label: 'Keywords',
sortable: false,
width: '120px',
render: (value: number) => value.toLocaleString(),
},
{
key: 'volume',
label: 'Volume',
sortable: false,
width: '120px',
render: (value: number) => value.toLocaleString(),
},
{
...difficultyColumn,
key: 'difficulty',
label: 'Difficulty',
sortable: false,
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: 'mapped_pages',
label: 'Mapped Pages',
sortable: false,
width: '120px',
render: (value: number) => value.toLocaleString(),
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => (
<Badge
color={value === 'active' ? 'success' : 'warning'}
size="sm"
>
{value}
</Badge>
),
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search clusters...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
},
{
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' },
],
},
{
key: 'volume',
label: 'Volume Range',
type: 'custom',
customRender: () => (
<div className="relative flex-1 min-w-[140px]">
<button
ref={handlers.volumeButtonRef}
type="button"
onClick={() => {
handlers.setIsVolumeDropdownOpen(!handlers.isVolumeDropdownOpen);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
handlers.volumeMin || handlers.volumeMax
? "text-gray-800 dark:text-white/90"
: "text-gray-400 dark:text-gray-400"
} ${
handlers.isVolumeDropdownOpen
? "border-brand-300 ring-3 ring-brand-500/10 dark:border-brand-800"
: ""
}`}
>
<span className="block text-left truncate">
{handlers.volumeMin || handlers.volumeMax
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
: 'Volume Range'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg
className="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</button>
{/* Dropdown Menu */}
{handlers.isVolumeDropdownOpen && (
<div
ref={handlers.volumeDropdownRef}
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden p-4 min-w-[280px]"
>
<div className="space-y-3">
<div>
<Label htmlFor="vol-min" className="text-xs mb-1">Min Volume</Label>
<Input
id="vol-min"
type="number"
placeholder="Min"
value={handlers.tempVolumeMin}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMin(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div>
<Label htmlFor="vol-max" className="text-xs mb-1">Max Volume</Label>
<Input
id="vol-max"
type="number"
placeholder="Max"
value={handlers.tempVolumeMax}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMax(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="primary"
onClick={async () => {
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
handlers.setIsVolumeDropdownOpen(false);
handlers.setVolumeMin(newMin);
handlers.setVolumeMax(newMax);
handlers.setCurrentPage(1);
setTimeout(() => {
handlers.loadClusters();
}, 0);
}}
className="flex-1"
>
OK
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
handlers.setIsVolumeDropdownOpen(false);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
),
},
],
formFields: () => [
{
key: 'name',
label: 'Cluster Name',
type: 'text',
placeholder: 'Enter cluster name',
required: true,
value: handlers.formData.name || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, name: value }),
},
{
key: 'description',
label: 'Description',
type: 'textarea',
placeholder: 'Enter cluster description',
value: handlers.formData.description || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, description: value }),
},
{
key: 'status',
label: 'Status',
type: 'select',
value: handlers.formData.status || 'active',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, status: value }),
options: [
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
},
],
headerMetrics: [
{
label: 'Total Clusters',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'Active',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.clusters.filter((c: Cluster) => c.status === 'active').length,
},
{
label: 'Total Keywords',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.keywords_count || 0), 0),
},
{
label: 'Total Volume',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.clusters.reduce((sum: number, c: Cluster) => sum + (c.volume || 0), 0),
},
],
};
};

View File

@@ -0,0 +1,65 @@
/**
* Delete Modal Configuration
* Dynamic delete confirmation modal content based on current page/table type
*/
export interface DeleteModalConfig {
title: string; // e.g., "Delete Keywords" or "Delete Cluster"
singleItemMessage: string; // Message for single item deletion
multipleItemsMessage: (count: number) => string; // Message for multiple items (receives count)
itemNameSingular: string; // e.g., "keyword", "cluster"
itemNamePlural: string; // e.g., "keywords", "clusters"
}
export const deleteModalConfigs: Record<string, DeleteModalConfig> = {
'/planner/keywords': {
title: 'Delete Keywords',
singleItemMessage: 'You are about to delete this keyword. This action cannot be undone.',
multipleItemsMessage: (count: number) => `You are deleting ${count} keywords. This action cannot be undone.`,
itemNameSingular: 'keyword',
itemNamePlural: 'keywords',
},
'/planner/clusters': {
title: 'Delete Clusters',
singleItemMessage: 'You are about to delete this cluster. All associated keywords will remain but will no longer be grouped under this cluster.',
multipleItemsMessage: (count: number) => `You are deleting ${count} clusters. All associated keywords will remain but will no longer be grouped under these clusters.`,
itemNameSingular: 'cluster',
itemNamePlural: 'clusters',
},
'/planner/ideas': {
title: 'Delete Ideas',
singleItemMessage: 'You are about to delete this idea. This action cannot be undone.',
multipleItemsMessage: (count: number) => `You are deleting ${count} ideas. This action cannot be undone.`,
itemNameSingular: 'idea',
itemNamePlural: 'ideas',
},
'/writer/tasks': {
title: 'Delete Tasks',
singleItemMessage: 'You are about to delete this task. This action cannot be undone.',
multipleItemsMessage: (count: number) => `You are deleting ${count} tasks. This action cannot be undone.`,
itemNameSingular: 'task',
itemNamePlural: 'tasks',
},
'/writer/drafts': {
title: 'Delete Drafts',
singleItemMessage: 'You are about to delete this draft. This action cannot be undone.',
multipleItemsMessage: (count: number) => `You are deleting ${count} drafts. This action cannot be undone.`,
itemNameSingular: 'draft',
itemNamePlural: 'drafts',
},
'/writer/published': {
title: 'Delete Published Content',
singleItemMessage: 'You are about to delete this published content. This action cannot be undone.',
multipleItemsMessage: (count: number) => `You are deleting ${count} published content items. This action cannot be undone.`,
itemNameSingular: 'published content',
itemNamePlural: 'published content items',
},
};
/**
* Get delete modal config for a given route
*/
export function getDeleteModalConfig(route: string): DeleteModalConfig | null {
return deleteModalConfigs[route] || null;
}

View File

@@ -0,0 +1,369 @@
/**
* Ideas Page Configuration
* Centralized config for Ideas page table, filters, and actions
*/
import React from 'react';
import {
titleColumn,
sectorColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { ContentIdea, Cluster } from '../../services/api';
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
}
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'select' | 'textarea';
placeholder?: string;
required?: boolean;
value: any;
onChange: (value: any) => void;
options?: Array<{ value: string; label: string }>;
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: string;
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { ideas: any[]; totalCount: number }) => number;
}
export interface IdeasPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
formFields: (clusters: Array<{ id: number; name: string }>) => FormFieldConfig[];
headerMetrics: HeaderMetricConfig[];
}
export const createIdeasPageConfig = (
handlers: {
clusters: Array<{ id: number; name: string }>;
activeSector: { id: number; name: string } | null;
formData: {
idea_title: string;
description?: string | null;
content_structure: string;
content_type: string;
target_keywords?: string | null;
keyword_cluster_id?: number | null;
status: string;
estimated_word_count?: number;
};
setFormData: React.Dispatch<React.SetStateAction<any>>;
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
clusterFilter: string;
setClusterFilter: (value: string) => void;
structureFilter: string;
setStructureFilter: (value: string) => void;
typeFilter: string;
setTypeFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
}
): IdeasPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
return {
columns: [
{
...titleColumn,
key: 'idea_title',
label: 'Title',
sortable: true,
sortField: 'idea_title',
toggleable: true, // Enable toggle for this column
toggleContentKey: 'description', // Use description field for toggle content
toggleContentLabel: 'Content Outline', // Label for expanded content
render: (value: string) => (
<span className="text-gray-800 dark:text-white font-medium">{value}</span>
),
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
...sectorColumn,
render: (value: string, row: ContentIdea) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'content_structure',
label: 'Structure',
sortable: true,
sortField: 'content_structure',
width: '150px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
key: 'content_type',
label: 'Type',
sortable: true,
sortField: 'content_type',
width: '120px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
key: 'target_keywords',
label: 'Target Keywords',
sortable: false,
width: '250px',
render: (value: string) => (
<span className="text-sm text-gray-600 dark:text-gray-400 truncate block max-w-[250px]">
{value || '-'}
</span>
),
},
{
key: 'keyword_cluster_name',
label: 'Cluster',
sortable: false,
width: '200px',
render: (_value: string, row: ContentIdea) => row.keyword_cluster_name || '-',
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
const statusColors: Record<string, 'success' | 'warning' | 'error'> = {
'new': 'warning',
'scheduled': 'info',
'published': 'success',
};
return (
<Badge
color={statusColors[value] || 'warning'}
size="sm"
>
{value}
</Badge>
);
},
},
{
key: 'estimated_word_count',
label: 'Words',
sortable: true,
sortField: 'estimated_word_count',
width: '100px',
render: (value: number) => value.toLocaleString(),
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search ideas...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'new', label: 'New' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'published', label: 'Published' },
],
},
{
key: 'content_structure',
label: 'Structure',
type: 'select',
options: [
{ value: '', label: 'All Structures' },
{ value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'pillar_page', label: 'Pillar Page' },
{ value: 'supporting_page', label: 'Supporting Page' },
],
},
{
key: 'content_type',
label: 'Type',
type: 'select',
options: [
{ value: '', label: 'All Types' },
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'tutorial', label: 'Tutorial' },
],
},
{
key: 'keyword_cluster_id',
label: 'Cluster',
type: 'select',
options: (() => {
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
dynamicOptions: 'clusters',
},
],
formFields: (clusters: Array<{ id: number; name: string }>) => [
{
key: 'idea_title',
label: 'Title',
type: 'text',
placeholder: 'Enter idea title',
required: true,
value: handlers.formData.idea_title || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, idea_title: value }),
},
{
key: 'description',
label: 'Description',
type: 'textarea',
placeholder: 'Enter description',
value: handlers.formData.description || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, description: value }),
},
{
key: 'content_structure',
label: 'Content Structure',
type: 'select',
value: handlers.formData.content_structure || 'blog_post',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, content_structure: value }),
options: [
{ value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'pillar_page', label: 'Pillar Page' },
{ value: 'supporting_page', label: 'Supporting Page' },
],
},
{
key: 'content_type',
label: 'Content Type',
type: 'select',
value: handlers.formData.content_type || 'blog_post',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, content_type: value }),
options: [
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'tutorial', label: 'Tutorial' },
],
},
{
key: 'target_keywords',
label: 'Target Keywords',
type: 'text',
placeholder: 'Enter keywords (comma-separated)',
value: handlers.formData.target_keywords || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, target_keywords: value }),
},
{
key: 'keyword_cluster_id',
label: 'Cluster',
type: 'select',
value: handlers.formData.keyword_cluster_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({
...handlers.formData,
keyword_cluster_id: value ? parseInt(value) : null,
}),
options: [
{ value: '', label: 'No Cluster' },
...clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
],
},
{
key: 'estimated_word_count',
label: 'Estimated Word Count',
type: 'number',
value: handlers.formData.estimated_word_count || 1000,
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, estimated_word_count: value ? parseInt(value) : 1000 }),
},
{
key: 'status',
label: 'Status',
type: 'select',
value: handlers.formData.status || 'new',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, status: value }),
options: [
{ value: 'new', label: 'New' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'published', label: 'Published' },
],
},
],
headerMetrics: [
{
label: 'Total Ideas',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'New',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'new').length,
},
{
label: 'Scheduled',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'scheduled').length,
},
{
label: 'Published',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'published').length,
},
],
};
};

View File

@@ -0,0 +1,194 @@
/**
* Images Page Configuration
* Centralized config for Images page table, filters, and actions
*/
import React from 'react';
import {
titleColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { TaskImage } from '../../services/api';
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { images: any[]; totalCount: number }) => number;
}
export interface ImagesPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
headerMetrics: HeaderMetricConfig[];
}
export const createImagesPageConfig = (
handlers: {
searchTerm: string;
setSearchTerm: (value: string) => void;
imageTypeFilter: string;
setImageTypeFilter: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
}
): ImagesPageConfig => {
return {
columns: [
{
key: 'task_title',
label: 'Task',
sortable: false,
width: '250px',
render: (_value: string, row: TaskImage) => (
<span className="font-medium text-gray-800 dark:text-white/90">
{row.task_title || '-'}
</span>
),
},
{
key: 'image_type',
label: 'Image Type',
sortable: false,
width: '150px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
key: 'image_url',
label: 'Image',
sortable: false,
width: '200px',
render: (value: string) => {
if (!value) return <span className="text-gray-400">-</span>;
return (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:text-brand-600 text-sm truncate block max-w-[200px]"
>
View Image
</a>
);
},
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
const statusColors: Record<string, 'success' | 'warning' | 'error'> = {
'pending': 'warning',
'generated': 'success',
'failed': 'error',
};
return (
<Badge
color={statusColors[value] || 'warning'}
size="sm"
>
{value}
</Badge>
);
},
},
{
key: 'position',
label: 'Position',
sortable: false,
width: '100px',
render: (value: number) => value || 0,
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by task title...',
},
{
key: 'image_type',
label: 'Image Type',
type: 'select',
options: [
{ value: '', label: 'All Types' },
{ value: 'featured', label: 'Featured Image' },
{ value: 'desktop', label: 'Desktop Image' },
{ value: 'mobile', label: 'Mobile Image' },
{ value: 'in_article', label: 'In-Article Image' },
],
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'pending', label: 'Pending' },
{ value: 'generated', label: 'Generated' },
{ value: 'failed', label: 'Failed' },
],
},
],
headerMetrics: [
{
label: 'Total Images',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'Generated',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'generated').length,
},
{
label: 'Pending',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'pending').length,
},
{
label: 'Failed',
value: 0,
accentColor: 'error' as const,
calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'failed').length,
},
],
};
};

View File

@@ -0,0 +1,551 @@
/**
* Keywords Page Configuration
* Centralized config for Keywords page table, filters, and actions
*
* This config is fully dynamic - all columns, filters, actions, and form fields
* are defined here instead of inline in the component.
*/
import React from 'react';
import {
keywordColumn,
volumeColumn,
difficultyColumn,
intentColumn,
clusterColumn,
sectorColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
// Icons removed - bulkActions and rowActions are now in table-actions.config.tsx
import Badge from '../../components/ui/badge/Badge';
import { getDifficultyNumber, getDifficultyOptions, getDifficultyValueFromNumber } from '../../utils/difficulty';
import { formatRelativeDate } from '../../utils/date';
import { Keyword } from '../../services/api';
import Input from '../../components/form/input/InputField';
import Label from '../../components/form/Label';
import Button from '../../components/ui/button/Button';
// SelectDropdown not used directly in config - removed
// Type definitions
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
}
// BulkActionConfig and RowActionConfig are now in table-actions.config.tsx
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'select';
placeholder?: string;
required?: boolean;
min?: number;
max?: number;
className?: string;
options?: Array<{ value: string; label: string }>;
value: any; // Required for FormModal
onChange: (value: any) => void; // Required for FormModal
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select' | 'daterange' | 'range' | 'custom';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
min?: number;
max?: number;
step?: number;
className?: string;
customRender?: () => React.ReactNode; // For complex custom filters like volume range
dynamicOptions?: string; // e.g., 'clusters' - flag for dynamic option loading
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { keywords: any[]; totalCount: number; clusters: any[] }) => number;
}
export interface KeywordsPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
// bulkActions and rowActions are now global - defined in table-actions.config.tsx
formFields: (clusters: Array<{ id: number; name: string }>) => FormFieldConfig[];
headerMetrics: HeaderMetricConfig[];
exportConfig: {
endpoint: string;
filename: string;
formats: Array<'csv' | 'json'>;
};
importConfig: {
endpoint: string;
acceptedFormats: string[];
maxFileSize: number;
};
}
/**
* Factory function to create keywords page config
* Accepts handlers/closures from component for actions that need them
*/
export const createKeywordsPageConfig = (
handlers: {
clusters: Array<{ id: number; name: string }>;
activeSector: { id: number; name: string } | null;
availableSeedKeywords: Array<{ id: number; keyword: string; volume: number; difficulty: number; intent: string }>;
formData: {
seed_keyword_id: number;
volume_override?: number | null;
difficulty_override?: number | null;
cluster_id?: number | null;
status: string;
};
setFormData: React.Dispatch<React.SetStateAction<any>>;
// Filter state handlers
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
intentFilter: string;
setIntentFilter: (value: string) => void;
difficultyFilter: string;
setDifficultyFilter: (value: string) => void;
clusterFilter: string;
setClusterFilter: (value: string) => void;
volumeMin: number | '';
volumeMax: number | '';
setVolumeMin: (value: number | '') => void;
setVolumeMax: (value: number | '') => void;
isVolumeDropdownOpen: boolean;
setIsVolumeDropdownOpen: (value: boolean) => void;
tempVolumeMin: number | '';
tempVolumeMax: number | '';
setTempVolumeMin: (value: number | '') => void;
setTempVolumeMax: (value: number | '') => void;
volumeButtonRef: React.RefObject<HTMLButtonElement | null>;
volumeDropdownRef: React.RefObject<HTMLDivElement | null>;
setCurrentPage: (page: number) => void;
loadKeywords: () => Promise<void>;
}
): KeywordsPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
return {
columns: [
{
...keywordColumn,
sortable: true,
sortField: 'keyword',
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
...sectorColumn,
render: (value: string, row: Keyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
...volumeColumn,
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
...clusterColumn,
sortable: false,
render: (_value: string, row: Keyword) => row.cluster_name || '-',
},
{
...difficultyColumn,
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
);
},
},
{
...intentColumn,
sortable: true,
sortField: 'intent',
render: (value: string) => {
// Map intent values to badge colors
// Transactional and Commercial → success (green, like active)
// Navigational → warning (amber/yellow, like pending)
// Informational → info (blue)
const getIntentColor = (intent: string) => {
const lowerIntent = intent?.toLowerCase() || '';
if (lowerIntent === 'transactional' || lowerIntent === 'commercial') {
return 'success'; // Green, like active status
} else if (lowerIntent === 'navigational') {
return 'warning'; // Amber/yellow, like pending status
}
return 'info'; // Blue for informational or default
};
return (
<Badge
color={getIntentColor(value)}
size="sm"
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
>
{value}
</Badge>
);
},
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
return (
<Badge
color={
value === 'active'
? 'success'
: value === 'pending'
? 'warning'
: 'error'
}
size="sm"
>
{value}
</Badge>
);
},
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'archived', label: 'Archived' },
],
},
{
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' },
],
},
{
key: 'volume',
label: 'Volume Range',
type: 'custom',
customRender: () => (
<div className="relative flex-1 min-w-[140px]">
<button
ref={handlers.volumeButtonRef}
type="button"
onClick={() => {
handlers.setIsVolumeDropdownOpen(!handlers.isVolumeDropdownOpen);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className={`igny8-select-styled h-9 w-full appearance-none rounded-lg border border-gray-300 bg-transparent px-3 py-2 pr-10 text-sm shadow-theme-xs focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-800 ${
handlers.volumeMin || handlers.volumeMax
? "text-gray-800 dark:text-white/90"
: "text-gray-400 dark:text-gray-400"
} ${
handlers.isVolumeDropdownOpen
? "border-brand-300 ring-3 ring-brand-500/10 dark:border-brand-800"
: ""
}`}
>
<span className="block text-left truncate">
{handlers.volumeMin || handlers.volumeMax
? `Vol: ${handlers.volumeMin || 'Min'} - ${handlers.volumeMax || 'Max'}`
: 'Volume Range'}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg
className="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</button>
{/* Dropdown Menu */}
{handlers.isVolumeDropdownOpen && (
<div
ref={handlers.volumeDropdownRef}
className="absolute z-50 left-0 right-0 mt-1 rounded-lg border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark overflow-hidden p-4 min-w-[280px]"
>
<div className="space-y-3">
<div>
<Label htmlFor="vol-min" className="text-xs mb-1">Min Volume</Label>
<Input
id="vol-min"
type="number"
placeholder="Min"
value={handlers.tempVolumeMin}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMin(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div>
<Label htmlFor="vol-max" className="text-xs mb-1">Max Volume</Label>
<Input
id="vol-max"
type="number"
placeholder="Max"
value={handlers.tempVolumeMax}
onChange={(e) => {
const val = e.target.value;
handlers.setTempVolumeMax(val === '' ? '' : parseInt(val) || '');
}}
className="w-full h-9"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="primary"
onClick={async () => {
const newMin = handlers.tempVolumeMin === '' ? '' : Number(handlers.tempVolumeMin);
const newMax = handlers.tempVolumeMax === '' ? '' : Number(handlers.tempVolumeMax);
handlers.setIsVolumeDropdownOpen(false);
handlers.setVolumeMin(newMin);
handlers.setVolumeMax(newMax);
handlers.setCurrentPage(1);
setTimeout(() => {
handlers.loadKeywords();
}, 0);
}}
className="flex-1"
>
OK
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
handlers.setIsVolumeDropdownOpen(false);
handlers.setTempVolumeMin(handlers.volumeMin);
handlers.setTempVolumeMax(handlers.volumeMax);
}}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
),
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
options: (() => {
// Dynamically generate options from current clusters
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
className: 'w-40',
},
],
headerMetrics: [
{
label: 'Total Keywords',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'Mapped',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => k.cluster_id).length,
},
{
label: 'Unmapped',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.keywords.filter((k: Keyword) => !k.cluster_id).length,
},
{
label: 'Total Volume',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.keywords.reduce((sum: number, k: Keyword) => sum + (k.volume || 0), 0),
},
],
// bulkActions and rowActions are now global - defined in table-actions.config.ts
// They're automatically loaded by TablePageTemplate based on the current route
formFields: (clusters: Array<{ id: number; name: string }>) => [
{
key: 'seed_keyword_id',
label: 'Seed Keyword',
type: 'select',
placeholder: 'Select a seed keyword',
value: handlers.formData.seed_keyword_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }),
required: true,
options: [
{ value: '', label: 'Select a keyword...' },
...handlers.availableSeedKeywords.map((sk) => ({
value: sk.id.toString(),
label: `${sk.keyword} (Vol: ${sk.volume.toLocaleString()}, Diff: ${sk.difficulty}, ${sk.intent})`,
})),
],
},
{
key: 'volume_override',
label: 'Volume Override (optional)',
type: 'number',
placeholder: 'Leave empty to use seed keyword volume',
value: handlers.formData.volume_override ?? '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, volume_override: value ? parseInt(value) : null }),
},
{
key: 'difficulty_override',
label: 'Difficulty Override (optional)',
type: 'number',
placeholder: 'Leave empty to use seed keyword difficulty',
value: handlers.formData.difficulty_override ?? '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, difficulty_override: value ? parseInt(value) : null }),
min: 0,
max: 100,
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
value: handlers.formData.cluster_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({
...handlers.formData,
cluster_id: value ? parseInt(value) : null,
}),
options: [
{ value: '', label: 'No Cluster' },
...clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
],
},
{
key: 'status',
label: 'Status',
type: 'select',
value: handlers.formData.status || 'pending',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, status: value }),
options: [
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
],
},
],
exportConfig: {
endpoint: '/v1/planner/keywords/export/',
filename: 'keywords',
formats: ['csv', 'json'],
},
importConfig: {
endpoint: '/v1/planner/keywords/import_keywords/',
acceptedFormats: ['.csv'],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
};
};

View File

@@ -0,0 +1,53 @@
/**
* Page Notifications Configuration
* Dynamic notification content based on current page route
*/
export interface PageNotificationConfig {
variant: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
showLink?: boolean;
linkHref?: string;
linkText?: string;
}
export const pageNotifications: Record<string, PageNotificationConfig> = {
'/planner/keywords': {
variant: 'info',
title: 'Keywords Management',
message: 'Manage and organize your SEO keywords. Use filters to find specific keywords, or bulk actions to update multiple keywords at once.',
showLink: false,
},
'/planner/clusters': {
variant: 'info',
title: 'Cluster Management',
message: 'Organize keywords into clusters for better content planning and strategy.',
showLink: false,
},
'/planner/ideas': {
variant: 'info',
title: 'Content Ideas',
message: 'Generate and manage content ideas based on your keywords and clusters.',
showLink: false,
},
'/writer/tasks': {
variant: 'info',
title: 'Writing Tasks',
message: 'Track and manage your content writing tasks and deadlines.',
showLink: false,
},
'/writer/drafts': {
variant: 'info',
title: 'Drafts',
message: 'Review and edit your content drafts before publishing.',
showLink: false,
},
'/writer/published': {
variant: 'success',
title: 'Published Content',
message: 'View all your published content and track their performance.',
showLink: false,
},
};

View File

@@ -0,0 +1,318 @@
/**
* Global Table Actions Configuration
* Defines bulk actions and row actions for all table-based pages
* Delete action is always present - other actions are page-specific
*/
import { TrashBinIcon, PencilIcon, CheckCircleIcon, DownloadIcon, BoltIcon, ArrowRightIcon } from '../../icons';
import React from 'react';
export interface RowActionConfig {
key: string; // 'edit' | 'delete' | custom
label: string;
icon: React.ReactNode;
variant?: 'primary' | 'danger' | 'secondary' | 'success'; // For styling
}
export interface BulkActionConfig {
key: string; // 'export' | 'update_status' | 'delete' | custom
label: string;
icon: React.ReactNode;
variant?: 'primary' | 'danger' | 'secondary' | 'success'; // For styling
}
export interface TableActionsConfig {
// Single record actions (edit is always first, delete is always last)
rowActions: RowActionConfig[];
// Bulk actions (delete is always last)
bulkActions: BulkActionConfig[];
}
// Global icons
const EditIcon = <PencilIcon className="w-5 h-5" />;
const DeleteIcon = <TrashBinIcon className="w-5 h-5" />;
const ExportIcon = <DownloadIcon className="w-5 h-5" />;
/**
* Get table actions config for a given route
* Delete action is always included automatically (except for keyword-opportunities)
*/
export function getTableActionsConfig(route: string): TableActionsConfig {
const config = tableActionsConfigs[route] || tableActionsConfigs['default'];
// Special handling for keyword-opportunities: no delete/export/edit
if (route === '/planner/keyword-opportunities') {
return {
rowActions: config.rowActions || [],
bulkActions: config.bulkActions || [],
};
}
// Build row actions: Update (Edit), then custom actions, then Export, then Delete
// Always include: Update, Delete, Export
const rowActionsWithoutDefaults = config.rowActions.filter(
a => a.key !== 'edit' && a.key !== 'delete' && a.key !== 'export'
);
// Always include Update (Edit) as first action
const updateAction = config.rowActions.find(a => a.key === 'edit' || a.key === 'update') || {
key: 'edit',
label: 'Update',
icon: EditIcon,
variant: 'primary' as const,
};
// Always include Export before Delete
const exportAction = config.rowActions.find(a => a.key === 'export') || {
key: 'export',
label: 'Export',
icon: ExportIcon,
variant: 'secondary' as const,
};
// Always include Delete as last action (used for both row and bulk actions)
const deleteAction = {
key: 'delete',
label: 'Delete',
icon: DeleteIcon,
variant: 'danger' as const,
};
// Ensure delete is always the last action for bulk actions
const bulkActionsWithoutDelete = config.bulkActions.filter(a => a.key !== 'delete');
const bulkDeleteAction = config.bulkActions.find(a => a.key === 'delete') || deleteAction;
return {
rowActions: [
updateAction, // Update (Edit) always first
...rowActionsWithoutDefaults, // Custom actions from config
exportAction, // Export always before delete
deleteAction, // Delete always last
],
bulkActions: [
...bulkActionsWithoutDelete,
bulkDeleteAction, // Delete always last
],
};
}
// Page-specific action configurations
const tableActionsConfigs: Record<string, TableActionsConfig> = {
'/planner/keyword-opportunities': {
rowActions: [
{
key: 'add_to_workflow',
label: 'Add',
icon: <BoltIcon className="w-5 h-5" />,
variant: 'success',
},
],
bulkActions: [
{
key: 'add_selected_to_workflow',
label: 'Add Selected to Workflow',
icon: <BoltIcon className="w-5 h-5" />,
variant: 'success',
},
],
},
'/planner/keywords': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
{
key: 'auto_cluster',
label: 'Auto-Cluster',
icon: <BoltIcon className="w-4 h-4 text-warning-500" />,
variant: 'secondary',
},
],
},
'/planner/clusters': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
{
key: 'generate_ideas',
label: 'Generate Ideas',
icon: <BoltIcon className="w-5 h-5" />,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
{
key: 'auto_generate_ideas',
label: 'Generate Ideas',
icon: <BoltIcon className="w-4 h-4 text-warning-500" />,
variant: 'secondary',
},
],
},
'/planner/ideas': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
{
key: 'queue_to_writer',
label: 'Queue to Writer',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
{
key: 'queue_to_writer',
label: 'Queue to Writer',
icon: <ArrowRightIcon className="w-4 h-4 text-brand-500" />,
variant: 'secondary',
},
],
},
'/writer/tasks': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
{
key: 'generate_content',
label: 'Generate Content',
icon: <BoltIcon className="w-5 h-5" />,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
// Removed generate_content from bulk actions - only available as row action
],
},
'/writer/drafts': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
{
key: 'generate_images',
label: 'Generate Images',
icon: <BoltIcon className="w-4 h-4 text-purple-500" />,
variant: 'secondary',
},
{
key: 'publish',
label: 'Publish Selected',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
],
},
'/writer/published': {
rowActions: [
{
key: 'edit',
label: 'Edit',
icon: EditIcon,
variant: 'primary',
},
],
bulkActions: [
{
key: 'update_status',
label: 'Update Status',
icon: <CheckCircleIcon className="w-4 h-4 text-success-500" />,
variant: 'secondary',
},
{
key: 'export',
label: 'Export Selected',
icon: <DownloadIcon className="w-4 h-4 text-blue-light-500" />,
variant: 'secondary',
},
],
},
// Default config (fallback)
default: {
rowActions: [],
bulkActions: [],
},
};

View File

@@ -0,0 +1,357 @@
/**
* Tasks Page Configuration
* Centralized config for Tasks page table, filters, and actions
*/
import React from 'react';
import {
titleColumn,
statusColumn,
createdColumn,
wordCountColumn,
sectorColumn,
} from '../snippets/columns.snippets';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { Task, Cluster } from '../../services/api';
export interface ColumnConfig {
key: string;
label: string;
sortable?: boolean;
sortField?: string;
align?: 'left' | 'center' | 'right';
width?: string;
render?: (value: any, row: any) => React.ReactNode;
toggleable?: boolean; // If true, this column will have a toggle button for expanding content
toggleContentKey?: string; // Key of the field containing content to display when toggled
toggleContentLabel?: string; // Label for the expanded content (e.g., "Content Outline", "Generated Content")
}
export interface FormFieldConfig {
key: string;
label: string;
type: 'text' | 'number' | 'select' | 'textarea';
placeholder?: string;
required?: boolean;
value: any;
onChange: (value: any) => void;
options?: Array<{ value: string; label: string }>;
}
export interface FilterConfig {
key: string;
label: string;
type: 'text' | 'select';
placeholder?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: string;
}
export interface HeaderMetricConfig {
label: string;
value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { tasks: any[]; totalCount: number }) => number;
}
export interface TasksPageConfig {
columns: ColumnConfig[];
filters: FilterConfig[];
formFields: (clusters: Array<{ id: number; name: string }>) => FormFieldConfig[];
headerMetrics: HeaderMetricConfig[];
}
export const createTasksPageConfig = (
handlers: {
clusters: Array<{ id: number; name: string }>;
activeSector: { id: number; name: string } | null;
formData: {
title: string;
description?: string | null;
keywords?: string | null;
cluster_id?: number | null;
idea_id?: number | null;
content_structure: string;
content_type: string;
status: string;
word_count?: number;
};
setFormData: React.Dispatch<React.SetStateAction<any>>;
searchTerm: string;
setSearchTerm: (value: string) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
clusterFilter: string;
setClusterFilter: (value: string) => void;
structureFilter: string;
setStructureFilter: (value: string) => void;
typeFilter: string;
setTypeFilter: (value: string) => void;
setCurrentPage: (page: number) => void;
}
): TasksPageConfig => {
const showSectorColumn = !handlers.activeSector; // Show when viewing all sectors
return {
columns: [
{
...titleColumn,
sortable: true,
sortField: 'title',
toggleable: true, // Enable toggle for this column
toggleContentKey: 'content', // Use content field for toggle (fallback to description if content not available)
toggleContentLabel: 'Generated Content', // Label for expanded content
},
// Sector column - only show when viewing all sectors
...(showSectorColumn ? [{
...sectorColumn,
render: (value: string, row: Task) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'cluster_name',
label: 'Cluster',
sortable: false,
width: '200px',
render: (_value: string, row: Task) => row.cluster_name || '-',
},
{
key: 'content_structure',
label: 'Structure',
sortable: true,
sortField: 'content_structure',
width: '150px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
key: 'content_type',
label: 'Type',
sortable: true,
sortField: 'content_type',
width: '120px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
const statusColors: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
'queued': 'warning',
'in_progress': 'info',
'draft': 'warning',
'review': 'info',
'published': 'success',
'completed': 'success',
};
return (
<Badge
color={statusColors[value] || 'warning'}
size="sm"
>
{value?.replace('_', ' ') || value}
</Badge>
);
},
},
{
...wordCountColumn,
sortable: true,
sortField: 'word_count',
render: (value: number) => value.toLocaleString(),
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search tasks...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'queued', label: 'Queued' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'published', label: 'Published' },
{ value: 'completed', label: 'Completed' },
],
},
{
key: 'content_structure',
label: 'Structure',
type: 'select',
options: [
{ value: '', label: 'All Structures' },
{ value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'pillar_page', label: 'Pillar Page' },
{ value: 'supporting_page', label: 'Supporting Page' },
],
},
{
key: 'content_type',
label: 'Type',
type: 'select',
options: [
{ value: '', label: 'All Types' },
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'tutorial', label: 'Tutorial' },
],
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
options: (() => {
return [
{ value: '', label: 'All Clusters' },
...handlers.clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
];
})(),
dynamicOptions: 'clusters',
},
],
formFields: (clusters: Array<{ id: number; name: string }>) => [
{
key: 'title',
label: 'Title',
type: 'text',
placeholder: 'Enter task title',
required: true,
value: handlers.formData.title || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, title: value }),
},
{
key: 'description',
label: 'Description',
type: 'textarea',
placeholder: 'Enter description',
value: handlers.formData.description || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, description: value }),
},
{
key: 'keywords',
label: 'Keywords',
type: 'text',
placeholder: 'Enter keywords (comma-separated)',
value: handlers.formData.keywords || '',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, keywords: value }),
},
{
key: 'cluster_id',
label: 'Cluster',
type: 'select',
value: handlers.formData.cluster_id?.toString() || '',
onChange: (value: any) =>
handlers.setFormData({
...handlers.formData,
cluster_id: value ? parseInt(value) : null,
}),
options: [
{ value: '', label: 'No Cluster' },
...clusters.map((c) => ({ value: c.id.toString(), label: c.name })),
],
},
{
key: 'content_structure',
label: 'Content Structure',
type: 'select',
value: handlers.formData.content_structure || 'blog_post',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, content_structure: value }),
options: [
{ value: 'cluster_hub', label: 'Cluster Hub' },
{ value: 'landing_page', label: 'Landing Page' },
{ value: 'pillar_page', label: 'Pillar Page' },
{ value: 'supporting_page', label: 'Supporting Page' },
],
},
{
key: 'content_type',
label: 'Content Type',
type: 'select',
value: handlers.formData.content_type || 'blog_post',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, content_type: value }),
options: [
{ value: 'blog_post', label: 'Blog Post' },
{ value: 'article', label: 'Article' },
{ value: 'guide', label: 'Guide' },
{ value: 'tutorial', label: 'Tutorial' },
],
},
{
key: 'status',
label: 'Status',
type: 'select',
value: handlers.formData.status || 'queued',
onChange: (value: any) =>
handlers.setFormData({ ...handlers.formData, status: value }),
options: [
{ value: 'queued', label: 'Queued' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'published', label: 'Published' },
{ value: 'completed', label: 'Completed' },
],
},
],
headerMetrics: [
{
label: 'Total Tasks',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
},
{
label: 'Queued',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'queued').length,
},
{
label: 'In Progress',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'in_progress').length,
},
{
label: 'Published',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.tasks.filter((t: Task) => t.status === 'published').length,
},
],
};
};

View File

@@ -0,0 +1,99 @@
/**
* Routes Configuration
* Defines menu hierarchy, breadcrumbs, and routing structure
*/
export interface RouteConfig {
path: string;
label: string;
icon?: string;
children?: RouteConfig[];
breadcrumb?: string;
}
export const routes: RouteConfig[] = [
{
path: '/',
label: 'Dashboard',
icon: 'Dashboard',
},
{
path: '/planner',
label: 'Planner',
icon: 'Planner',
children: [
{ path: '/planner', label: 'Dashboard', breadcrumb: 'Planner Dashboard' },
{ path: '/planner/keywords', label: 'Keywords', breadcrumb: 'Keywords' },
{ path: '/planner/clusters', label: 'Clusters', breadcrumb: 'Clusters' },
{ path: '/planner/ideas', label: 'Ideas', breadcrumb: 'Ideas' },
{ path: '/planner/mapping', label: 'Mapping', breadcrumb: 'Mapping' },
],
},
{
path: '/writer',
label: 'Writer',
icon: 'Writer',
children: [
{ path: '/writer', label: 'Dashboard', breadcrumb: 'Writer Dashboard' },
{ path: '/writer/tasks', label: 'Tasks', breadcrumb: 'Tasks' },
{ path: '/writer/drafts', label: 'Drafts', breadcrumb: 'Drafts' },
{ path: '/writer/published', label: 'Published', breadcrumb: 'Published' },
],
},
{
path: '/thinker',
label: 'Thinker',
icon: 'Thinker',
children: [
{ path: '/thinker', label: 'Dashboard', breadcrumb: 'Thinker Dashboard' },
{ path: '/thinker/prompts', label: 'Prompts', breadcrumb: 'Prompts' },
{ path: '/thinker/strategies', label: 'Strategies', breadcrumb: 'Strategies' },
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
],
},
{
path: '/analytics',
label: 'Analytics',
icon: 'Analytics',
},
{
path: '/schedules',
label: 'Schedules',
icon: 'Schedules',
},
];
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
const breadcrumbs: Array<{ label: string; path: string }> = [
{ label: 'Home', path: '/' },
];
// Find matching route
const findRoute = (routes: RouteConfig[], path: string): RouteConfig | null => {
for (const route of routes) {
if (route.path === path) {
return route;
}
if (route.children) {
const child = route.children.find((r) => r.path === path);
if (child) {
return { ...child, path: route.path };
}
const found = findRoute(route.children, path);
if (found) {
breadcrumbs.push({ label: route.label, path: route.path });
return found;
}
}
}
return null;
};
const route = findRoute(routes, pathname);
if (route) {
breadcrumbs.push({ label: route.breadcrumb || route.label, path: route.path });
}
return breadcrumbs;
};

View File

@@ -0,0 +1,31 @@
/**
* Shared Action Snippets
* Common action button definitions for data tables
*/
export const commonActions = [
{ key: 'view', label: 'View', icon: 'Eye' },
{ key: 'edit', label: 'Edit', icon: 'Edit' },
{ key: 'delete', label: 'Delete', icon: 'Trash' },
];
export const keywordActions = [
{ key: 'edit', label: 'Edit', icon: 'Edit' },
{ key: 'delete', label: 'Delete', icon: 'Trash' },
];
export const taskActions = [
{ key: 'view', label: 'View', icon: 'Eye' },
{ key: 'edit', label: 'Edit', icon: 'Edit' },
{ key: 'generate', label: 'Generate', icon: 'Magic' },
{ key: 'publish', label: 'Publish', icon: 'Send' },
{ key: 'delete', label: 'Delete', icon: 'Trash' },
];
export const bulkActions = {
updateStatus: { key: 'update_status', label: 'Update Status' },
delete: { key: 'delete', label: 'Delete' },
assignCluster: { key: 'assign_cluster', label: 'Assign Cluster' },
export: { key: 'export', label: 'Export CSV' },
};

View File

@@ -0,0 +1,97 @@
/**
* Shared Column Snippets
* Reusable column definitions for data tables
*/
export const titleColumn = {
key: 'title',
label: 'Title',
sortable: true,
width: 'auto',
};
export const keywordColumn = {
key: 'keyword',
label: 'Keyword',
sortable: true,
width: 'auto',
};
export const statusColumn = {
key: 'status',
label: 'Status',
sortable: true,
badge: true,
width: '120px',
};
export const volumeColumn = {
key: 'volume',
label: 'Volume',
sortable: true,
numeric: true,
width: '100px',
};
export const difficultyColumn = {
key: 'difficulty',
label: 'Difficulty',
sortable: true,
badge: true,
width: '120px',
};
export const intentColumn = {
key: 'intent',
label: 'Intent',
sortable: true,
badge: true,
width: '120px',
};
export const clusterColumn = {
key: 'cluster',
label: 'Cluster',
sortable: true,
width: '200px',
};
export const createdColumn = {
key: 'created_at',
label: 'Created',
sortable: true,
date: true,
width: '150px',
};
export const updatedColumn = {
key: 'updated_at',
label: 'Updated',
sortable: true,
date: true,
width: '150px',
};
export const actionsColumn = {
key: 'actions',
label: 'Actions',
sortable: false,
width: '100px',
fixed: true,
};
export const wordCountColumn = {
key: 'word_count',
label: 'Word Count',
sortable: true,
numeric: true,
width: '120px',
};
export const sectorColumn = {
key: 'sector_name',
label: 'Sector',
sortable: false,
width: '150px',
};

View File

@@ -0,0 +1,73 @@
/**
* Shared Filter Snippets
* Reusable filter definitions for data tables
*/
export const statusFilter = {
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'archived', label: 'Archived' },
],
};
export const intentFilter = {
key: 'intent',
label: 'Intent',
type: 'select',
options: [
{ value: '', label: 'All Intent' },
{ value: 'informational', label: 'Informational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'commercial', label: 'Commercial' },
],
};
export const difficultyFilter = {
key: 'difficulty',
label: 'Difficulty',
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: 'Very Easy (1)' },
{ value: '2', label: 'Easy (2)' },
{ value: '3', label: 'Medium (3)' },
{ value: '4', label: 'Hard (4)' },
{ value: '5', label: 'Very Hard (5)' },
],
};
export const dateRangeFilter = {
key: 'date_range',
label: 'Date Range',
type: 'daterange',
};
export const volumeRangeFilter = {
key: 'volume_range',
label: 'Volume Range',
type: 'range',
min: 0,
max: 100000,
step: 100,
};
export const searchFilter = {
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
};
export const clusterFilter = {
key: 'cluster_id',
label: 'Cluster',
type: 'select',
options: [], // Will be populated dynamically
};

View File

@@ -0,0 +1,9 @@
/**
* Snippets Index
* Exports all shared snippets
*/
export * from './columns.snippets';
export * from './filters.snippets';
export * from './actions.snippets';

View File

@@ -0,0 +1,7 @@
/**
* Application Version
* Update this version when releasing new versions
*/
export const APP_VERSION = "1.0.0";
export const APP_NAME = "IGNY8";