From 9a22fcf0f458c469ff5bf258ca27512fb558399e Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Wed, 12 Nov 2025 10:38:14 +0000 Subject: [PATCH] Add image fetching functionality and enhance ContentViewTemplate --- frontend/src/services/api.ts | 48 ++ .../src/templates/ContentViewTemplate.tsx | 717 ++++++++++++++++-- 2 files changed, 721 insertions(+), 44 deletions(-) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a035d39b..ad03ee42 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { 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 { + 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: { diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx index 47633caf..7b9969d0 100644 --- a/frontend/src/templates/ContentViewTemplate.tsx +++ b/frontend/src/templates/ContentViewTemplate.tsx @@ -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 = { + 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(); + + 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; + 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 ( + + {status} + + ); +}; + +const PromptPlaceholder = ({ + prompt, + label, + minHeight = 220, +}: { + prompt?: string | null; + label?: string; + minHeight?: number; +}) => ( +
+
+ {label && ( +

+ {label} +

+ )} +

+ {prompt || 'Image prompt available, awaiting generation.'} +

+
+
+); + +const FeaturedImageBlock = ({ + image, + loading, +}: { + image: ImageRecord | null; + loading: boolean; +}) => { + const imageSrc = getImageSrc(image); + + if (!loading && !image && !imageSrc) { + return null; + } + + return ( +
+
+
+ Featured Visual +
+ +
+
+ {loading && !imageSrc ? ( +
+ ) : imageSrc ? ( + {image?.prompt + ) : ( + + )} + {image?.prompt && imageSrc && ( +
+ Prompt aligned to hero section +
+ )} +
+ {image?.prompt && ( +
+ {image.prompt} +
+ )} +
+ ); +}; + +const SectionImageBlock = ({ + image, + loading, + heading, +}: { + image: ImageRecord | null; + loading: boolean; + heading: string; +}) => { + if (!image && !loading) return null; + + const imageSrc = getImageSrc(image); + + return ( +
+
+ {loading && !image ? ( +
+ ) : imageSrc ? ( + {image?.prompt + ) : ( + + )} +
+ +
+
+ {image?.prompt && ( +
+

+ Visual Direction +

+

{image.prompt}

+
+ )} +
+ ); +}; + +const IntroBlock = ({ html }: { html: string }) => ( +
+
+ Opening Narrative +
+
+
+
+
+); + +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 ( +
+
+
+
+ + {index + 1} + +
+ + Section Spotlight + +

+ {headingLabel} +

+
+
+ +
+
+
+
+ {hasImage && ( +
+ +
+ )} +
+
+
+
+ ); +}; + +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 ( +
+
+
+
+
+ ); + } + + return ( +
+ {introHtml && } + {sections.map((section, index) => ( + + ))} +
+ ); +}; + export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) { + const [imageRecords, setImageRecords] = useState([]); + const [imagesLoading, setImagesLoading] = useState(false); + const [imagesError, setImagesError] = useState(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(); + sorted.forEach((img, index) => { + const pos = img.position ?? index + 1; + byPosition.set(pos, img); + }); + + const usedPositions = new Set(); + 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 (
@@ -291,14 +856,30 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
)} - {/* HTML Content */} -
-
-
+ {/* Featured Image */} + {shouldShowFeaturedBlock && ( +
+
+ )} + + {imagesError && ( +
+
+ {imagesError} +
+
+ )} + + {/* Article Body */} +
+
{/* Metadata JSON (Collapsible) */} @@ -322,69 +903,117 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten {/* Custom Styles for Content HTML */}