content view template final version
This commit is contained in:
@@ -197,12 +197,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
|||||||
prompt_text = str(prompt_data)
|
prompt_text = str(prompt_data)
|
||||||
caption_text = ''
|
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(
|
Images.objects.update_or_create(
|
||||||
content=content,
|
content=content,
|
||||||
image_type='in_article',
|
image_type='in_article',
|
||||||
position=idx + 1,
|
position=idx, # 0-based position matching section array indices
|
||||||
defaults={
|
defaults={
|
||||||
'prompt': prompt_text,
|
'prompt': prompt_text,
|
||||||
'caption': caption_text,
|
'caption': caption_text,
|
||||||
|
|||||||
@@ -345,19 +345,76 @@ const IntroBlock = ({ html }: { html: string }) => (
|
|||||||
</section>
|
</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 = ({
|
const ContentSectionBlock = ({
|
||||||
section,
|
section,
|
||||||
image,
|
image,
|
||||||
loading,
|
loading,
|
||||||
index,
|
index,
|
||||||
|
imagePlacement = 'right',
|
||||||
|
firstImage = null,
|
||||||
}: {
|
}: {
|
||||||
section: ArticleSection;
|
section: ArticleSection;
|
||||||
image: ImageRecord | null;
|
image: ImageRecord | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
|
imagePlacement?: 'left' | 'center' | 'right';
|
||||||
|
firstImage?: ImageRecord | null;
|
||||||
}) => {
|
}) => {
|
||||||
const hasImage = Boolean(image);
|
const hasImage = Boolean(image);
|
||||||
const headingLabel = section.heading || `Section ${index + 1}`;
|
const headingLabel = section.heading || `Section ${index + 1}`;
|
||||||
|
const sectionHasTable = hasTable(section.bodyHtml);
|
||||||
|
const { beforeH3, h3AndAfter } = splitAtFirstH3(section.bodyHtml);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id={section.id} className="group/section scroll-mt-24">
|
<section id={section.id} className="group/section scroll-mt-24">
|
||||||
@@ -377,16 +434,86 @@ const ContentSectionBlock = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={hasImage ? 'grid gap-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]' : ''}>
|
{imagePlacement === 'center' && hasImage ? (
|
||||||
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert ${hasImage ? '' : ''}`}>
|
<div className="flex flex-col gap-10">
|
||||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
{/* Content before H3 */}
|
||||||
</div>
|
{beforeH3 && (
|
||||||
{hasImage && (
|
<div className={`content-html prose prose-lg max-w-none text-gray-800 dark:prose-invert`}>
|
||||||
<div className="flex flex-col gap-4">
|
<div dangerouslySetInnerHTML={{ __html: beforeH3 }} />
|
||||||
<SectionImageBlock image={image} loading={loading} heading={headingLabel} />
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -404,6 +531,14 @@ interface ArticleBodyProps {
|
|||||||
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
|
const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtml }: ArticleBodyProps) => {
|
||||||
const hasStructuredSections = sections.length > 0;
|
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) {
|
if (!hasStructuredSections && !introHtml && rawHtml) {
|
||||||
return (
|
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="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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{introHtml && <IntroBlock html={introHtml} />}
|
{introHtml && <IntroBlock html={introHtml} />}
|
||||||
@@ -424,6 +562,8 @@ const ArticleBody = ({ introHtml, sections, sectionImages, imagesLoading, rawHtm
|
|||||||
image={sectionImages[index] ?? null}
|
image={sectionImages[index] ?? null}
|
||||||
loading={imagesLoading}
|
loading={imagesLoading}
|
||||||
index={index}
|
index={index}
|
||||||
|
imagePlacement={getImagePlacement(index)}
|
||||||
|
firstImage={firstImage}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -535,13 +675,13 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
|
|
||||||
const byPosition = new Map<number, ImageRecord>();
|
const byPosition = new Map<number, ImageRecord>();
|
||||||
sorted.forEach((img, index) => {
|
sorted.forEach((img, index) => {
|
||||||
const pos = img.position ?? index + 1;
|
const pos = img.position ?? index;
|
||||||
byPosition.set(pos, img);
|
byPosition.set(pos, img);
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedPositions = new Set<number>();
|
const usedPositions = new Set<number>();
|
||||||
const merged: ImageRecord[] = prompts.map((prompt, index) => {
|
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);
|
const existing = byPosition.get(position);
|
||||||
usedPositions.add(position);
|
usedPositions.add(position);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -561,7 +701,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
image_path: undefined,
|
image_path: undefined,
|
||||||
prompt,
|
prompt,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
position,
|
position, // 0-based position
|
||||||
created_at: '',
|
created_at: '',
|
||||||
updated_at: '',
|
updated_at: '',
|
||||||
account_id: undefined,
|
account_id: undefined,
|
||||||
@@ -569,7 +709,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
});
|
});
|
||||||
|
|
||||||
sorted.forEach((img, idx) => {
|
sorted.forEach((img, idx) => {
|
||||||
const position = img.position ?? idx + 1;
|
const position = img.position ?? idx;
|
||||||
if (!usedPositions.has(position)) {
|
if (!usedPositions.has(position)) {
|
||||||
merged.push(img);
|
merged.push(img);
|
||||||
}
|
}
|
||||||
@@ -596,7 +736,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
<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="animate-pulse">
|
||||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
|
<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="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) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
<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">
|
<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" />
|
<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>
|
<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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
<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 */}
|
{/* Back Button */}
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
@@ -676,7 +816,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Card */}
|
{/* 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 */}
|
{/* Header Section */}
|
||||||
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
|
<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 items-start justify-between gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user