'use client'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { TableOfContents } from './table-of-contents'; import { ArticleEditor } from './article-editor'; import { RelatedArticles } from './related-articles'; interface ArticleStreamProps { title: string; slug: string; decodedTitle: string; isEditMode: boolean; onEditModeChange: (editing: boolean) => void; } export interface TocItem { id: string; text: string; level: number; } function generateSlug(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim(); } function extractHeadings(markdown: string): TocItem[] { const headingRegex = /^(#{2,3})\s+(.+)$/gm; const headings: TocItem[] = []; let match; while ((match = headingRegex.exec(markdown)) !== null) { const level = match[1].length; const text = match[2].replace(/\*\*/g, '').replace(/\*/g, '').trim(); const id = generateSlug(text); headings.push({ id, text, level }); } return headings; } const remarkPlugins = [remarkGfm]; export function ArticleStream({ title, slug, decodedTitle, isEditMode, onEditModeChange }: ArticleStreamProps) { const [content, setContent] = useState(''); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const pendingContentRef = useRef(null); const rafIdRef = useRef(null); const headings = useMemo(() => extractHeadings(content), [content]); const scheduleContentUpdate = useCallback((newContent: string) => { pendingContentRef.current = newContent; if (rafIdRef.current === null) { rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null; if (pendingContentRef.current !== null) { setContent(pendingContentRef.current); } }); } }, []); useEffect(() => { let isCancelled = false; async function fetchStream() { try { const response = await fetch(`/api/ai-article?title=${encodeURIComponent(title)}`); if (!response.ok) { throw new Error('Failed to fetch article'); } const reader = response.body?.getReader(); if (!reader) { throw new Error('No response body'); } const decoder = new TextDecoder(); let buffer = ''; let insideArticle = false; let displayContent = ''; while (true) { const { done, value } = await reader.read(); if (done || isCancelled) break; const chunk = decoder.decode(value, { stream: true }); buffer += chunk; if (!insideArticle && buffer.includes('
')) { insideArticle = true; setIsLoading(false); buffer = buffer.split('
')[1] || ''; } if (insideArticle) { if (buffer.includes('
')) { displayContent += buffer.split('
')[0]; setContent(displayContent); break; } displayContent += buffer; buffer = ''; if (!isCancelled) { scheduleContentUpdate(displayContent); } } } reader.releaseLock(); } catch (err) { if (!isCancelled) { setError(err instanceof Error ? err.message : 'An error occurred'); setIsLoading(false); } } } fetchStream(); return () => { isCancelled = true; if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } }; }, [title, scheduleContentUpdate]); const handleSaved = (newContent: string) => { setContent(newContent); onEditModeChange(false); }; if (error) { return (

{error}

Unable to generate article. Please try again later.

); } if (isLoading) { return (
); } if (isEditMode) { return (
onEditModeChange(false)} onSaved={handleSaved} />
); } return (
{ const text = String(children).replace(/\*\*/g, '').replace(/\*/g, ''); const id = generateSlug(text); return

{children}

; }, h3: ({ children }) => { const text = String(children).replace(/\*\*/g, '').replace(/\*/g, ''); const id = generateSlug(text); return

{children}

; }, img: ({ src, alt }) => ( // eslint-disable-next-line @next/next/no-img-element {alt ), a: ({ href, children }) => ( {children} ), }} > {content}
); }