import React, { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { trackPageDrawerOpened, trackPageDetailsSaved } from '../lib/mixpanel'; import { LlmsIncludeToggle } from './llms-include-toggle'; import { useFocusTrap } from '../hooks/use-focus-trap'; import { useScrollLock } from '../hooks/use-scroll-lock'; import { __, sprintf, _n } from '../lib/i18n'; import type { ReadinessRecommendation } from './readiness-recommendations-list'; import { DrawerHeaderBar } from './drawer-header-bar'; import { DrawerSection } from './drawer-section'; import { VisibilityScoreCard } from './visibility-score-card'; import { Button } from '@/components/ui/button'; // Types interface PageImage { attachment_id: number; url: string; alt: string; is_featured: boolean; } interface PageCategory { term_id: number; name: string; slug: string; path: string; } interface PageTag { term_id: number; name: string; slug: string; } interface HeadingItem { level: string; text: string; } interface ReadabilityData { avg_sentence_length: number; avg_paragraph_length: number; paragraph_count: number; } interface PageData { id: number; title: string; permalink: string; path: string; post_type: string; status: string; excerpt: string; word_count: number; edit_url: string; images: PageImage[]; categories: PageCategory[]; tags: PageTag[]; heading_structure: HeadingItem[]; readability: ReadabilityData; } interface VisibilityScoreComponents { frequency: number; visit_type: number; visit_count: number; page_status: number; url_type_multiplier: number; } interface BotVisibility { score: number; components: VisibilityScoreComponents; } interface ContentQualityComponents { content_length: number; heading_structure: number; alt_coverage: number; excerpt_quality: number; tags_categories: number; readability: number; } interface ContentQuality { score: number; components: ContentQualityComponents; } interface VisibilityScoreData { score: number; bot_visibility: BotVisibility; content_quality: ContentQuality; recommendations: ReadinessRecommendation[] | string[]; } interface PageDrawerProps { postId: number | null; isOpen: boolean; onClose: () => void; apiRoot: string; apiNamespace: string; rangeDays: number; onPageUpdated?: () => void; } export function PageDrawer({ postId, isOpen, onClose, apiRoot, apiNamespace, rangeDays, onPageUpdated, }: PageDrawerProps) { const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [error, setError] = useState(null); const [pageData, setPageData] = useState(null); const [visibilityScore, setVisibilityScore] = useState(null); const [isDirty, setIsDirty] = useState(false); const drawerRef = useRef(null); // Editable fields const [excerpt, setExcerpt] = useState(''); const [imageAlts, setImageAlts] = useState<{ [key: number]: string }>({}); const [tags, setTags] = useState([]); // Fetch page details when drawer opens useEffect(() => { if (isOpen && postId) { fetchPageDetails(); } }, [isOpen, postId, rangeDays]); // Lock background scroll when drawer is open (WP admin scroll roots + shell outlet). useScrollLock(isOpen); // Focus trap + focus restore + ESC to close useFocusTrap(drawerRef, { active: isOpen, onEscape: () => handleClose() }); const fetchPageDetails = async (retryCount = 0) => { if (!postId) { setError(__('Invalid post ID', 'llmagnet-llm-txt-generator')); setIsLoading(false); return; } setIsLoading(true); setError(null); try { const url = `${apiRoot}${apiNamespace}/page-details?post_id=${postId}&range_days=${rangeDays}`; const response = await fetch(url, { headers: { 'X-WP-Nonce': (window as any).wpApiSettings?.nonce || '', }, credentials: 'same-origin', }); if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } const responseText = await response.text(); let data; try { data = JSON.parse(responseText); } catch (jsonError) { const jsonMatch = responseText.match(/\{.*\}/s); if (jsonMatch) { data = JSON.parse(jsonMatch[0]); } else { if (retryCount < 2) { await new Promise(resolve => setTimeout(resolve, 500)); return fetchPageDetails(retryCount + 1); } throw new Error(__('Invalid JSON response from server', 'llmagnet-llm-txt-generator')); } } if (!data.success) { throw new Error(data.error || __('Failed to load page details', 'llmagnet-llm-txt-generator')); } setPageData(data.post); setVisibilityScore(data.visibility_score); // Initialize editable fields setExcerpt(data.post.excerpt || ''); const alts: { [key: number]: string } = {}; data.post.images.forEach((img: PageImage) => { if (img.attachment_id > 0) { alts[img.attachment_id] = img.alt || ''; } }); setImageAlts(alts); setTags(data.post.tags.map((t: PageTag) => t.name)); setIsDirty(false); trackPageDrawerOpened(postId, data.post.title); } catch (err: any) { console.error('Error fetching page details:', err); setError(err.message || __('Failed to load page details', 'llmagnet-llm-txt-generator')); } finally { setIsLoading(false); } }; const handleSave = async (retryCount = 0) => { if (!postId || !isDirty) return; setIsSaving(true); setError(null); try { const imageAltsArray = Object.entries(imageAlts).map(([attachmentId, alt]) => ({ attachment_id: parseInt(attachmentId), alt, })); const response = await fetch( `${apiRoot}${apiNamespace}/page-details/update`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': (window as any).wpApiSettings?.nonce || '', }, credentials: 'same-origin', body: JSON.stringify({ post_id: postId, excerpt, image_alts: imageAltsArray, tags, }), } ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const responseText = await response.text(); let data; try { data = JSON.parse(responseText); } catch (jsonError) { const jsonMatch = responseText.match(/\{.*\}/s); if (jsonMatch) { data = JSON.parse(jsonMatch[0]); } else { if (retryCount < 2) { await new Promise(resolve => setTimeout(resolve, 500)); return handleSave(retryCount + 1); } throw new Error(__('Invalid JSON response from server', 'llmagnet-llm-txt-generator')); } } if (!data.success) { throw new Error(data.error || __('Failed to update page', 'llmagnet-llm-txt-generator')); } setIsDirty(false); setSaveSuccess(true); trackPageDetailsSaved(postId); setTimeout(() => setSaveSuccess(false), 3000); setTimeout(() => fetchPageDetails(), 500); if (onPageUpdated) { onPageUpdated(); } } catch (err: any) { console.error('Error saving page:', err); setError(err.message || __('Failed to save page', 'llmagnet-llm-txt-generator')); } finally { setIsSaving(false); } }; const handleClose = () => { onClose(); }; const handleExcerptChange = (value: string) => { setExcerpt(value); setIsDirty(true); }; const handleImageAltChange = (attachmentId: number, alt: string) => { setImageAlts(prev => ({ ...prev, [attachmentId]: alt })); setIsDirty(true); }; const handleTagsChange = (newTags: string[]) => { setTags(newTags); setIsDirty(true); }; if (!isOpen) return null; return createPortal( <> {/* Backdrop */}
{/* Drawer — portal + flex scroll so content isn't clipped by admin shell overflow */}
{isLoading ? ( ) : error ? ( ) : pageData && visibilityScore ? ( <>
{/* Content Analysis Sections (Read-only) */} {/* Editable Sections */} {/* llms.txt include toggle — parity with the Gutenberg panel (FB-5) */}
) : null}
, document.body, ); } // Loading Skeleton function LoadingSkeleton() { return (
); } // Error State function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) { return (

{__('Error Loading Page', 'llmagnet-llm-txt-generator')}

{error}

); } function DrawerHeader({ page, score, isDirty, isSaving, saveSuccess, onSave, onClose, }: { page: PageData; score: VisibilityScoreData; isDirty: boolean; isSaving: boolean; saveSuccess: boolean; onSave: () => void; onClose: () => void; }) { return ( <>
); } // Headings Structure Section (Read-only) function HeadingsSection({ headings }: { headings: HeadingItem[] }) { const getHeadingColor = (level: string) => { switch (level) { case 'h2': return 'text-purple-700 bg-purple-50'; case 'h3': return 'text-blue-700 bg-blue-50'; case 'h4': return 'text-teal-700 bg-teal-50'; case 'h5': return 'text-gray-700 bg-gray-50'; case 'h6': return 'text-gray-600 bg-gray-50'; default: return 'text-gray-700 bg-gray-50'; } }; const getIndent = (level: string) => { const lvl = parseInt(level.replace('h', '')); return (lvl - 2) * 16; // H2 = 0px, H3 = 16px, H4 = 32px, etc. }; return ( {headings.length === 0 ? (
!
{__('No headings found', 'llmagnet-llm-txt-generator')}
{__('Add H2 and H3 headings to structure your content for better readability and SEO.', 'llmagnet-llm-txt-generator')}
) : (
{headings.map((heading, i) => (
{heading.level.toUpperCase()} {heading.text}
))}
)}
); } // Content Stats Section (Read-only) function ContentStatsSection({ wordCount, readability, }: { wordCount: number; readability: ReadabilityData; }) { const getWordCountColor = (count: number) => { if (count >= 1500) return 'text-green-600'; if (count >= 800) return 'text-green-600'; if (count >= 300) return 'text-yellow-600'; return 'text-red-600'; }; const getWordCountLabel = (count: number) => { if (count >= 1500) return __('Excellent', 'llmagnet-llm-txt-generator'); if (count >= 800) return __('Good', 'llmagnet-llm-txt-generator'); if (count >= 300) return __('Fair', 'llmagnet-llm-txt-generator'); return __('Short', 'llmagnet-llm-txt-generator'); }; return (
{wordCount.toLocaleString()}
{__('Words', 'llmagnet-llm-txt-generator')}
{getWordCountLabel(wordCount)}
{readability.paragraph_count}
{__('Paragraphs', 'llmagnet-llm-txt-generator')}
{readability.avg_sentence_length}
{__('Avg words/sentence', 'llmagnet-llm-txt-generator')}
); } // Images Section function ImagesSection({ images, imageAlts, onAltChange, }: { images: PageImage[]; imageAlts: { [key: number]: string }; onAltChange: (attachmentId: number, alt: string) => void; }) { const editableImages = images.filter(img => img.attachment_id > 0); const readOnlyImages = images.filter(img => img.attachment_id === 0); if (images.length === 0) { return (
{__('No images found in this post.', 'llmagnet-llm-txt-generator')}
); } return ( {editableImages.map((image) => { const alt = imageAlts[image.attachment_id] || ''; const hasAlt = alt && alt.trim().length > 0; return (
{alt { (e.target as HTMLImageElement).src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="80"%3E%3Crect fill="%23f0f0f0" width="80" height="80"/%3E%3C/svg%3E'; }} />
{image.is_featured && ( {__('Featured', 'llmagnet-llm-txt-generator')} )}
onAltChange(image.attachment_id, e.target.value)} placeholder={__('Add descriptive ALT text...', 'llmagnet-llm-txt-generator')} className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" /> {!hasAlt && (
! {__('Missing ALT text', 'llmagnet-llm-txt-generator')}
)}
= 50 && alt.length <= 125 ? 'text-green-600' : 'text-gray-500'}`}> {alt.length < 50 ? sprintf(__('%1$d characters (recommended: 50-125)', 'llmagnet-llm-txt-generator'), alt.length) : alt.length <= 125 ? sprintf(_n('%d character', '%d characters', alt.length, 'llmagnet-llm-txt-generator'), alt.length) : sprintf(__('%1$d characters (consider shortening)', 'llmagnet-llm-txt-generator'), alt.length)}
); })} {readOnlyImages.length > 0 && (
{sprintf( _n( '%1$d image found without media library attachment. Edit ALT text in the WordPress editor.', '%1$d images found without media library attachment. Edit ALT text in the WordPress editor.', readOnlyImages.length, 'llmagnet-llm-txt-generator' ), readOnlyImages.length )}
)}
); } // Excerpt Section function ExcerptSection({ excerpt, onChange, }: { excerpt: string; onChange: (value: string) => void; }) { const excerptLength = excerpt.length; const getCharCountColor = () => { if (excerptLength === 0) return 'text-gray-400'; if (excerptLength < 120) return 'text-red-600'; if (excerptLength <= 160) return 'text-green-600'; return 'text-yellow-600'; }; return (