Files
igny8/frontend/src/components/common/ToggleTableRow.tsx

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;