/** * ContentViewTemplate - Template for displaying individual content with all metadata * * Features: * - Centered layout with max-width 1200px * - Modern styling with Tailwind CSS * - Displays all content metadata * - Responsive design * * Usage: * navigate('/writer/content')} * /> */ import React, { useEffect, useMemo, useState } from 'react'; import { Content, fetchImages, ImageRecord } from '../services/api'; import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon, PencilIcon, ImageIcon, BoltIcon } from '../icons'; import { useNavigate } from 'react-router-dom'; import Button from '../components/ui/button/Button'; interface ContentViewTemplateProps { content: Content | null; loading: boolean; onBack?: () => void; } interface ArticleSection { id: string; heading: string; headingLevel: number; bodyHtml: string; } interface ParsedArticle { introHtml: string; sections: ArticleSection[]; } const imageStatusClassMap: Record = { generated: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-200', pending: 'bg-warning-100 text-warning-700 dark:bg-warning-500/20 dark:text-warning-200', queued: 'bg-warning-100 text-warning-700 dark:bg-warning-500/20 dark:text-warning-200', failed: 'bg-error-100 text-error-700 dark:bg-error-500/20 dark:text-error-200', error: 'bg-error-100 text-error-700 dark:bg-error-500/20 dark:text-error-200', }; const serializeNodes = (nodes: Node[]): string => nodes .map((node) => { if (node.nodeType === Node.ELEMENT_NODE) { return (node as HTMLElement).outerHTML; } if (node.nodeType === Node.TEXT_NODE) { return node.textContent ?? ''; } return ''; }) .join(''); const slugify = (value: string, index: number): string => { const base = value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return base || `section-${index + 1}`; }; const parseContentHtml = (html: string): ParsedArticle => { if (!html) { return { introHtml: '', sections: [] }; } let parser: DOMParser | null = null; if (typeof window !== 'undefined' && typeof window.DOMParser !== 'undefined') { parser = new window.DOMParser(); } else if (typeof DOMParser !== 'undefined') { parser = new DOMParser(); } if (!parser) { return { introHtml: html, sections: [], }; } try { const doc = parser.parseFromString(html, 'text/html'); const body = doc.body; const introNodes: Node[] = []; const sections: ArticleSection[] = []; let currentSection: { heading: string; level: number; nodes: Node[] } | null = null; Array.from(body.childNodes).forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as HTMLElement; if (element.tagName === 'H2') { if (currentSection) { sections.push({ id: slugify(currentSection.heading, sections.length), heading: currentSection.heading || `Section ${sections.length + 1}`, headingLevel: currentSection.level, bodyHtml: serializeNodes(currentSection.nodes).trim(), }); } currentSection = { heading: element.textContent?.trim() || '', level: 2, nodes: [], }; return; } } if (currentSection) { currentSection.nodes.push(node); } else { introNodes.push(node); } }); if (currentSection) { sections.push({ id: slugify(currentSection.heading, sections.length), heading: currentSection.heading || `Section ${sections.length + 1}`, headingLevel: currentSection.level, bodyHtml: serializeNodes(currentSection.nodes).trim(), }); } return { introHtml: serializeNodes(introNodes).trim(), sections, }; } catch { return { introHtml: html, sections: [], }; } }; interface MetadataPrompts { featured_prompt?: string; in_article_prompts?: string[]; } const extractImagePromptsFromMetadata = (metadata: unknown): MetadataPrompts | null => { if (!metadata || typeof metadata !== 'object') { return null; } const seen = new WeakSet(); const search = (value: unknown): MetadataPrompts | null => { if (!value || typeof value !== 'object') return null; if (seen.has(value as object)) { return null; } seen.add(value as object); if (Array.isArray(value)) { for (const item of value) { const found = search(item); if (found) return found; } return null; } const obj = value as Record; const hasFeatured = typeof obj.featured_prompt === 'string'; const hasInArticle = Array.isArray(obj.in_article_prompts); if (hasFeatured || hasInArticle) { return { featured_prompt: hasFeatured ? (obj.featured_prompt as string) : undefined, in_article_prompts: hasInArticle ? (obj.in_article_prompts as unknown[]).filter((item): item is string => typeof item === 'string') : undefined, }; } for (const key of Object.keys(obj)) { const found = search(obj[key]); if (found) return found; } return null; }; return search(metadata); }; const getImageSrc = (image: ImageRecord | null): string | undefined => { if (!image) return undefined; if (image.image_url) return image.image_url; if (image.image_path) return `/api/v1/writer/images/${image.id}/file/`; return undefined; }; const ImageStatusPill = ({ status, className = '' }: { status?: string | null; className?: string }) => { if (!status) return null; const normalized = status.toLowerCase(); const classes = imageStatusClassMap[normalized] || 'bg-gray-100 text-gray-700 dark:bg-gray-700/70 dark:text-gray-200'; return ( {status} ); }; const PromptPlaceholder = ({ prompt, label, minHeight = 220, }: { prompt?: string | null; label?: string; minHeight?: number; }) => (
{label && (

{label}

)}

{prompt || 'Image prompt available, awaiting generation.'}

); const FeaturedImageBlock = ({ image, loading, }: { image: ImageRecord | null; loading: boolean; }) => { const imageSrc = getImageSrc(image); if (!loading && !image && !imageSrc) { return null; } return (
Featured Visual
{loading && !imageSrc ? (
) : imageSrc ? ( {image?.prompt ) : ( )} {image?.caption && imageSrc && (
Caption aligned to hero section
)}
{image?.caption && (
{image.caption}
)}
); }; const SectionImageBlock = ({ image, loading, heading, showPrompt = true, }: { image: ImageRecord | null; loading: boolean; heading: string; showPrompt?: boolean; }) => { if (!image && !loading) return null; const imageSrc = getImageSrc(image); return (
{loading && !image ? (
) : imageSrc ? ( {image?.prompt ) : ( )}
{showPrompt && image?.caption && (

Image Caption

{image.caption}

)}
); }; const IntroBlock = ({ html }: { html: string }) => (
Opening Narrative
); // Helper to split content at first H3 tag const splitAtFirstH3 = (html: string): { beforeH3: string; h3AndAfter: string } => { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const h3 = doc.querySelector('h3'); if (!h3) { return { beforeH3: html, h3AndAfter: '' }; } const beforeNodes: Node[] = []; const afterNodes: Node[] = []; let foundH3 = false; Array.from(doc.body.childNodes).forEach((node) => { if (node === h3) { foundH3 = true; afterNodes.push(node); } else if (foundH3) { afterNodes.push(node); } else { beforeNodes.push(node); } }); const serializeNodes = (nodes: Node[]): string => nodes .map((node) => { if (node.nodeType === Node.ELEMENT_NODE) { return (node as HTMLElement).outerHTML; } if (node.nodeType === Node.TEXT_NODE) { return node.textContent ?? ''; } return ''; }) .join(''); return { beforeH3: serializeNodes(beforeNodes), h3AndAfter: serializeNodes(afterNodes), }; }; /** * ContentSectionBlock - Renders a content section with image layout based on distribution pattern * * Layout rules (first 4 sections): * - Section 1: Square image right-aligned (50%) with description * - Section 2: Landscape image full-width (1024px) with description * - Section 3: Square image left-aligned (50%) with description * - Section 4: Landscape image full-width (1024px) with description * - Sections 5+: Reuse images without descriptions */ const ContentSectionBlock = ({ section, image, loading, index, aspectRatio = 'square', imageAlign = 'full', showDescription = true, }: { section: ArticleSection; image: ImageRecord | null; loading: boolean; index: number; aspectRatio?: 'square' | 'landscape'; imageAlign?: 'left' | 'right' | 'full'; showDescription?: boolean; }) => { const hasImage = Boolean(image); const headingLabel = section.heading || `Section ${index + 1}`; const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml); // Determine image container width class based on aspect ratio and alignment const getImageContainerClass = () => { if (aspectRatio === 'landscape') { return 'w-full max-w-[1024px] mx-auto'; } if (imageAlign === 'left') { return 'w-full max-w-[50%] mr-auto'; } if (imageAlign === 'right') { return 'w-full max-w-[50%] ml-auto'; } return 'w-full max-w-[50%] mx-auto'; }; return (
{/* Section header */}
{index + 1}
Section Spotlight

{headingLabel}

{/* Content layout with images */}
{/* Square images (left/right aligned) - content and image in same row */} {aspectRatio === 'square' && (imageAlign === 'left' || imageAlign === 'right') && hasImage ? (
{/* Image side (48% width) */}
{/* Content side (48% width with auto remaining) */}
{/* Content before H3 */} {beforeH3 && (
)} {/* H3 and remaining content */} {h3AndAfter && (
)} {/* Fallback if no H3 structure found */} {!beforeH3 && !h3AndAfter && (
)}
) : ( <> {/* Content before H3 */} {beforeH3 && (
)} {/* Landscape image - full width centered */} {hasImage && aspectRatio === 'landscape' && (
)} {/* H3 and remaining content */} {h3AndAfter && (
)} {/* Fallback if no H3 structure found */} {!beforeH3 && !h3AndAfter && (
)} )}
); }; interface ArticleBodyProps { introHtml: string; sections: ArticleSection[]; sectionImages: ImageRecord[]; imagesLoading: boolean; rawHtml?: string; } const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => { const hasStructuredSections = sections.length > 0; // Image distribution mapping for first 4 sections (matches WordPress template) const imageDistribution = [ { position: 0, type: 'square' as const, align: 'right' as const }, // Section 1 { position: 3, type: 'landscape' as const, align: 'full' as const }, // Section 2 { position: 2, type: 'square' as const, align: 'left' as const }, // Section 3 { position: 1, type: 'landscape' as const, align: 'full' as const }, // Section 4 ]; // Reuse pattern for sections 5+ (without descriptions) const reusePattern = [1, 0, 3, 2]; // Get image aspect ratio from record or fallback to position-based calculation const getImageAspectRatio = (image: ImageRecord | null, position: number): 'square' | 'landscape' => { if (image?.aspect_ratio) return image.aspect_ratio; // Fallback: positions 0, 2 are square, positions 1, 3 are landscape return position === 0 || position === 2 ? 'square' : 'landscape'; }; if (!hasStructuredSections && !introHtml && rawHtml) { return (
); } return (
{introHtml && } {sections.map((section, sectionIndex) => { let image: ImageRecord | null = null; let aspectRatio: 'square' | 'landscape' = 'landscape'; let imageAlign: 'left' | 'right' | 'full' = 'full'; let showDescription = true; // First 4 sections: use distribution pattern if (sectionIndex < 4) { const dist = imageDistribution[sectionIndex]; const imgPosition = dist.position; image = sectionImages[imgPosition] ?? null; aspectRatio = dist.type; imageAlign = dist.align; showDescription = true; } // Sections 5+: reuse images without descriptions else { const reuseIndex = (sectionIndex - 4) % reusePattern.length; const imgPosition = reusePattern[reuseIndex]; image = sectionImages[imgPosition] ?? null; if (image) { aspectRatio = getImageAspectRatio(image, imgPosition); imageAlign = (aspectRatio === 'square') ? (reuseIndex % 2 === 0 ? 'right' : 'left') : 'full'; } showDescription = false; } return ( ); })}
); }; export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) { const navigate = useNavigate(); const [imageRecords, setImageRecords] = useState([]); const [imagesLoading, setImagesLoading] = useState(false); const [imagesError, setImagesError] = useState(null); const metadataPrompts = useMemo(() => extractImagePromptsFromMetadata(content?.metadata), [content?.metadata]); useEffect(() => { let isActive = true; if (!content?.id) { setImageRecords([]); setImagesError(null); setImagesLoading(false); return () => { isActive = false; }; } setImagesLoading(true); fetchImages({ content_id: content.id, ordering: 'position', page_size: 50, }) .then((response) => { if (!isActive) return; setImageRecords((response?.results ?? []).filter(Boolean)); setImagesError(null); }) .catch((error: any) => { if (!isActive) return; setImagesError(error?.message || 'Unable to load images for this content.'); setImageRecords([]); }) .finally(() => { if (!isActive) return; setImagesLoading(false); }); return () => { isActive = false; }; }, [content?.id]); const sortedImages = useMemo(() => { if (!imageRecords.length) return []; return [...imageRecords].sort((a, b) => { const aPos = a.position ?? 0; const bPos = b.position ?? 0; if (a.image_type === b.image_type) { return aPos - bPos; } if (a.image_type === 'featured') return -1; if (b.image_type === 'featured') return 1; return aPos - bPos; }); }, [imageRecords]); const featuredImageFromRecords = useMemo( () => sortedImages.find((img) => img.image_type === 'featured') ?? null, [sortedImages] ); const inArticleImagesFromRecords = useMemo( () => sortedImages.filter((img) => img.image_type === 'in_article'), [sortedImages] ); const resolvedFeaturedImage = useMemo(() => { if (featuredImageFromRecords) { return featuredImageFromRecords; } if (metadataPrompts?.featured_prompt) { return { id: -1, task_id: content?.task_id ?? null, task_title: content?.task_title ?? null, content_id: content?.id ?? null, content_title: content?.title ?? content?.meta_title ?? null, image_type: 'featured', image_url: undefined, image_path: undefined, prompt: metadataPrompts.featured_prompt, status: 'pending', position: 0, created_at: '', updated_at: '', account_id: undefined, } as ImageRecord; } return null; }, [featuredImageFromRecords, metadataPrompts, content?.task_id, content?.task_title, content?.id, content?.title, content?.meta_title]); const resolvedInArticleImages = useMemo(() => { const sorted = [...inArticleImagesFromRecords].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const prompts = metadataPrompts?.in_article_prompts ?? []; if (!prompts.length) { return sorted; } const byPosition = new Map(); sorted.forEach((img, index) => { const pos = img.position ?? index; byPosition.set(pos, img); }); const usedPositions = new Set(); const merged: ImageRecord[] = prompts.map((prompt, index) => { const position = index; // 0-based position matching section array index const existing = byPosition.get(position); usedPositions.add(position); if (existing) { return { ...existing, prompt: existing.prompt || prompt, }; } return { id: -1 * (index + 1), task_id: content?.task_id ?? null, task_title: content?.task_title ?? null, content_id: content?.id ?? null, content_title: content?.title ?? content?.meta_title ?? null, image_type: 'in_article', image_url: undefined, image_path: undefined, prompt, status: 'pending', position, // 0-based position created_at: '', updated_at: '', account_id: undefined, } as ImageRecord; }); sorted.forEach((img, idx) => { const position = img.position ?? idx; if (!usedPositions.has(position)) { merged.push(img); } }); return merged.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); }, [ inArticleImagesFromRecords, metadataPrompts, content?.task_id, content?.task_title, content?.id, content?.title, content?.meta_title, ]); const parsedArticle = useMemo( () => parseContentHtml(content?.content_html ?? ''), [content?.content_html] ); const shouldShowFeaturedBlock = imagesLoading || Boolean(resolvedFeaturedImage); if (loading) { return (
); } if (!content) { return (

Content Not Found

The content you're looking for doesn't exist or has been deleted.

{onBack && ( )}
); } const formatDate = (dateString: string) => { try { return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch { return dateString; } }; const getStatusColor = (status: string) => { const statusLower = status.toLowerCase(); if (statusLower === 'generated' || statusLower === 'published' || statusLower === 'complete') { return 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-400'; } if (statusLower === 'approved') { return 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-400'; } if (statusLower === 'review') { return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'; } if (statusLower === 'pending' || statusLower === 'draft') { return 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-400'; } if (statusLower === 'failed' || statusLower === 'error') { return 'bg-error-100 text-error-800 dark:bg-error-900/30 dark:text-error-400'; } return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; }; const getStatusLabel = (status: string, hasExternalId?: boolean) => { const statusLower = status.toLowerCase(); // Map status to user-friendly labels const statusLabels: Record = { 'draft': 'Draft', 'review': 'In Review', 'approved': 'Ready to Publish', 'published': hasExternalId ? 'On Site' : 'Approved', }; return statusLabels[statusLower] || status.charAt(0).toUpperCase() + status.slice(1); }; return (
{/* Back Button */} {onBack && ( )} {/* Main Content Card */}
{/* Header Section */}
{getStatusLabel(content.status, !!content.external_id)}

{content.meta_title || content.title || `Content #${content.id}`}

{content.meta_description && (

{content.meta_description}

)}
{/* Metadata Section */}
{/* Basic Info */}

Basic Information

Generated

{formatDate(content.generated_at)}

{content.updated_at && content.updated_at !== content.generated_at && (

Last Updated

{formatDate(content.updated_at)}

)}

Word Count

{content.word_count?.toLocaleString() || 'N/A'} words

{/* Task & Sector Info */}

Related Information

{content.task_title && (

Task

{content.task_title}

ID: {content.task_id}

)} {content.sector_name && (

Sector

{content.sector_name}

)} {content.cluster_name && (

Cluster

{content.cluster_name}

)} {/* Categories */} {content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (

Category

{content.taxonomy_terms_data .filter(term => term.taxonomy_type === 'category') .map((category) => ( {category.name} ))}
)} {/* Tags */} {content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (

Tags

{content.taxonomy_terms_data .filter(term => term.taxonomy_type === 'tag') .map((tag) => ( {tag.name} ))}
)}
{/* Keywords & Tags */}

SEO & Tags

{content.meta_title && (

Meta Title

{content.meta_title}

)} {content.meta_description && (

Meta Description

{content.meta_description}

)} {content.primary_keyword && (

Primary Keyword

{content.primary_keyword}

)} {content.secondary_keywords && content.secondary_keywords.length > 0 && (

Secondary Keywords

{content.secondary_keywords.map((keyword, idx) => ( {keyword} ))}
)}
{/* Action Buttons - Conditional based on status */} {content.status && (
{/* Draft status: Show Edit Content + Generate Images */} {content.status.toLowerCase() === 'draft' && ( <> )} {/* Review status: Show Edit Content + Publish */} {content.status.toLowerCase() === 'review' && ( <> )}
{/* Publishing Status Display */} {content.site_status && (
{content.site_status === 'published' && ( )} {content.site_status === 'scheduled' && ( )} {content.site_status === 'publishing' && ( )} {content.site_status === 'failed' && ( )} {content.site_status === 'not_published' && ( )} {content.site_status === 'not_published' && 'Not Published'} {content.site_status === 'scheduled' && 'Scheduled'} {content.site_status === 'publishing' && 'Publishing...'} {content.site_status === 'published' && 'Published'} {content.site_status === 'failed' && 'Failed'}
{content.scheduled_publish_at && content.site_status === 'scheduled' && ( {formatDate(content.scheduled_publish_at)} )} {content.external_url && content.site_status === 'published' && ( View on site → )}
)}
)} {/* Image Status */} {(content.has_image_prompts || content.has_generated_images) && (
{content.has_image_prompts && (
Image Prompts Generated
)} {content.has_generated_images && (
Images Generated
)}
)} {/* Featured Image */} {shouldShowFeaturedBlock && (
)} {imagesError && (
{imagesError}
)} {/* Article Body */} {/* Metadata JSON (Collapsible) */} {content.metadata && Object.keys(content.metadata).length > 0 && (
View Full Metadata
                    {JSON.stringify(content.metadata, null, 2)}
                  
)}
{/* Custom Styles for Content HTML */}
); }