content view template final version
This commit is contained in:
@@ -197,12 +197,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
||||
prompt_text = str(prompt_data)
|
||||
caption_text = ''
|
||||
|
||||
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}"
|
||||
heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx}"
|
||||
|
||||
Images.objects.update_or_create(
|
||||
content=content,
|
||||
image_type='in_article',
|
||||
position=idx + 1,
|
||||
position=idx, # 0-based position matching section array indices
|
||||
defaults={
|
||||
'prompt': prompt_text,
|
||||
'caption': caption_text,
|
||||
|
||||
@@ -345,19 +345,76 @@ const IntroBlock = ({ html }: { html: string }) => (
|
||||
</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">
|
||||
@@ -377,16 +434,86 @@ const ContentSectionBlock = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={hasImage ? 'grid gap-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]' : ''}>
|
||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert ${hasImage ? '' : ''}`}>
|
||||
{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>
|
||||
{hasImage && (
|
||||
)}
|
||||
</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>
|
||||
@@ -404,6 +531,14 @@ interface ArticleBodyProps {
|
||||
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">
|
||||
@@ -414,6 +549,9 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
||||
);
|
||||
}
|
||||
|
||||
// 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} />}
|
||||
@@ -424,6 +562,8 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
||||
image={sectionImages[index] ?? null}
|
||||
loading={imagesLoading}
|
||||
index={index}
|
||||
imagePlacement={getImagePlacement(index)}
|
||||
firstImage={firstImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -535,13 +675,13 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
|
||||
const byPosition = new Map<number, ImageRecord>();
|
||||
sorted.forEach((img, index) => {
|
||||
const pos = img.position ?? index + 1;
|
||||
const pos = img.position ?? index;
|
||||
byPosition.set(pos, img);
|
||||
});
|
||||
|
||||
const usedPositions = new Set<number>();
|
||||
const merged: ImageRecord[] = prompts.map((prompt, index) => {
|
||||
const position = index + 1;
|
||||
const position = index; // 0-based position matching section array index
|
||||
const existing = byPosition.get(position);
|
||||
usedPositions.add(position);
|
||||
if (existing) {
|
||||
@@ -561,7 +701,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
image_path: undefined,
|
||||
prompt,
|
||||
status: 'pending',
|
||||
position,
|
||||
position, // 0-based position
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
account_id: undefined,
|
||||
@@ -569,7 +709,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
});
|
||||
|
||||
sorted.forEach((img, idx) => {
|
||||
const position = img.position ?? idx + 1;
|
||||
const position = img.position ?? idx;
|
||||
if (!usedPositions.has(position)) {
|
||||
merged.push(img);
|
||||
}
|
||||
@@ -596,7 +736,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-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>
|
||||
@@ -613,7 +753,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-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>
|
||||
@@ -663,7 +803,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Back Button */}
|
||||
{onBack && (
|
||||
<button
|
||||
@@ -676,7 +816,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
)}
|
||||
|
||||
{/* Main Content Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-800 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">
|
||||
|
||||
Reference in New Issue
Block a user