import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import type { ArticleDetailResponse, ArticleVersionDetailResponse, ArticleVersionEntry, } from '../../service/visibility/visibility.interface'; import { deleteArticle, getArticle, getArticleVersion, getArticleVersions, patchArticle, publishArticle, regenerateArticle, unpublishArticle, } from '../../service/visibility/visibility.service'; import { RESOURCE_TYPE_ARTICLE_SUGGESTION } from '../../service/media/media.interface'; import type { MediaResponse } from '../../service/media/media.interface'; import { listMediaForResource } from '../../service/media/media.service'; import { deleteArticleTexts } from '../../utils/confirmTexts'; import ConfirmDialog from '../ui/ConfirmDialog'; import { editedByToTone, normaliseFaq, toneToBadgeClass } from './helpers'; import ImageSuggestionCard from './ImageSuggestionCard'; import MarkdownView, { extractHeadings, markdownToHtml, type TocEntry, } from './MarkdownView'; import Modal from './Modal'; interface ArticleModalProps { open: boolean; articleId: string | null; onClose: () => void; clientId: string; token: string; /** * Invoked after the merchant hard-deletes this article. Parent should drop * it from its local article list; the modal also calls ``onClose`` itself. */ onDeleted?: (articleId: string) => void; /** * Invoked after the merchant flips this article between ``ready`` and * ``published``. Parent should patch its local article list with the * updated summary so the Recommended/Published tabs re-balance. */ onPublishedChanged?: (article: ArticleDetailResponse) => void; } /** Slug used for the meta description pseudo-section at the top. */ const SLUG_META = 'article-overview'; /** Hard cap mirrors the backend ``ArticleRegenerateRequest`` schema. */ const REGEN_INSTRUCTIONS_MAX = 2000; /** Auto-poll cadence (ms) while an article is in pending/generating state. */ const POLL_INTERVAL_MS = 2000; /** Time-to-live for inline copy/save feedback messages (ms). */ const FEEDBACK_TIMEOUT_MS = 2400; const REGEN_PLACEHOLDER = 'Optional - tell the model what to change. Examples:\n' + '• Rewrite as a listicle with 7 numbered picks.\n' + '• Keep the article as-is, but never use the word "premium".\n' + '• Shift to a casual, second-person tone.\n' + '• Add a comparison section between brand A and brand B.'; /** * Full article viewer with two tabs: * - **Current**: TOC sidebar + markdown body + FAQ + target prompts * - **Versions**: timeline of edits + selected version preview */ const ArticleModal = ({ open, articleId, onClose, clientId, token, onDeleted, onPublishedChanged, }: ArticleModalProps): JSX.Element | null => { const [article, setArticle] = useState(null); const [mediaBySuggestion, setMediaBySuggestion] = useState< Record >({}); const [mediaError, setMediaError] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [activeSlug, setActiveSlug] = useState(null); const [copyFeedback, setCopyFeedback] = useState(null); const [regenerateBusy, setRegenerateBusy] = useState(false); const [regenerateOpen, setRegenerateOpen] = useState(false); const [regenerateInstructions, setRegenerateInstructions] = useState(''); const [selectedTab, setSelectedTab] = useState(0); const [deleteBusy, setDeleteBusy] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [publishBusy, setPublishBusy] = useState(false); const [editing, setEditing] = useState(false); const [editDraft, setEditDraft] = useState(''); const [saveBusy, setSaveBusy] = useState(false); const [versionsLoading, setVersionsLoading] = useState(false); const [versionsError, setVersionsError] = useState(null); const [versions, setVersions] = useState([]); const [selectedVersionId, setSelectedVersionId] = useState( null ); const [versionDetail, setVersionDetail] = useState(null); const [versionDetailLoading, setVersionDetailLoading] = useState(false); const contentPaneRef = useRef(null); const showFeedback = useCallback((message: string) => { setCopyFeedback(message); window.setTimeout(() => setCopyFeedback(null), FEEDBACK_TIMEOUT_MS); }, []); const loadArticle = useCallback(async () => { if (!articleId) return; setLoading(true); setError(null); try { const data = await getArticle(clientId, token, articleId); setArticle(data); } catch (loadError) { setError( loadError instanceof Error ? loadError.message : 'Failed to load article.' ); } finally { setLoading(false); } }, [articleId, clientId, token]); useEffect(() => { if (open && articleId) { loadArticle(); } if (!open) { setArticle(null); setError(null); setActiveSlug(null); setCopyFeedback(null); setSelectedTab(0); setVersions([]); setSelectedVersionId(null); setVersionDetail(null); setVersionsError(null); setEditing(false); setEditDraft(''); setRegenerateOpen(false); setRegenerateInstructions(''); setMediaBySuggestion({}); setMediaError(null); } }, [open, articleId, loadArticle]); useEffect(() => { if (!open || !articleId || !clientId || !token) return; let cancelled: boolean = false; listMediaForResource( clientId, token, RESOURCE_TYPE_ARTICLE_SUGGESTION, articleId ) .then((rows: MediaResponse[]) => { if (cancelled) return; const map: Record = {}; for (const row of rows) map[row.suggestion_id] = row; setMediaBySuggestion(map); }) .catch(listError => { if (!cancelled) console.warn('[ArticleModal] listMediaForResource failed', listError); }); return () => { cancelled = true; }; }, [open, articleId, clientId, token]); const handleMediaChanged = useCallback((fresh: MediaResponse): void => { setMediaBySuggestion(previous => ({ ...previous, [fresh.suggestion_id]: fresh, })); }, []); const handleMediaDeleted = useCallback((suggestionId: string): void => { setMediaBySuggestion(previous => { const next: Record = { ...previous }; delete next[suggestionId]; return next; }); }, []); /** * Auto-poll while the article is in a generating state - typical regen * lifecycle is ``pending`` → ``generating`` → ``ready``. Polling stops * automatically once status leaves the in-flight window. */ const articleStatus = article?.summary.status; useEffect(() => { if (!open || !articleId) return; if (articleStatus !== 'pending' && articleStatus !== 'generating') return; let cancelled = false; const pollOnce = async () => { if (cancelled) return; try { const fresh = await getArticle(clientId, token, articleId); if (cancelled) return; setArticle(fresh); if (fresh.summary.status === 'ready') { showFeedback('Article ready.'); } else if (fresh.summary.status === 'failed') { showFeedback('Regeneration failed - try again.'); } } catch { /* transient errors shouldn't kill polling */ } }; const intervalHandle = window.setInterval(pollOnce, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearInterval(intervalHandle); }; }, [open, articleStatus, articleId, clientId, token, showFeedback]); const isGenerating = articleStatus === 'pending' || articleStatus === 'generating'; const isPublished = articleStatus === 'published'; // Publish is reversible (ready → published → ready), but only those two // states can transition: pending/generating/failed have nothing to flip. const canTogglePublish = articleStatus === 'ready' || articleStatus === 'published'; const publishLabel = isPublished ? 'Mark as not published' : 'Mark as published'; useEffect(() => { if (!open || !articleId || selectedTab !== 1) return; if (versions.length > 0 || versionsLoading) return; setVersionsLoading(true); setVersionsError(null); getArticleVersions(clientId, token, articleId) .then(response => { setVersions(response.versions || []); const firstVersion = response.versions?.[0]; if (firstVersion) setSelectedVersionId(firstVersion.version_id); }) .catch(versionsLoadError => { setVersionsError( versionsLoadError instanceof Error ? versionsLoadError.message : 'Failed to load versions.' ); }) .finally(() => setVersionsLoading(false)); }, [ open, articleId, selectedTab, clientId, token, versions.length, versionsLoading, ]); useEffect(() => { if (!articleId || !selectedVersionId) { setVersionDetail(null); return; } let cancelled = false; setVersionDetailLoading(true); getArticleVersion(clientId, token, articleId, selectedVersionId) .then(detail => { if (cancelled) return; setVersionDetail(detail); }) .catch(versionLoadError => { if (cancelled) return; setVersionsError( versionLoadError instanceof Error ? versionLoadError.message : 'Failed to load the selected version.' ); }) .finally(() => { if (!cancelled) setVersionDetailLoading(false); }); return () => { cancelled = true; }; }, [articleId, selectedVersionId, clientId, token]); const faq = useMemo(() => normaliseFaq(article?.faq), [article]); const tableOfContents: TocEntry[] = useMemo(() => { if (!article) return []; const headings = extractHeadings(article.markdown ?? ''); const title = article.h1 || article.summary.title; const base: TocEntry[] = title ? [{ level: 0, text: title, slug: SLUG_META }, ...headings] : headings; const extras: TocEntry[] = []; if (faq.length > 0) extras.push({ level: 0, text: 'FAQ', slug: 'faq-section' }); if ( Array.isArray( (article.outline as { sections?: unknown } | null)?.sections ) && ((article.outline as { sections: unknown[] }).sections.length ?? 0) > 0 ) extras.push({ level: 0, text: 'Outline', slug: 'outline' }); if ( Array.isArray(article.image_suggestions) && article.image_suggestions.length > 0 ) extras.push({ level: 0, text: 'Image suggestions', slug: 'image-suggestions', }); if ( Array.isArray(article.internal_links) && article.internal_links.length > 0 ) extras.push({ level: 0, text: 'Internal links', slug: 'internal-links' }); if ( Array.isArray(article.external_refs) && article.external_refs.length > 0 ) extras.push({ level: 0, text: 'External references', slug: 'external-refs', }); if (article.schema_article_jsonld || article.schema_faq_jsonld) extras.push({ level: 0, text: 'Schema (JSON-LD)', slug: 'schema-jsonld', }); return [...base, ...extras]; }, [article, faq]); const scrollToSlug = (slug: string) => { const contentPane = contentPaneRef.current; if (!contentPane) return; const target = contentPane.querySelector( `#${CSS.escape(slug)}` ); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); setActiveSlug(slug); } }; useEffect(() => { if (!open || !article) return; const contentPane = contentPaneRef.current; if (!contentPane) return; const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.isIntersecting && entry.target.id) { setActiveSlug(entry.target.id); break; } } }, { root: contentPane, rootMargin: '0px 0px -70% 0px', threshold: 0 } ); contentPane.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(headingEl => { if (headingEl.id) observer.observe(headingEl); }); const overviewEl = contentPane.querySelector(`#${SLUG_META}`); if (overviewEl) observer.observe(overviewEl); [ 'faq-section', 'outline', 'image-suggestions', 'internal-links', 'external-refs', 'schema-jsonld', ].forEach(id => { const sectionEl = contentPane.querySelector(`#${id}`); if (sectionEl) observer.observe(sectionEl); }); return () => observer.disconnect(); }, [open, article, tableOfContents]); /** * Best-effort clipboard write with a hidden-textarea fallback for * environments where the async API is unavailable (older webviews, * embedded iframes with restricted permissions). */ const writeToClipboard = async (value: string): Promise => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return true; } } catch { /* fall through to the textarea fallback */ } try { const fallbackTextarea = document.createElement('textarea'); fallbackTextarea.value = value; fallbackTextarea.setAttribute('readonly', ''); fallbackTextarea.style.position = 'absolute'; fallbackTextarea.style.left = '-9999px'; document.body.appendChild(fallbackTextarea); fallbackTextarea.select(); document.execCommand('copy'); document.body.removeChild(fallbackTextarea); return true; } catch { return false; } }; const handleCopyHtml = async () => { if (!article?.markdown) return; const html = markdownToHtml(article.markdown); const success = await writeToClipboard(html); showFeedback( success ? 'HTML copied - paste into the WordPress post body.' : "Couldn't copy - select manually." ); }; const handleCopyText = async () => { if (!article?.markdown) return; const html = markdownToHtml(article.markdown); const tmp = document.createElement('div'); tmp.innerHTML = html; const plain = (tmp as HTMLElement).innerText ?? tmp.textContent ?? ''; const ok = await writeToClipboard(plain); showFeedback( ok ? 'Plain text copied.' : "Couldn't copy — select manually." ); }; const handleCopyJsonLd = async (value: string, label: string) => { const ok = await writeToClipboard(value); showFeedback(ok ? `${label} copied.` : "Couldn't copy - select manually."); }; const handleStartEdit = () => { if (!article?.markdown) return; setEditDraft(article.markdown); setEditing(true); }; const handleCancelEdit = () => { setEditing(false); setEditDraft(''); }; const handleSaveEdit = async () => { if (!article) return; if (editDraft === article.markdown) { setEditing(false); return; } setSaveBusy(true); try { const updated = await patchArticle( clientId, token, article.summary.article_id, { markdown: editDraft } ); setArticle(updated); setEditing(false); setEditDraft(''); // Force the versions tab to refetch so the new version row shows up. setVersions([]); showFeedback('Edits saved - a new version was recorded.'); } catch (saveError) { showFeedback( saveError instanceof Error ? saveError.message : 'Failed to save edits.' ); } finally { setSaveBusy(false); } }; const handleRegenerate = async () => { if (!article) return; setRegenerateBusy(true); try { const instructions = regenerateInstructions.trim(); await regenerateArticle( clientId, token, article.summary.article_id, instructions ? { instructions } : undefined ); // Optimistically flip status so the polling effect picks up // immediately, even before the backend has flipped to "generating". setArticle(previous => previous ? { ...previous, summary: { ...previous.summary, status: 'generating' }, } : previous ); showFeedback( instructions ? 'Regeneration started with your directive.' : 'Regeneration started.' ); setRegenerateOpen(false); setRegenerateInstructions(''); } catch (regenerateError) { showFeedback( regenerateError instanceof Error ? regenerateError.message : 'Regenerate failed.' ); } finally { setRegenerateBusy(false); } }; /** * Toggle the article between ``ready`` and ``published`` on the backend * and patch the modal's local copy with the response. Forwards the * updated detail to the parent so the Recommended/Published tabs * re-balance without a full refetch. */ const handlePublishToggle = async () => { if (!article) return; const wasPublished = article.summary.status === 'published'; setPublishBusy(true); try { const updated = wasPublished ? await unpublishArticle(clientId, token, article.summary.article_id) : await publishArticle(clientId, token, article.summary.article_id); setArticle(updated); onPublishedChanged?.(updated); showFeedback( wasPublished ? 'Marked as not published.' : 'Marked as published.' ); } catch (err) { showFeedback( err instanceof Error ? err.message : 'Failed to update status.' ); } finally { setPublishBusy(false); } }; /** * Confirm and hard-delete the currently-loaded article. On success * notifies the parent via ``onDeleted`` and closes the modal; on * failure surfaces the error in the inline copy/feedback strip. * * @returns {Promise} Resolves once the delete request and modal teardown have finished. */ const handleDelete = async () => { if (!article) return; setDeleteConfirmOpen(false); setDeleteBusy(true); try { await deleteArticle(clientId, token, article.summary.article_id); onDeleted?.(article.summary.article_id); onClose(); } catch (deleteError) { showFeedback( deleteError instanceof Error ? deleteError.message : 'Failed to delete article.' ); } finally { setDeleteBusy(false); } }; const deleteTitle = article ? article.summary.title || article.h1 || 'this article' : ''; const handleCopyVersionHtml = async () => { if (!versionDetail?.markdown) return; const html = markdownToHtml(versionDetail.markdown); const success = await writeToClipboard(html); showFeedback( success ? 'HTML copied - paste into the WordPress post body.' : "Couldn't copy - select manually." ); }; const handleCopyVersionText = async () => { if (!versionDetail?.markdown) return; const html = markdownToHtml(versionDetail.markdown); const tmp = document.createElement('div'); tmp.innerHTML = html; const plain = (tmp as HTMLElement).innerText ?? tmp.textContent ?? ''; const ok = await writeToClipboard(plain); showFeedback( ok ? 'Plain text copied.' : "Couldn't copy - select manually." ); }; const statsLine = useMemo(() => { if (!article) return ''; const parts: string[] = []; if (article.summary.word_count) { parts.push(`${article.summary.word_count} words`); } if (article.summary.seo_score) { parts.push(`SEO ${article.summary.seo_score}/100`); } if (article.summary.aeo_score) { parts.push(`AEO ${article.summary.aeo_score}/100`); } return parts.join(' · '); }, [article]); if (!open) return null; const renderCurrentTab = (): JSX.Element => (
{loading && !article ? (
) : article ? (
{article.summary.status} {article.summary.language.toUpperCase()} {statsLine && ( {statsLine} )}

{article.h1 || article.summary.title}

{article.meta_description && (

{article.meta_description}

)}
{editing ? (