1346 lines
50 KiB
TypeScript
1346 lines
50 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';
|
|
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<string, string> = {
|
|
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<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-gray-100 text-gray-700 dark:bg-gray-700/70 dark:text-gray-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-gray-100 via-gray-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-gray-500 dark:text-gray-400">
|
|
{label}
|
|
</p>
|
|
)}
|
|
<p className="text-sm font-medium leading-relaxed text-gray-600 dark:text-gray-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-gray-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-gray-500 dark:text-gray-400">
|
|
Featured Visual
|
|
</div>
|
|
<ImageStatusPill status={image?.status} />
|
|
</div>
|
|
<div className="relative mt-6">
|
|
{loading && !imageSrc ? (
|
|
<div className="h-[420px] animate-pulse bg-gray-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-gray-600 backdrop-blur-sm dark:bg-gray-950/70 dark:text-gray-300">
|
|
Caption aligned to hero section
|
|
</div>
|
|
)}
|
|
</div>
|
|
{image?.caption && (
|
|
<div className="border-t border-gray-200/70 bg-white/70 px-8 py-6 text-sm leading-relaxed text-gray-600 backdrop-blur-sm dark:border-gray-800/60 dark:bg-gray-900/70 dark:text-gray-300">
|
|
{image.caption}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<figure className="overflow-hidden rounded-3xl border border-gray-200/70 bg-gray-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-gray-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>
|
|
{showPrompt && image?.caption && (
|
|
<figcaption className="space-y-3 px-6 py-5 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
|
<p className="font-semibold uppercase tracking-[0.25em] text-gray-400 dark:text-gray-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-gray-200/80 bg-gradient-to-br from-white via-gray-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-gray-500 dark:text-gray-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),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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 id={section.id} className="group/section scroll-mt-24">
|
|
<div className="overflow-hidden rounded-3xl border border-gray-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">
|
|
{/* Section header */}
|
|
<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-gray-400 dark:text-gray-500">
|
|
Section Spotlight
|
|
</span>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
|
|
{headingLabel}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content layout with images */}
|
|
<div className="flex flex-col gap-10">
|
|
{/* Square images (left/right aligned) - content and image in same row */}
|
|
{aspectRatio === 'square' && (imageAlign === 'left' || imageAlign === 'right') && hasImage ? (
|
|
<div className={`flex ${imageAlign === 'right' ? 'flex-row' : 'flex-row-reverse'} gap-8 items-start`}>
|
|
{/* Image side (48% width) */}
|
|
<div className="w-[48%] flex-shrink-0">
|
|
<SectionImageBlock image={image} loading={loading} heading={headingLabel} showPrompt={showDescription} />
|
|
</div>
|
|
|
|
{/* Content side (48% width with auto remaining) */}
|
|
<div className="flex-1">
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* 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 structure 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>
|
|
) : (
|
|
<>
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* Landscape image - full width centered */}
|
|
{hasImage && aspectRatio === 'landscape' && (
|
|
<div className="flex justify-center">
|
|
<div className={getImageContainerClass()}>
|
|
<SectionImageBlock image={image} loading={loading} heading={headingLabel} showPrompt={showDescription} />
|
|
</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 structure 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>
|
|
</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;
|
|
|
|
// 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 (
|
|
<div className="overflow-hidden rounded-3xl border border-gray-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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-12">
|
|
{introHtml && <IntroBlock html={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 (
|
|
<ContentSectionBlock
|
|
key={section.id || `section-${sectionIndex}`}
|
|
section={section}
|
|
image={image}
|
|
loading={imagesLoading}
|
|
index={sectionIndex}
|
|
aspectRatio={aspectRatio}
|
|
imageAlign={imageAlign}
|
|
showDescription={showDescription}
|
|
/>
|
|
);
|
|
})}
|
|
</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-[1280px] 2xl:max-w-[1530px] 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-[1280px] 2xl:max-w-[1530px] 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
|
|
variant="primary"
|
|
onClick={onBack}
|
|
startIcon={<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-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<string, string> = {
|
|
'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 (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
|
<div className="max-w-[1280px] 2xl:max-w-[1530px] mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Back Button */}
|
|
{onBack && (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onBack}
|
|
className="mb-6"
|
|
startIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
|
>
|
|
Back to Content List
|
|
</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)}`}>
|
|
{getStatusLabel(content.status, !!content.external_id)}
|
|
</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 justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-4">
|
|
{/* Draft status: Show Edit Content + Generate Images */}
|
|
{content.status.toLowerCase() === 'draft' && (
|
|
<>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
|
startIcon={<PencilIcon className="w-4 h-4" />}
|
|
>
|
|
Edit Content
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
tone="brand"
|
|
onClick={() => navigate(`/writer/images?contentId=${content.id}`)}
|
|
startIcon={<ImageIcon className="w-4 h-4" />}
|
|
>
|
|
Generate Images
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Review status: Show Edit Content + Publish */}
|
|
{content.status.toLowerCase() === 'review' && (
|
|
<>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => navigate(`/sites/${content.site_id}/posts/${content.id}/edit`)}
|
|
startIcon={<PencilIcon className="w-4 h-4" />}
|
|
>
|
|
Edit Content
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
tone="brand"
|
|
onClick={() => navigate(`/writer/published?contentId=${content.id}&action=publish`)}
|
|
startIcon={<BoltIcon className="w-4 h-4" />}
|
|
>
|
|
Publish
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Publishing Status Display */}
|
|
{content.site_status && (
|
|
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center gap-2">
|
|
{content.site_status === 'published' && (
|
|
<CheckCircleIcon className="w-5 h-5 text-success-500" />
|
|
)}
|
|
{content.site_status === 'scheduled' && (
|
|
<ClockIcon className="w-5 h-5 text-brand-500" />
|
|
)}
|
|
{content.site_status === 'publishing' && (
|
|
<ClockIcon className="w-5 h-5 text-warning-500 animate-pulse" />
|
|
)}
|
|
{content.site_status === 'failed' && (
|
|
<XCircleIcon className="w-5 h-5 text-error-500" />
|
|
)}
|
|
{content.site_status === 'not_published' && (
|
|
<FileTextIcon className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{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'}
|
|
</span>
|
|
</div>
|
|
{content.scheduled_publish_at && content.site_status === 'scheduled' && (
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{formatDate(content.scheduled_publish_at)}
|
|
</span>
|
|
)}
|
|
{content.external_url && content.site_status === 'published' && (
|
|
<a
|
|
href={content.external_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
|
>
|
|
View on site →
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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-success-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-success-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-[1024px] mx-auto">
|
|
<FeaturedImageBlock image={resolvedFeaturedImage} loading={imagesLoading} />
|
|
</div>
|
|
)}
|
|
|
|
{imagesError && (
|
|
<div className="px-8 pt-4">
|
|
<div className="rounded-2xl border border-error-200 bg-error-50/80 px-4 py-3 text-sm font-medium text-error-700 dark:border-error-900/40 dark:bg-error-950/30 dark:text-error-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 var(--color-gray-200);
|
|
padding: 0.875rem 1rem;
|
|
text-align: left;
|
|
}
|
|
.content-html table th {
|
|
background: var(--color-gray-50);
|
|
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: var(--color-brand-600);
|
|
text-decoration: none;
|
|
border-bottom: 1px solid color-mix(in oklch, var(--color-brand-600) 30%, transparent);
|
|
transition: color 0.2s ease, border-bottom-color 0.2s ease;
|
|
}
|
|
.content-html a:hover {
|
|
color: var(--color-brand-700);
|
|
border-bottom-color: color-mix(in oklch, var(--color-brand-600) 60%, transparent);
|
|
}
|
|
.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: var(--color-brand-300);
|
|
border-bottom-color: color-mix(in oklch, var(--color-brand-300) 40%, transparent);
|
|
}
|
|
.dark .content-html a:hover {
|
|
color: var(--color-brand-200);
|
|
border-bottom-color: color-mix(in oklch, var(--color-brand-200) 60%, transparent);
|
|
}
|
|
.dark .content-html code,
|
|
.dark .content-html pre {
|
|
background: rgba(15, 23, 42, 0.7);
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|