Files
igny8/frontend/src/templates/ContentViewTemplate.tsx
IGNY8 VPS (Salman) 178b7c23ce Section 3 Completed
2025-12-27 02:43:46 +00:00

1232 lines
44 KiB
TypeScript

/**
* 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:
* <ContentViewTemplate
* content={contentData}
* loading={false}
* onBack={() => 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';
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<string, string> = {
generated: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
queued: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
error: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-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<object>();
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<string, unknown>;
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-slate-100 text-slate-700 dark:bg-slate-700/70 dark:text-slate-200';
return (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide ${classes} ${className}`}>
{status}
</span>
);
};
const PromptPlaceholder = ({
prompt,
label,
minHeight = 220,
}: {
prompt?: string | null;
label?: string;
minHeight?: number;
}) => (
<div
className="flex w-full items-center justify-center rounded-3xl bg-gradient-to-br from-slate-100 via-slate-50 to-white p-8 text-center dark:from-gray-800 dark:via-gray-900 dark:to-gray-950"
style={{ minHeight }}
>
<div className="max-w-xl space-y-3">
{label && (
<p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
{label}
</p>
)}
<p className="text-sm font-medium leading-relaxed text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
{prompt || 'Image prompt available, awaiting generation.'}
</p>
</div>
</div>
);
const FeaturedImageBlock = ({
image,
loading,
}: {
image: ImageRecord | null;
loading: boolean;
}) => {
const imageSrc = getImageSrc(image);
if (!loading && !image && !imageSrc) {
return null;
}
return (
<div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white/80 shadow-lg shadow-slate-200/40 backdrop-blur-sm dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/10">
<div className="flex items-center justify-between px-8 pt-8">
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
Featured Visual
</div>
<ImageStatusPill status={image?.status} />
</div>
<div className="relative mt-6">
{loading && !imageSrc ? (
<div className="h-[420px] animate-pulse bg-slate-200/70 dark:bg-gray-800/60" />
) : imageSrc ? (
<img
src={imageSrc}
alt={image?.prompt ? `Featured visual: ${image.prompt}` : 'Featured visual'}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<PromptPlaceholder prompt={image?.prompt} minHeight={420} label="Featured Image Prompt" />
)}
{image?.caption && imageSrc && (
<div className="absolute bottom-5 left-5 rounded-full bg-white/80 px-4 py-2 text-xs font-medium text-slate-600 backdrop-blur-sm dark:bg-gray-950/70 dark:text-slate-300">
Caption aligned to hero section
</div>
)}
</div>
{image?.caption && (
<div className="border-t border-slate-200/70 bg-white/70 px-8 py-6 text-sm leading-relaxed text-slate-600 backdrop-blur-sm dark:border-gray-800/60 dark:bg-gray-900/70 dark:text-slate-300">
{image.caption}
</div>
)}
</div>
);
};
const SectionImageBlock = ({
image,
loading,
heading,
}: {
image: ImageRecord | null;
loading: boolean;
heading: string;
}) => {
if (!image && !loading) return null;
const imageSrc = getImageSrc(image);
return (
<figure className="overflow-hidden rounded-3xl border border-slate-200/70 bg-slate-50/70 shadow-inner shadow-slate-200/70 dark:border-gray-800/60 dark:bg-gray-900/40 dark:shadow-black/30">
<div className="relative">
{loading && !image ? (
<div className="h-[260px] animate-pulse bg-slate-200/60 dark:bg-gray-800/60" />
) : imageSrc ? (
<img
src={imageSrc}
alt={image?.prompt ? `${heading} visual: ${image.prompt}` : `${heading} visual`}
className="w-full object-cover"
loading="lazy"
/>
) : (
<PromptPlaceholder prompt={image?.prompt} minHeight={260} label="In-Article Prompt" />
)}
<div className="absolute right-4 top-4">
<ImageStatusPill status={image?.status} />
</div>
</div>
{image?.caption && (
<figcaption className="space-y-3 px-6 py-5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<p className="font-semibold uppercase tracking-[0.25em] text-slate-400 dark:text-slate-500">
Image Caption
</p>
<p className="font-medium whitespace-pre-wrap">{image.caption}</p>
</figcaption>
)}
</figure>
);
};
const IntroBlock = ({ html }: { html: string }) => (
<section className="overflow-hidden rounded-3xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-white p-8 shadow-sm shadow-slate-200/60 dark:border-gray-800/70 dark:from-gray-900 dark:via-gray-950 dark:to-gray-900 dark:shadow-black/20">
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
Opening Narrative
</div>
<div className="content-html prose prose-lg mt-6 max-w-none text-gray-800 dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
</section>
);
// 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),
};
};
// Helper to check if section contains a table
const hasTable = (html: string): boolean => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return doc.querySelector('table') !== null;
};
const ContentSectionBlock = ({
section,
image,
loading,
index,
imagePlacement = 'right',
firstImage = null,
}: {
section: ArticleSection;
image: ImageRecord | null;
loading: boolean;
index: number;
imagePlacement?: 'left' | 'center' | 'right';
firstImage?: ImageRecord | null;
}) => {
const hasImage = Boolean(image);
const headingLabel = section.heading || `Section ${index + 1}`;
const sectionHasTable = hasTable(section.bodyHtml);
const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml);
return (
<section id={section.id} className="group/section scroll-mt-24">
<div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 shadow-lg shadow-slate-200/50 backdrop-blur-sm transition-transform duration-300 group-hover/section:-translate-y-1 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
<div className="flex flex-col gap-6 p-8 sm:p-10">
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-brand-500/10 text-sm font-semibold text-brand-600 dark:bg-brand-500/20 dark:text-brand-300">
{index + 1}
</span>
<div className="flex flex-col">
<span className="text-[0.7rem] font-semibold uppercase tracking-[0.35em] text-slate-400 dark:text-slate-500">
Section Spotlight
</span>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white sm:text-3xl">
{headingLabel}
</h2>
</div>
</div>
{imagePlacement === 'center' && hasImage ? (
<div className="flex flex-col gap-10">
{/* Content before H3 */}
{beforeH3 && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
</div>
)}
{/* Centered image before H3 */}
<div className="flex justify-center">
<div className="w-full max-w-[60%]">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
</div>
{/* H3 and remaining content */}
{h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
</div>
)}
{/* Fallback if no H3 found */}
{!beforeH3 && !h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
)}
</div>
) : sectionHasTable && hasImage && firstImage ? (
<div className="flex flex-col gap-10">
{/* Content before H3 */}
{beforeH3 && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
</div>
)}
{/* Two images side by side at 50% width each */}
<div className="grid grid-cols-2 gap-6">
<div className="w-full">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
<div className="w-full">
<SectionImageBlock image={firstImage} loading={loading} heading="First Article Image" />
</div>
</div>
{/* H3 and remaining content */}
{h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: h3AndAfter }} />
</div>
)}
{/* Fallback if no H3 found */}
{!beforeH3 && !h3AndAfter && (
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
)}
</div>
) : (
<div className={hasImage ? `grid gap-10 ${imagePlacement === 'left' ? 'lg:grid-cols-[minmax(0,40%)_minmax(0,60%)]' : 'lg:grid-cols-[minmax(0,60%)_minmax(0,40%)]'}` : ''}>
{imagePlacement === 'left' && hasImage && (
<div className="flex flex-col gap-4">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
)}
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
</div>
{imagePlacement === 'right' && hasImage && (
<div className="flex flex-col gap-4">
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
</div>
)}
</div>
)}
</div>
</div>
</section>
);
};
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;
// Calculate image placement: right → center → left → repeat
const getImagePlacement = (index: number): 'left' | 'center' | 'right' => {
const position = index % 3;
if (position === 0) return 'right';
if (position === 1) return 'center';
return 'left';
};
if (!hasStructuredSections && !introHtml && rawHtml) {
return (
<div className="overflow-hidden rounded-3xl border border-slate-200/80 bg-white/90 p-8 shadow-lg shadow-slate-200/50 dark:border-gray-800/70 dark:bg-gray-900/70 dark:shadow-black/20">
<div className="content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert">
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
</div>
</div>
);
}
// Get the first in-article image (position 0)
const firstImage = sectionImages.length > 0 ? sectionImages[0] : null;
return (
<div className="space-y-12">
{introHtml && <IntroBlock html={introHtml} />}
{sections.map((section, index) => (
<ContentSectionBlock
key={section.id || `section-${index}`}
section={section}
image={sectionImages[index] ?? null}
loading={imagesLoading}
index={index}
imagePlacement={getImagePlacement(index)}
firstImage={firstImage}
/>
))}
</div>
);
};
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
const navigate = useNavigate();
const [imageRecords, setImageRecords] = useState<ImageRecord[]>([]);
const [imagesLoading, setImagesLoading] = useState(false);
const [imagesError, setImagesError] = useState<string | null>(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<number, ImageRecord>();
sorted.forEach((img, index) => {
const pos = img.position ?? index;
byPosition.set(pos, img);
});
const usedPositions = new Set<number>();
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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
);
}
if (!content) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<XCircleIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">The content you're looking for doesn't exist or has been deleted.</p>
{onBack && (
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Content List
</button>
)}
</div>
</div>
</div>
);
}
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-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
}
if (statusLower === 'pending' || statusLower === 'draft') {
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
}
if (statusLower === 'failed' || statusLower === 'error') {
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
}
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Button */}
{onBack && (
<button
onClick={onBack}
className="mb-6 inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeftIcon className="w-5 h-5" />
<span className="font-medium">Back to Content</span>
</button>
)}
{/* Main Content Card */}
<div className="overflow-hidden">
{/* Header Section */}
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<FileTextIcon className="w-6 h-6" />
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(content.status)}`}>
{content.status}
</span>
</div>
<h1 className="text-3xl font-bold mb-2">
{content.meta_title || content.title || `Content #${content.id}`}
</h1>
{content.meta_description && (
<p className="text-brand-50 text-lg leading-relaxed">
{content.meta_description}
</p>
)}
</div>
</div>
</div>
{/* Metadata Section */}
<div className="px-8 py-6 border-b border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Basic Information
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Generated</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formatDate(content.generated_at)}
</p>
</div>
</div>
{content.updated_at && content.updated_at !== content.generated_at && (
<div className="flex items-start gap-3">
<ClockIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Last Updated</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formatDate(content.updated_at)}
</p>
</div>
</div>
)}
<div className="flex items-start gap-3">
<FileTextIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Word Count</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.word_count?.toLocaleString() || 'N/A'} words
</p>
</div>
</div>
</div>
</div>
{/* Task & Sector Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Related Information
</h3>
<div className="space-y-3">
{content.task_title && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Task</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.task_title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">ID: {content.task_id}</p>
</div>
)}
{content.sector_name && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Sector</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.sector_name}
</p>
</div>
)}
{content.cluster_name && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Cluster</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.cluster_name}
</p>
</div>
)}
{/* Categories */}
{content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Category</p>
<div className="flex flex-wrap gap-2">
{content.taxonomy_terms_data
.filter(term => term.taxonomy_type === 'category')
.map((category) => (
<span
key={category.id}
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
>
{category.name}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Tags</p>
<div className="flex flex-wrap gap-2">
{content.taxonomy_terms_data
.filter(term => term.taxonomy_type === 'tag')
.map((tag) => (
<span
key={tag.id}
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
>
{tag.name}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Keywords & Tags */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
SEO & Tags
</h3>
<div className="space-y-3">
{content.meta_title && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Meta Title</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.meta_title}
</p>
</div>
)}
{content.meta_description && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Meta Description</p>
<p className="text-sm text-gray-700 dark:text-gray-300">
{content.meta_description}
</p>
</div>
)}
{content.primary_keyword && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Primary Keyword</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.primary_keyword}
</p>
</div>
)}
{content.secondary_keywords && content.secondary_keywords.length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Secondary Keywords</p>
<div className="flex flex-wrap gap-2">
{content.secondary_keywords.map((keyword, idx) => (
<span
key={idx}
className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs"
>
{keyword}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Action Buttons - Conditional based on status */}
{content.status && (
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
{/* Draft status: Show Edit Content + Generate Images */}
{content.status.toLowerCase() === 'draft' && (
<>
<button
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white rounded-lg font-medium transition-colors"
>
<PencilIcon className="w-4 h-4" />
Edit Content
</button>
<button
onClick={() => navigate(`/writer/images?contentId=${content.id}`)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg font-medium transition-colors"
>
<ImageIcon className="w-4 h-4" />
Generate Images
</button>
</>
)}
{/* Review status: Show Edit Content + Publish */}
{content.status.toLowerCase() === 'review' && (
<>
<button
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 hover:bg-brand-600 text-white rounded-lg font-medium transition-colors"
>
<PencilIcon className="w-4 h-4" />
Edit Content
</button>
<button
onClick={() => navigate(`/writer/published?contentId=${content.id}&action=publish`)}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition-colors"
>
<BoltIcon className="w-4 h-4" />
Publish
</button>
</>
)}
</div>
</div>
)}
{/* Image Status */}
{(content.has_image_prompts || content.has_generated_images) && (
<div className="px-8 py-4 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
{content.has_image_prompts && (
<div className="flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">Image Prompts Generated</span>
</div>
)}
{content.has_generated_images && (
<div className="flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">Images Generated</span>
</div>
)}
</div>
</div>
)}
{/* Featured Image */}
{shouldShowFeaturedBlock && (
<div className="mb-12 max-w-[800px] mx-auto">
<FeaturedImageBlock image={resolvedFeaturedImage} loading={imagesLoading} />
</div>
)}
{imagesError && (
<div className="px-8 pt-4">
<div className="rounded-2xl border border-rose-200 bg-rose-50/80 px-4 py-3 text-sm font-medium text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-300">
{imagesError}
</div>
</div>
)}
{/* Article Body */}
<ArticleBody
introHtml={parsedArticle.introHtml}
sections={parsedArticle.sections}
sectionImages={resolvedInArticleImages}
imagesLoading={imagesLoading}
rawHtml={content.content_html}
/>
{/* Metadata JSON (Collapsible) */}
{content.metadata && Object.keys(content.metadata).length > 0 && (
<div className="px-8 py-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<details className="group">
<summary className="cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
View Full Metadata
</summary>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
{JSON.stringify(content.metadata, null, 2)}
</pre>
</div>
</details>
</div>
)}
</div>
</div>
{/* Custom Styles for Content HTML */}
<style>{`
.content-html {
line-height: 1.85;
font-size: 1.05rem;
}
.content-html h2,
.content-html h3,
.content-html h4,
.content-html h5,
.content-html h6 {
margin-top: 2.5rem;
margin-bottom: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
color: inherit;
}
.content-html h3 { font-size: 1.6rem; }
.content-html h4 { font-size: 1.35rem; }
.content-html h5 { font-size: 1.15rem; }
.content-html p {
margin-bottom: 1.3rem;
}
.content-html ul,
.content-html ol {
margin-bottom: 1.5rem;
padding-left: 1.75rem;
}
.content-html li {
margin-bottom: 0.6rem;
}
.content-html blockquote {
margin: 2rem 0;
padding: 1.25rem 1.5rem;
border-left: 4px solid rgba(59, 130, 246, 0.25);
background: rgba(59, 130, 246, 0.08);
border-radius: 1rem;
font-style: italic;
}
.content-html table {
width: 100%;
border-collapse: collapse;
margin: 2rem 0;
border-radius: 1rem;
overflow: hidden;
}
.content-html table th,
.content-html table td {
border: 1px solid #e5e7eb;
padding: 0.875rem 1rem;
text-align: left;
}
.content-html table th {
background: #f9fafb;
font-weight: 600;
}
.content-html img {
max-width: 100%;
height: auto;
border-radius: 1.25rem;
margin: 1.75rem auto;
display: block;
box-shadow: 0 20px 45px -25px rgba(15, 23, 42, 0.35);
}
.content-html a {
color: #2563eb;
text-decoration: none;
border-bottom: 1px solid rgba(37, 99, 235, 0.3);
transition: color 0.2s ease, border-bottom-color 0.2s ease;
}
.content-html a:hover {
color: #1d4ed8;
border-bottom-color: rgba(37, 99, 235, 0.6);
}
.content-html code {
background: rgba(15, 23, 42, 0.06);
padding: 0.2rem 0.45rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.content-html pre {
background: rgba(15, 23, 42, 0.08);
padding: 1.25rem;
border-radius: 1.25rem;
overflow-x: auto;
margin: 2rem 0;
}
.content-html hr {
border: none;
border-top: 1px solid rgba(148, 163, 184, 0.4);
margin: 3rem 0;
}
.dark .content-html blockquote {
border-left-color: rgba(96, 165, 250, 0.45);
background: rgba(30, 41, 59, 0.65);
}
.dark .content-html table th,
.dark .content-html table td {
border-color: rgba(148, 163, 184, 0.25);
}
.dark .content-html table th {
background: rgba(30, 41, 59, 0.65);
}
.dark .content-html a {
color: #93c5fd;
border-bottom-color: rgba(147, 197, 253, 0.4);
}
.dark .content-html a:hover {
color: #bfdbfe;
border-bottom-color: rgba(191, 219, 254, 0.6);
}
.dark .content-html code,
.dark .content-html pre {
background: rgba(15, 23, 42, 0.7);
}
`}</style>
</div>
);
}