Merge branch 'main' of https://git.igny8.com/salman/igny8
This commit is contained in:
@@ -1028,6 +1028,40 @@ export interface ContentImagesResponse {
|
||||
results: ContentImagesGroup[];
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
id: number;
|
||||
task_id?: number | null;
|
||||
task_title?: string | null;
|
||||
content_id?: number | null;
|
||||
content_title?: string | null;
|
||||
image_type: string;
|
||||
image_url?: string | null;
|
||||
image_path?: string | null;
|
||||
prompt?: string | null;
|
||||
status: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
account_id?: number | null;
|
||||
}
|
||||
|
||||
export interface ImageListResponse {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: ImageRecord[];
|
||||
}
|
||||
|
||||
export interface ImageFilters {
|
||||
content_id?: number;
|
||||
task_id?: number;
|
||||
image_type?: string;
|
||||
status?: string;
|
||||
ordering?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function fetchContentImages(): Promise<ContentImagesResponse> {
|
||||
return fetchAPI('/v1/writer/images/content_images/');
|
||||
}
|
||||
@@ -1049,6 +1083,20 @@ export async function generateImages(imageIds: number[], contentId?: number): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchImages(filters: ImageFilters = {}): Promise<ImageListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.content_id) params.append('content_id', filters.content_id.toString());
|
||||
if (filters.task_id) params.append('task_id', filters.task_id.toString());
|
||||
if (filters.image_type) params.append('image_type', filters.image_type);
|
||||
if (filters.status) params.append('status', filters.status);
|
||||
if (filters.ordering) params.append('ordering', filters.ordering);
|
||||
if (filters.page) params.append('page', filters.page.toString());
|
||||
if (filters.page_size) params.append('page_size', filters.page_size.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/writer/images/${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
export interface ImageGenerationSettings {
|
||||
success: boolean;
|
||||
config: {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Content } from '../services/api';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Content, fetchImages, ImageRecord } from '../services/api';
|
||||
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../icons';
|
||||
|
||||
interface ContentViewTemplateProps {
|
||||
@@ -25,7 +25,572 @@ interface ContentViewTemplateProps {
|
||||
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?.prompt && 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">
|
||||
Prompt aligned to hero section
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{image?.prompt && (
|
||||
<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.prompt}
|
||||
</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?.prompt && (
|
||||
<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">
|
||||
Visual Direction
|
||||
</p>
|
||||
<p className="font-medium whitespace-pre-wrap">{image.prompt}</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>
|
||||
);
|
||||
|
||||
const ContentSectionBlock = ({
|
||||
section,
|
||||
image,
|
||||
loading,
|
||||
index,
|
||||
}: {
|
||||
section: ArticleSection;
|
||||
image: ImageRecord | null;
|
||||
loading: boolean;
|
||||
index: number;
|
||||
}) => {
|
||||
const hasImage = Boolean(image);
|
||||
const headingLabel = section.heading || `Section ${index + 1}`;
|
||||
|
||||
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>
|
||||
|
||||
<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 ? '' : ''}`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: section.bodyHtml }} />
|
||||
</div>
|
||||
{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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
|
||||
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 + 1;
|
||||
byPosition.set(pos, img);
|
||||
});
|
||||
|
||||
const usedPositions = new Set<number>();
|
||||
const merged: ImageRecord[] = prompts.map((prompt, index) => {
|
||||
const position = index + 1;
|
||||
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,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
account_id: undefined,
|
||||
} as ImageRecord;
|
||||
});
|
||||
|
||||
sorted.forEach((img, idx) => {
|
||||
const position = img.position ?? idx + 1;
|
||||
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?.html_content ?? ''),
|
||||
[content?.html_content]
|
||||
);
|
||||
|
||||
const shouldShowFeaturedBlock = imagesLoading || Boolean(resolvedFeaturedImage);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
@@ -291,14 +856,30 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTML Content */}
|
||||
<div className="px-8 py-8">
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: content.html_content }}
|
||||
className="content-html"
|
||||
/>
|
||||
{/* Featured Image */}
|
||||
{shouldShowFeaturedBlock && (
|
||||
<div className="px-8 pt-8">
|
||||
<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 */}
|
||||
<div className="px-8 pb-10 pt-10">
|
||||
<ArticleBody
|
||||
introHtml={parsedArticle.introHtml}
|
||||
sections={parsedArticle.sections}
|
||||
sectionImages={resolvedInArticleImages}
|
||||
imagesLoading={imagesLoading}
|
||||
rawHtml={content.html_content}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata JSON (Collapsible) */}
|
||||
@@ -322,69 +903,117 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
{/* Custom Styles for Content HTML */}
|
||||
<style>{`
|
||||
.content-html {
|
||||
line-height: 1.75;
|
||||
line-height: 1.85;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.content-html h1,
|
||||
.content-html h2,
|
||||
.content-html h3,
|
||||
.content-html h4 {
|
||||
font-weight: 700;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
.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 h1 { font-size: 2.25em; }
|
||||
.content-html h2 { font-size: 1.875em; }
|
||||
.content-html h3 { font-size: 1.5em; }
|
||||
.content-html h4 { font-size: 1.25em; }
|
||||
.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.25em;
|
||||
margin-bottom: 1.3rem;
|
||||
}
|
||||
.content-html ul,
|
||||
.content-html ol {
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.content-html li {
|
||||
margin-bottom: 0.5em;
|
||||
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: 0.5rem;
|
||||
margin: 1.5em 0;
|
||||
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: #3b82f6;
|
||||
text-decoration: underline;
|
||||
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: #2563eb;
|
||||
}
|
||||
.content-html blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1em;
|
||||
margin: 1.5em 0;
|
||||
font-style: italic;
|
||||
color: #1d4ed8;
|
||||
border-bottom-color: rgba(37, 99, 235, 0.6);
|
||||
}
|
||||
.content-html code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125em 0.375em;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.content-html pre {
|
||||
background-color: #f3f4f6;
|
||||
padding: 1em;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
padding: 1.25rem;
|
||||
border-radius: 1.25rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5em 0;
|
||||
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: #4b5563;
|
||||
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-color: #1f2937;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user