Files
2026-01-24 16:52:19 -06:00

267 lines
8.1 KiB
TypeScript

'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<string | null>(null);
const pendingContentRef = useRef<string | null>(null);
const rafIdRef = useRef<number | null>(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('<article>')) {
insideArticle = true;
setIsLoading(false);
buffer = buffer.split('<article>')[1] || '';
}
if (insideArticle) {
if (buffer.includes('</article>')) {
displayContent += buffer.split('</article>')[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 (
<div className="text-center py-12">
<p className="text-destructive mb-4">{error}</p>
<p className="text-muted-foreground">
Unable to generate article. Please try again later.
</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex gap-8">
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="sticky top-24 space-y-8">
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-24 mb-4" />
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-5/6" />
<div className="h-3 bg-muted rounded w-4/5" />
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-3/4" />
</div>
<div className="animate-pulse space-y-3">
<div className="h-4 bg-muted rounded w-24 mb-4" />
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-5/6" />
<div className="h-3 bg-muted rounded w-4/5" />
</div>
</div>
</aside>
<div className="flex-1 min-w-0 animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-3/4" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-5/6" />
<div className="h-64 bg-muted rounded w-full mt-6" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-4/5" />
<div className="h-6 bg-muted rounded w-1/2 mt-6" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-3/4" />
</div>
</div>
);
}
if (isEditMode) {
return (
<div className="flex gap-8">
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="sticky top-24">
<RelatedArticles title={decodedTitle} currentSlug={slug} />
</div>
</aside>
<div className="flex-1 min-w-0">
<ArticleEditor
slug={slug}
initialContent={content}
onCancel={() => onEditModeChange(false)}
onSaved={handleSaved}
/>
</div>
</div>
);
}
return (
<div className="flex gap-8">
<aside className="hidden lg:block w-64 flex-shrink-0">
<div className="sticky top-24 space-y-8">
<TableOfContents headings={headings} />
<div className="border-t border-border pt-8">
<RelatedArticles title={decodedTitle} currentSlug={slug} />
</div>
</div>
</aside>
<div className="flex-1 min-w-0 article-content">
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={{
h2: ({ children }) => {
const text = String(children).replace(/\*\*/g, '').replace(/\*/g, '');
const id = generateSlug(text);
return <h2 id={id}>{children}</h2>;
},
h3: ({ children }) => {
const text = String(children).replace(/\*\*/g, '').replace(/\*/g, '');
const id = generateSlug(text);
return <h3 id={id}>{children}</h3>;
},
img: ({ src, alt }) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt || ''}
className="w-full max-w-md mx-auto rounded-lg my-6"
loading="lazy"
/>
),
a: ({ href, children }) => (
<a
href={href}
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
</div>
);
}