407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
/**
|
|
* Content Page Configuration
|
|
* Centralized config for Content 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 { Content } from '../../services/api';
|
|
import { CONTENT_TYPE_OPTIONS, STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping';
|
|
|
|
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;
|
|
toggleContentKey?: string;
|
|
toggleContentLabel?: string;
|
|
defaultVisible?: boolean; // Whether column is visible by default (default: true)
|
|
}
|
|
|
|
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: { content: any[]; totalCount: number }) => number;
|
|
}
|
|
|
|
export interface ContentPageConfig {
|
|
columns: ColumnConfig[];
|
|
filters: FilterConfig[];
|
|
headerMetrics: HeaderMetricConfig[];
|
|
}
|
|
|
|
const getList = (primary?: string[], fallback?: any): string[] => {
|
|
if (primary && primary.length > 0) return primary;
|
|
if (!fallback) return [];
|
|
if (Array.isArray(fallback)) return fallback;
|
|
return [];
|
|
};
|
|
|
|
const renderBadgeList = (items: string[], emptyLabel = '-') => {
|
|
if (!items || items.length === 0) {
|
|
return <span className="text-gray-400 dark:text-gray-500">{emptyLabel}</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{items.map((item, index) => (
|
|
<Badge key={`${item}-${index}`} color="light" size="sm" variant="light">
|
|
{item}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const createContentPageConfig = (
|
|
handlers: {
|
|
activeSector: { id: number; name: string } | null;
|
|
searchTerm: string;
|
|
setSearchTerm: (value: string) => void;
|
|
statusFilter: string;
|
|
setStatusFilter: (value: string) => void;
|
|
setCurrentPage: (page: number) => void;
|
|
onViewContent?: (row: Content) => void;
|
|
}
|
|
): ContentPageConfig => {
|
|
const showSectorColumn = !handlers.activeSector;
|
|
|
|
const statusColors: Record<string, 'warning' | 'info' | 'success' | 'primary'> = {
|
|
draft: 'warning',
|
|
review: 'info',
|
|
publish: 'success',
|
|
};
|
|
|
|
return {
|
|
columns: [
|
|
{
|
|
...titleColumn,
|
|
sortable: true,
|
|
sortField: 'title',
|
|
toggleable: true,
|
|
toggleContentKey: 'content_html',
|
|
toggleContentLabel: 'Generated Content',
|
|
render: (value: string, row: Content) => (
|
|
<div>
|
|
{handlers.onViewContent ? (
|
|
<button
|
|
onClick={() => handlers.onViewContent!(row)}
|
|
className="font-medium text-blue-500 hover:text-blue-600 hover:underline text-left transition-colors"
|
|
>
|
|
{row.title || `Content #${row.id}`}
|
|
</button>
|
|
) : (
|
|
<div className="font-medium text-gray-900 dark:text-white">
|
|
{row.title || `Content #${row.id}`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
...(showSectorColumn ? [{
|
|
...sectorColumn,
|
|
render: (value: string, row: Content) => (
|
|
<Badge color="info" size="sm" variant="light">
|
|
{row.sector_name || '-'}
|
|
</Badge>
|
|
),
|
|
}] : []),
|
|
{
|
|
key: 'content_type',
|
|
label: 'Content Type',
|
|
sortable: true,
|
|
sortField: 'content_type',
|
|
width: '120px',
|
|
render: (value: string) => (
|
|
<Badge color="primary" size="sm" variant="light">
|
|
{TYPE_LABELS[value] || value || '-'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'content_structure',
|
|
label: 'Structure',
|
|
sortable: true,
|
|
sortField: 'content_structure',
|
|
width: '150px',
|
|
render: (value: string) => (
|
|
<Badge color="info" size="sm" variant="light">
|
|
{STRUCTURE_LABELS[value] || value || '-'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'cluster_name',
|
|
label: 'Cluster',
|
|
sortable: false,
|
|
width: '150px',
|
|
render: (_value: any, row: Content) => {
|
|
const clusterName = row.cluster_name;
|
|
if (!clusterName) {
|
|
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
|
}
|
|
return (
|
|
<Badge color="primary" size="sm" variant="light">
|
|
{clusterName}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'taxonomy_terms',
|
|
label: 'Taxonomy',
|
|
sortable: false,
|
|
width: '180px',
|
|
render: (_value: any, row: Content) => {
|
|
const taxonomyTerms = row.taxonomy_terms;
|
|
if (!taxonomyTerms || taxonomyTerms.length === 0) {
|
|
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{taxonomyTerms.map((term) => (
|
|
<Badge key={term.id} color="purple" size="sm" variant="light">
|
|
{term.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
...statusColumn,
|
|
sortable: true,
|
|
sortField: 'status',
|
|
render: (value: string) => {
|
|
const statusColors: Record<string, 'warning' | 'success'> = {
|
|
draft: 'warning',
|
|
published: 'success',
|
|
};
|
|
const color = statusColors[value] || 'warning';
|
|
const label = value === 'published' ? 'Published' : 'Draft';
|
|
return (
|
|
<Badge color={color} size="sm" variant="light">
|
|
{label}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
// Removed the separate status icon column. Status icon will be shown in the Created column below.
|
|
{
|
|
key: 'source',
|
|
label: 'Source',
|
|
sortable: true,
|
|
sortField: 'source',
|
|
width: '120px',
|
|
render: (value: any, row: Content) => {
|
|
const source = value || row.source || 'igny8';
|
|
const sourceColors: Record<string, 'primary' | 'info'> = {
|
|
igny8: 'primary',
|
|
wordpress: 'info',
|
|
};
|
|
const sourceLabels: Record<string, string> = {
|
|
igny8: 'IGNY8',
|
|
wordpress: 'WordPress',
|
|
};
|
|
return (
|
|
<Badge color={sourceColors[source] || 'primary'} size="sm" variant="light">
|
|
{sourceLabels[source] || source}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'external_url',
|
|
label: 'URL',
|
|
sortable: false,
|
|
width: '200px',
|
|
defaultVisible: false,
|
|
render: (value: string | null, row: Content) => {
|
|
const url = value || row.external_url || null;
|
|
return url ? (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 truncate block max-w-[200px]"
|
|
>
|
|
{url}
|
|
</a>
|
|
) : (
|
|
<span className="text-sm text-gray-400 dark:text-gray-500">-</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
...createdColumn,
|
|
sortable: true,
|
|
sortField: 'created_at',
|
|
label: 'Created',
|
|
align: 'right',
|
|
render: (value: string, row: Content) => {
|
|
// Image status logic: pending (prompt exists, no image), generated, failed
|
|
let status = null;
|
|
if (row.image_status === 'failed') {
|
|
status = 'failed';
|
|
} else if (row.image_status === 'generated' || row.has_generated_images) {
|
|
status = 'generated';
|
|
} else if (row.has_image_prompts) {
|
|
status = 'pending';
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
'pending': 'text-amber-500 dark:text-amber-400',
|
|
'generated': 'text-green-500 dark:text-green-400',
|
|
'failed': 'text-red-500 dark:text-red-400',
|
|
};
|
|
const statusTitles: Record<string, string> = {
|
|
'pending': 'Images pending',
|
|
'generated': 'Images generated',
|
|
'failed': 'Image generation failed',
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center justify-end gap-3 pr-10">
|
|
<span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
{formatRelativeDate(value)}
|
|
</span>
|
|
{status && (
|
|
<div
|
|
className={`w-5 h-5 flex items-center justify-center flex-shrink-0 ${statusColors[status]}`}
|
|
title={statusTitles[status]}
|
|
>
|
|
{/* Single icon for all statuses */}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="w-4 h-4"
|
|
>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
// Optional columns - hidden by default
|
|
{
|
|
key: 'updated_at',
|
|
label: 'Updated',
|
|
sortable: true,
|
|
sortField: 'updated_at',
|
|
defaultVisible: false,
|
|
render: (value: string) => formatRelativeDate(value),
|
|
},
|
|
],
|
|
filters: [
|
|
{
|
|
key: 'search',
|
|
label: 'Search',
|
|
type: 'text',
|
|
placeholder: 'Search content...',
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '', label: 'All Status' },
|
|
{ value: 'draft', label: 'Draft' },
|
|
{ value: 'published', label: 'Published' },
|
|
],
|
|
},
|
|
{
|
|
key: 'content_type',
|
|
label: 'Content Type',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '', label: 'All Types' },
|
|
...CONTENT_TYPE_OPTIONS,
|
|
],
|
|
},
|
|
{
|
|
key: 'content_structure',
|
|
label: 'Content Structure',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '', label: 'All Structures' },
|
|
{ value: 'article', label: 'Article' },
|
|
{ value: 'guide', label: 'Guide' },
|
|
{ value: 'comparison', label: 'Comparison' },
|
|
{ value: 'review', label: 'Review' },
|
|
{ value: 'listicle', label: 'Listicle' },
|
|
{ value: 'landing_page', label: 'Landing Page' },
|
|
{ value: 'business_page', label: 'Business Page' },
|
|
{ value: 'service_page', label: 'Service Page' },
|
|
{ value: 'general', label: 'General' },
|
|
{ value: 'cluster_hub', label: 'Cluster Hub' },
|
|
{ value: 'product_page', label: 'Product Page' },
|
|
{ value: 'category_archive', label: 'Category Archive' },
|
|
{ value: 'tag_archive', label: 'Tag Archive' },
|
|
{ value: 'attribute_archive', label: 'Attribute Archive' },
|
|
],
|
|
},
|
|
{
|
|
key: 'source',
|
|
label: 'Source',
|
|
type: 'select',
|
|
options: [
|
|
{ value: '', label: 'All Sources' },
|
|
{ value: 'igny8', label: 'IGNY8' },
|
|
{ value: 'wordpress', label: 'WordPress' },
|
|
],
|
|
},
|
|
],
|
|
headerMetrics: [
|
|
{
|
|
label: 'Total Content',
|
|
value: 0,
|
|
accentColor: 'blue' as const,
|
|
calculate: (data) => data.totalCount || 0,
|
|
},
|
|
{
|
|
label: 'Draft',
|
|
value: 0,
|
|
accentColor: 'amber' as const,
|
|
calculate: (data) => data.content.filter((c: Content) => c.status === 'draft').length,
|
|
},
|
|
{
|
|
label: 'Published',
|
|
value: 0,
|
|
accentColor: 'green' as const,
|
|
calculate: (data) => data.content.filter((c: Content) => c.status === 'published').length,
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|