Files
igny8/frontend/src/config/pages/content.config.tsx
IGNY8 VPS (Salman) 4bea79a76d 123
2025-11-29 07:20:26 +00:00

440 lines
14 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;
onRowClick?: (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',
render: (value: string, row: Content) => (
<div>
{handlers.onRowClick ? (
<button
onClick={() => handlers.onRowClick!(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="xs" variant="soft">
<span className="text-[11px] font-normal">{row.sector_name || '-'}</span>
</Badge>
),
}] : []),
{
key: 'content_type',
label: 'Type',
sortable: true,
sortField: 'content_type',
width: '110px',
render: (value: string) => {
const label = TYPE_LABELS[value] || value || '-';
// Proper case: capitalize first letter only
const properCase = label.charAt(0).toUpperCase() + label.slice(1);
return (
<Badge color="blue" size="xs" variant="soft">
<span className="text-[11px] font-normal">{properCase}</span>
</Badge>
);
},
},
{
key: 'content_structure',
label: 'Structure',
sortable: true,
sortField: 'content_structure',
width: '130px',
render: (value: string) => {
const label = STRUCTURE_LABELS[value] || value || '-';
// Proper case: capitalize first letter of each word
const properCase = label.split(/[_\s]+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
return (
<Badge color="purple" size="xs" variant="soft">
<span className="text-[11px] font-normal">{properCase}</span>
</Badge>
);
},
},
{
key: 'cluster_name',
label: 'Cluster',
sortable: false,
width: '130px',
render: (_value: any, row: Content) => {
const clusterName = row.cluster_name;
if (!clusterName) {
return <span className="text-gray-400 dark:text-gray-500 text-[11px]">-</span>;
}
return (
<Badge color="indigo" size="xs" variant="soft">
<span className="text-[11px] font-normal">{clusterName}</span>
</Badge>
);
},
},
{
key: 'taxonomy_terms',
label: 'Tags',
sortable: false,
width: '150px',
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 text-[11px]">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{taxonomyTerms.slice(0, 2).map((term) => (
<Badge key={term.id} color="pink" size="xs" variant="soft">
<span className="text-[11px] font-normal">{term.name}</span>
</Badge>
))}
{taxonomyTerms.length > 2 && (
<span className="text-[11px] text-gray-500">+{taxonomyTerms.length - 2}</span>
)}
</div>
);
},
},
{
...statusColumn,
sortable: true,
sortField: 'status',
render: (value: string) => {
const statusColors: Record<string, 'success' | 'amber'> = {
draft: 'amber',
published: 'success',
};
const color = statusColors[value] || 'amber';
// Proper case
const label = value ? value.charAt(0).toUpperCase() + value.slice(1) : 'Draft';
return (
<Badge color={color} size="xs" variant="soft">
<span className="text-[11px] font-normal">{label}</span>
</Badge>
);
},
},
// Removed the separate Status icon column. Both icons will be shown in the Created column below.
{
key: 'source',
label: 'Source',
sortable: true,
sortField: 'source',
width: '90px',
render: (value: any, row: Content) => {
const source = value || row.source || 'igny8';
const sourceColors: Record<string, 'teal' | 'cyan'> = {
igny8: 'teal',
wordpress: 'cyan',
};
const sourceLabels: Record<string, string> = {
igny8: 'Igny8',
wordpress: 'Wp',
};
return (
<Badge color={sourceColors[source] || 'teal'} size="xs" variant="soft">
<span className="text-[11px] font-normal">{sourceLabels[source] || source}</span>
</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) => {
// Prompt icon logic (unchanged)
const hasPrompts = row.has_image_prompts || false;
// Image icon logic (status-aware)
let imageStatus: 'pending' | 'generated' | 'failed' | null = null;
if (row.image_status === 'failed') {
imageStatus = 'failed';
} else if (row.image_status === 'generated' || row.has_generated_images) {
imageStatus = 'generated';
} else if (row.has_image_prompts) {
imageStatus = 'pending';
}
const imageStatusColors: 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 imageStatusTitles: 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>
{/* Prompts Icon */}
<div
className={`w-5 h-5 flex items-center justify-center flex-shrink-0 ${
hasPrompts ? 'text-purple-500 dark:text-purple-400' : 'text-gray-300 dark:text-gray-600'
}`}
title={hasPrompts ? 'Prompts ready' : 'No prompts'}
>
<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"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
{/* Images Icon (status-aware) */}
<div
className={`w-5 h-5 flex items-center justify-center flex-shrink-0 ${imageStatus ? imageStatusColors[imageStatus] : 'text-gray-300 dark:text-gray-600'}`}
title={imageStatus ? imageStatusTitles[imageStatus] : 'No images'}
>
<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,
},
],
};
};