355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
/**
|
|
* ToggleTableRow Component
|
|
* Reusable component for displaying long HTML content in table rows with expand/collapse functionality
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { ChevronDownIcon, HorizontaLDots } from '../../icons';
|
|
import HTMLContentRenderer from './HTMLContentRenderer';
|
|
import Badge from '../ui/badge/Badge';
|
|
|
|
interface ToggleTableRowProps {
|
|
/** The row data */
|
|
row: any;
|
|
/** The column key that contains the toggleable content */
|
|
contentKey: string;
|
|
/** Custom label for the expanded content (e.g., "Content Outline", "Description") */
|
|
contentLabel?: string;
|
|
/** Column span for the expanded row (should match number of columns in table) */
|
|
colSpan: number;
|
|
/** Whether the row is expanded (controlled) */
|
|
isExpanded?: boolean;
|
|
/** Whether the row is initially expanded (uncontrolled) */
|
|
defaultExpanded?: boolean;
|
|
/** Callback when toggle state changes */
|
|
onToggle?: (expanded: boolean, rowId: string | number) => void;
|
|
/** Custom className */
|
|
className?: string;
|
|
}
|
|
|
|
const ToggleTableRow: React.FC<ToggleTableRowProps> = ({
|
|
row,
|
|
contentKey,
|
|
contentLabel = 'Content',
|
|
colSpan,
|
|
isExpanded: controlledExpanded,
|
|
defaultExpanded = false,
|
|
onToggle,
|
|
className = '',
|
|
}) => {
|
|
// Use controlled state if provided, otherwise use internal state
|
|
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
|
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
|
const [contentHeight, setContentHeight] = useState<number | 'auto'>('auto');
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Get content - handle fallback to description if primary contentKey is empty
|
|
let content = row[contentKey];
|
|
let contentMetadata: Record<string, any> | null = null;
|
|
|
|
if (content && typeof content === 'object' && content !== null && 'content' in content) {
|
|
contentMetadata = { ...content };
|
|
content = content.content;
|
|
}
|
|
|
|
if (!content || (typeof content === 'string' && content.trim().length === 0)) {
|
|
// Try fallback to description if primary content is empty
|
|
content = row.description || row.content_outline || null;
|
|
}
|
|
// Check if content exists - handle both strings and objects
|
|
const hasContent = content && (
|
|
typeof content === 'string'
|
|
? content.trim().length > 0
|
|
: typeof content === 'object' && content !== null && Object.keys(content).length > 0
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isExpanded && contentRef.current) {
|
|
// Measure content height for smooth animation
|
|
const height = contentRef.current.scrollHeight;
|
|
setContentHeight(height);
|
|
} else {
|
|
setContentHeight(0);
|
|
}
|
|
}, [isExpanded, content]);
|
|
|
|
const handleToggle = () => {
|
|
if (!hasContent) return;
|
|
|
|
const newExpanded = !isExpanded;
|
|
|
|
// Update internal state if uncontrolled
|
|
if (controlledExpanded === undefined) {
|
|
setInternalExpanded(newExpanded);
|
|
}
|
|
|
|
// Notify parent
|
|
if (onToggle) {
|
|
onToggle(newExpanded, row.id ?? row.id);
|
|
}
|
|
};
|
|
|
|
if (!hasContent) {
|
|
return null;
|
|
}
|
|
|
|
// Don't render anything if not expanded - no row HTML before toggle
|
|
if (!isExpanded) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
className={`toggle-content-row expanded ${className}`}
|
|
aria-hidden={false}
|
|
>
|
|
<td
|
|
colSpan={colSpan}
|
|
className="px-5 py-0 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-white/[0.05]"
|
|
>
|
|
<div
|
|
ref={contentRef}
|
|
className="overflow-hidden bg-white dark:bg-gray-900"
|
|
style={{
|
|
maxWidth: '1200px',
|
|
margin: 'auto',
|
|
padding: '25px',
|
|
marginTop: '50px',
|
|
}}
|
|
>
|
|
<div className="py-4 px-2">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 tracking-wide">
|
|
{contentLabel}
|
|
</div>
|
|
|
|
{/* Show idea title if available (for Tasks page) */}
|
|
{row.idea_title && (
|
|
<div className="mb-2">
|
|
<div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-1">Idea:</div>
|
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{row.idea_title}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metadata badges */}
|
|
<ToggleMetadata row={row} contentMetadata={contentMetadata} />
|
|
|
|
{/* Rendered content */}
|
|
<div className="html-content-wrapper">
|
|
<HTMLContentRenderer
|
|
content={content}
|
|
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
};
|
|
|
|
interface ToggleMetadataProps {
|
|
row: any;
|
|
contentMetadata: Record<string, any> | null;
|
|
}
|
|
|
|
const ToggleMetadata: React.FC<ToggleMetadataProps> = ({ row, contentMetadata }) => {
|
|
const primaryKeyword =
|
|
row.content_primary_keyword ||
|
|
row.primary_keyword ||
|
|
contentMetadata?.primary_keyword ||
|
|
contentMetadata?.metadata?.primary_keyword ||
|
|
null;
|
|
|
|
const secondaryKeywords =
|
|
row.content_secondary_keywords ||
|
|
row.secondary_keywords ||
|
|
contentMetadata?.secondary_keywords ||
|
|
contentMetadata?.metadata?.secondary_keywords ||
|
|
[];
|
|
|
|
const tags =
|
|
row.content_tags ||
|
|
row.tags ||
|
|
contentMetadata?.tags ||
|
|
contentMetadata?.metadata?.tags ||
|
|
[];
|
|
|
|
const categories =
|
|
row.content_categories ||
|
|
row.categories ||
|
|
contentMetadata?.categories ||
|
|
contentMetadata?.metadata?.categories ||
|
|
[];
|
|
|
|
// Extract meta_description, avoiding JSON strings
|
|
let metaDescription: string | null = null;
|
|
|
|
// Try direct fields first
|
|
if (row.meta_description && typeof row.meta_description === 'string') {
|
|
metaDescription = row.meta_description;
|
|
} else if (row.content_meta_description && typeof row.content_meta_description === 'string') {
|
|
metaDescription = row.content_meta_description;
|
|
} else if (contentMetadata?.meta_description && typeof contentMetadata.meta_description === 'string') {
|
|
metaDescription = contentMetadata.meta_description;
|
|
} else if (contentMetadata?.metadata?.meta_description && typeof contentMetadata.metadata.meta_description === 'string') {
|
|
metaDescription = contentMetadata.metadata.meta_description;
|
|
}
|
|
|
|
// If metaDescription looks like JSON, try to parse it
|
|
if (metaDescription && metaDescription.trim().startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(metaDescription);
|
|
// If parsed object has meta_description, use that
|
|
if (parsed.meta_description && typeof parsed.meta_description === 'string') {
|
|
metaDescription = parsed.meta_description;
|
|
} else {
|
|
// If it's a full JSON response, extract meta_description from it
|
|
metaDescription = parsed.meta_description || null;
|
|
}
|
|
} catch {
|
|
// Not valid JSON, keep as is
|
|
}
|
|
}
|
|
|
|
const hasMetadata =
|
|
primaryKeyword ||
|
|
(secondaryKeywords && secondaryKeywords.length > 0) ||
|
|
(tags && tags.length > 0) ||
|
|
(categories && categories.length > 0) ||
|
|
metaDescription;
|
|
|
|
if (!hasMetadata) {
|
|
return null;
|
|
}
|
|
|
|
const renderBadgeList = (items: any, color: 'info' | 'light' = 'light') => {
|
|
if (!items) return null;
|
|
const list = Array.isArray(items) ? items : [items];
|
|
if (list.length === 0) return null;
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{list.map((item, index) => (
|
|
<Badge
|
|
key={`${item}-${index}`}
|
|
color={color}
|
|
size="sm"
|
|
variant="light"
|
|
>
|
|
{item}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{primaryKeyword && (
|
|
<div className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="font-medium text-gray-600 dark:text-gray-400">Primary Keyword:</span>
|
|
<Badge color="info" size="sm" variant="light">
|
|
{primaryKeyword}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{(() => {
|
|
const badges = renderBadgeList(secondaryKeywords);
|
|
if (!badges) return null;
|
|
return (
|
|
<div className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="font-medium text-gray-600 dark:text-gray-400">Secondary Keywords:</span>
|
|
{badges}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{(() => {
|
|
const badges = renderBadgeList(tags);
|
|
if (!badges) return null;
|
|
return (
|
|
<div className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="font-medium text-gray-600 dark:text-gray-400">Tags:</span>
|
|
{badges}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{(() => {
|
|
const badges = renderBadgeList(categories);
|
|
if (!badges) return null;
|
|
return (
|
|
<div className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<span className="font-medium text-gray-600 dark:text-gray-400">Categories:</span>
|
|
{badges}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{metaDescription && (
|
|
<div className="rounded-lg bg-gray-50 dark:bg-gray-800/50 p-3 text-xs text-gray-600 dark:text-gray-400">
|
|
<span className="font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 block mb-1">
|
|
Meta Description
|
|
</span>
|
|
{metaDescription}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Toggle Button Component - To be used in table cells
|
|
*/
|
|
interface ToggleButtonProps {
|
|
/** Whether the row is expanded */
|
|
isExpanded: boolean;
|
|
/** Click handler */
|
|
onClick: () => void;
|
|
/** Whether content exists */
|
|
hasContent: boolean;
|
|
/** Custom className */
|
|
className?: string;
|
|
}
|
|
|
|
export const ToggleButton: React.FC<ToggleButtonProps> = ({
|
|
isExpanded,
|
|
onClick,
|
|
hasContent,
|
|
className = '',
|
|
}) => {
|
|
if (!hasContent) {
|
|
return (
|
|
<span className={`inline-flex items-center justify-center w-8 h-8 text-gray-300 dark:text-gray-600 ${className}`}>
|
|
<HorizontaLDots className="w-4 h-4" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200 ${
|
|
isExpanded
|
|
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
} ${className}`}
|
|
aria-label={isExpanded ? 'Collapse content' : 'Expand content'}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<ChevronDownIcon
|
|
className={`w-4 h-4 transition-transform duration-200 ${
|
|
isExpanded ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export default ToggleTableRow;
|
|
|