Major changes

This commit is contained in:
owenqwenstarsky
2026-01-24 16:52:19 -06:00
parent 011e40119d
commit 7181791734
46 changed files with 8371 additions and 127 deletions
+791
View File
@@ -0,0 +1,791 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { ReviewResultPanel } from './review-result-panel';
import { EditorToolbar } from './editor-toolbar';
import { EditorPreview } from './editor-preview';
import { DiffPreview } from './diff-preview';
import {
Maximize2,
Minimize2,
Eye,
EyeOff,
Search,
X,
ChevronDown,
FileText,
Save,
AlertCircle,
} from 'lucide-react';
interface ArticleEditorProps {
slug: string;
initialContent: string;
onCancel: () => void;
onSaved: (newContent: string) => void;
}
interface ReviewResult {
approved: boolean;
reason: string;
}
interface FindReplaceState {
isOpen: boolean;
findText: string;
replaceText: string;
matchCount: number;
currentMatch: number;
}
export function ArticleEditor({
slug,
initialContent,
onCancel,
onSaved,
}: ArticleEditorProps) {
const [content, setContent] = useState(initialContent);
const [isReviewing, setIsReviewing] = useState(false);
const [reviewResponse, setReviewResponse] = useState('');
const [reviewResult, setReviewResult] = useState<ReviewResult | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [showDiff, setShowDiff] = useState(false);
const [undoStack, setUndoStack] = useState<string[]>([initialContent]);
const [redoStack, setRedoStack] = useState<string[]>([]);
const [findReplace, setFindReplace] = useState<FindReplaceState>({
isOpen: false,
findText: '',
replaceText: '',
matchCount: 0,
currentMatch: 0,
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const lastSavedContent = useRef(initialContent);
const isScrollingSyncRef = useRef(false);
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showRecoveryBanner, setShowRecoveryBanner] = useState(false);
// Word and character count
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
const charCount = content.length;
const lineCount = content.split('\n').length;
const hasChanges = content !== initialContent;
// Storage key for auto-save
const storageKey = `article-draft-${slug}`;
// Check for recovered draft on mount
useEffect(() => {
const savedDraft = localStorage.getItem(storageKey);
if (savedDraft && savedDraft !== initialContent && savedDraft.length > 0) {
setShowRecoveryBanner(true);
}
}, [storageKey, initialContent]);
// Auto-save to localStorage
useEffect(() => {
if (hasChanges) {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
setAutoSaveStatus('saving');
autoSaveTimeoutRef.current = setTimeout(() => {
localStorage.setItem(storageKey, content);
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
}, 1000);
}
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [content, hasChanges, storageKey]);
// Clear draft on successful save
const clearDraft = useCallback(() => {
localStorage.removeItem(storageKey);
}, [storageKey]);
const recoverDraft = useCallback(() => {
const savedDraft = localStorage.getItem(storageKey);
if (savedDraft) {
updateContent(savedDraft);
setShowRecoveryBanner(false);
}
}, [storageKey]);
const dismissRecovery = useCallback(() => {
localStorage.removeItem(storageKey);
setShowRecoveryBanner(false);
}, [storageKey]);
// Synchronized scrolling between editor and preview
const handleEditorScroll = useCallback(() => {
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
isScrollingSyncRef.current = true;
const textarea = textareaRef.current;
const preview = previewRef.current;
const scrollPercentage = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight);
const previewScrollTop = scrollPercentage * (preview.scrollHeight - preview.clientHeight);
preview.scrollTop = previewScrollTop;
requestAnimationFrame(() => {
isScrollingSyncRef.current = false;
});
}, [showPreview]);
const handlePreviewScroll = useCallback(() => {
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
isScrollingSyncRef.current = true;
const textarea = textareaRef.current;
const preview = previewRef.current;
const scrollPercentage = preview.scrollTop / (preview.scrollHeight - preview.clientHeight);
const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight);
textarea.scrollTop = textareaScrollTop;
requestAnimationFrame(() => {
isScrollingSyncRef.current = false;
});
}, [showPreview]);
// Update match count when find text changes
useEffect(() => {
if (findReplace.findText) {
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
const matches = content.match(regex);
setFindReplace(prev => ({
...prev,
matchCount: matches?.length || 0,
currentMatch: matches?.length ? 1 : 0,
}));
} else {
setFindReplace(prev => ({ ...prev, matchCount: 0, currentMatch: 0 }));
}
}, [findReplace.findText, content]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!textareaRef.current) return;
const isMod = e.ctrlKey || e.metaKey;
if (isMod && e.key === 'b') {
e.preventDefault();
wrapSelection('**', '**');
} else if (isMod && e.key === 'i') {
e.preventDefault();
wrapSelection('*', '*');
} else if (isMod && e.key === 'k') {
e.preventDefault();
insertLink();
} else if (isMod && e.key === 'f') {
e.preventDefault();
setFindReplace(prev => ({ ...prev, isOpen: true }));
} else if (isMod && e.shiftKey && e.key === 'z') {
e.preventDefault();
handleRedo();
} else if (isMod && e.key === 'z') {
e.preventDefault();
handleUndo();
} else if (e.key === 'Escape') {
if (findReplace.isOpen) {
setFindReplace(prev => ({ ...prev, isOpen: false }));
} else if (isFullscreen) {
setIsFullscreen(false);
}
} else if (e.key === 'Tab' && document.activeElement === textareaRef.current) {
e.preventDefault();
insertAtCursor(' ');
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content, findReplace.isOpen, isFullscreen]);
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const updateContent = useCallback((newContent: string) => {
setUndoStack(prev => [...prev.slice(-50), content]);
setRedoStack([]);
setContent(newContent);
}, [content]);
const handleUndo = useCallback(() => {
if (undoStack.length > 1) {
const newUndo = [...undoStack];
const current = newUndo.pop()!;
const previous = newUndo[newUndo.length - 1];
setRedoStack(prev => [...prev, current]);
setUndoStack(newUndo);
setContent(previous);
}
}, [undoStack]);
const handleRedo = useCallback(() => {
if (redoStack.length > 0) {
const newRedo = [...redoStack];
const next = newRedo.pop()!;
setUndoStack(prev => [...prev, content]);
setRedoStack(newRedo);
setContent(next);
}
}, [redoStack, content]);
const wrapSelection = useCallback((before: string, after: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
const newText = content.substring(0, start) + before + selectedText + after + content.substring(end);
updateContent(newText);
// Restore selection
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + before.length, end + before.length);
}, 0);
}, [content, updateContent]);
const insertAtCursor = useCallback((text: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = content.substring(0, start) + text + content.substring(end);
updateContent(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + text.length, start + text.length);
}, 0);
}, [content, updateContent]);
const insertLink = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
wrapSelection('[', '](url)');
} else {
insertAtCursor('[link text](url)');
}
}, [content, wrapSelection, insertAtCursor]);
const insertHeading = useCallback((level: number) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
const prefix = '#'.repeat(level) + ' ';
const newText = content.substring(0, lineStart) + prefix + content.substring(lineStart);
updateContent(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(lineStart + prefix.length, lineStart + prefix.length);
}, 0);
}, [content, updateContent]);
const insertList = useCallback((ordered: boolean) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
const lines = selectedText.split('\n');
const listLines = lines.map((line, i) =>
ordered ? `${i + 1}. ${line}` : `- ${line}`
).join('\n');
const newText = content.substring(0, start) + listLines + content.substring(end);
updateContent(newText);
} else {
insertAtCursor(ordered ? '\n1. ' : '\n- ');
}
}, [content, updateContent, insertAtCursor]);
const insertBlockquote = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
const lines = selectedText.split('\n');
const quotedLines = lines.map(line => `> ${line}`).join('\n');
const newText = content.substring(0, start) + quotedLines + content.substring(end);
updateContent(newText);
} else {
insertAtCursor('\n> ');
}
}, [content, updateContent, insertAtCursor]);
const insertCodeBlock = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
wrapSelection('\n```\n', '\n```\n');
} else {
insertAtCursor('\n```\ncode here\n```\n');
}
}, [wrapSelection, insertAtCursor]);
const insertTable = useCallback(() => {
const table = `
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
`;
insertAtCursor(table);
}, [insertAtCursor]);
const insertTemplate = useCallback((template: string) => {
insertAtCursor('\n' + template + '\n');
}, [insertAtCursor]);
const insertImage = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText && (selectedText.startsWith('http') || selectedText.startsWith('/'))) {
const newText = content.substring(0, start) + `![Image](${selectedText})` + content.substring(end);
updateContent(newText);
} else {
insertAtCursor('![alt text](image-url)');
}
}, [content, updateContent, insertAtCursor]);
const handleFindNext = useCallback(() => {
if (!findReplace.findText || !textareaRef.current) return;
const textarea = textareaRef.current;
const start = textarea.selectionEnd;
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
// Search from current position
const afterCursor = content.substring(start);
const match = regex.exec(afterCursor);
if (match) {
const matchStart = start + match.index;
const matchEnd = matchStart + match[0].length;
textarea.focus();
textarea.setSelectionRange(matchStart, matchEnd);
setFindReplace(prev => ({
...prev,
currentMatch: Math.min(prev.currentMatch + 1, prev.matchCount),
}));
} else {
// Wrap around to beginning
const beforeCursor = content.substring(0, start);
const wrapMatch = new RegExp(escapeRegex(findReplace.findText), 'gi').exec(content);
if (wrapMatch) {
textarea.focus();
textarea.setSelectionRange(wrapMatch.index, wrapMatch.index + wrapMatch[0].length);
setFindReplace(prev => ({ ...prev, currentMatch: 1 }));
}
}
}, [findReplace.findText, content]);
const handleReplace = useCallback(() => {
if (!findReplace.findText || !textareaRef.current) return;
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText.toLowerCase() === findReplace.findText.toLowerCase()) {
const newText = content.substring(0, start) + findReplace.replaceText + content.substring(end);
updateContent(newText);
handleFindNext();
} else {
handleFindNext();
}
}, [findReplace, content, updateContent, handleFindNext]);
const handleReplaceAll = useCallback(() => {
if (!findReplace.findText) return;
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
const newText = content.replace(regex, findReplace.replaceText);
updateContent(newText);
}, [findReplace, content, updateContent]);
const parseReviewAction = (text: string): ReviewResult | null => {
const match = text.match(/<action\s+approve="(true|false)"\s+reason="([^"]+)"\s*\/?>/i);
if (match) {
return {
approved: match[1].toLowerCase() === 'true',
reason: match[2],
};
}
return null;
};
const handleReview = async () => {
setIsReviewing(true);
setReviewResponse('');
setReviewResult(null);
try {
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newContent: content }),
});
if (!response.ok) {
throw new Error('Failed to review changes');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let fullResponse = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
fullResponse += chunk;
setReviewResponse(fullResponse);
const result = parseReviewAction(fullResponse);
if (result) {
setReviewResult(result);
}
}
if (!reviewResult) {
const finalResult = parseReviewAction(fullResponse);
if (finalResult) {
setReviewResult(finalResult);
}
}
} catch (error) {
console.error('Review error:', error);
setReviewResult({
approved: false,
reason: 'Failed to complete review. Please try again.',
});
} finally {
setIsReviewing(false);
}
};
const handleSave = async () => {
if (!reviewResult?.approved) return;
setIsSaving(true);
try {
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
newContent: content,
reason: reviewResult.reason,
}),
});
if (!response.ok) {
throw new Error('Failed to save changes');
}
lastSavedContent.current = content;
clearDraft();
onSaved(content);
} catch (error) {
console.error('Save error:', error);
alert('Failed to save changes. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleContinueEditing = () => {
setReviewResult(null);
setReviewResponse('');
setShowDiff(false);
};
const toolbarActions = {
onBold: () => wrapSelection('**', '**'),
onItalic: () => wrapSelection('*', '*'),
onStrikethrough: () => wrapSelection('~~', '~~'),
onHeading: insertHeading,
onLink: insertLink,
onImage: insertImage,
onBulletList: () => insertList(false),
onNumberedList: () => insertList(true),
onBlockquote: insertBlockquote,
onCode: () => wrapSelection('`', '`'),
onCodeBlock: insertCodeBlock,
onTable: insertTable,
onUndo: handleUndo,
onRedo: handleRedo,
canUndo: undoStack.length > 1,
canRedo: redoStack.length > 0,
onInsertTemplate: insertTemplate,
};
return (
<div
ref={editorContainerRef}
className={`article-editor-container ${isFullscreen ? 'editor-fullscreen' : ''}`}
>
{/* Recovery banner */}
{showRecoveryBanner && (
<div className="recovery-banner">
<div className="recovery-banner-content">
<AlertCircle className="w-4 h-4" />
<span>A previous draft was found. Would you like to recover it?</span>
</div>
<div className="recovery-banner-actions">
<Button variant="ghost" size="xs" onClick={dismissRecovery}>
Discard
</Button>
<Button size="xs" onClick={recoverDraft}>
Recover Draft
</Button>
</div>
</div>
)}
{/* Header bar */}
<div className="editor-header">
<div className="editor-header-left">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isReviewing || isSaving}
>
<X className="w-4 h-4 mr-1" />
Cancel
</Button>
<div className="editor-status">
{hasChanges ? (
<span className="status-unsaved">
Unsaved changes
{autoSaveStatus === 'saving' && ' (saving...)'}
{autoSaveStatus === 'saved' && ' (draft saved)'}
</span>
) : (
<span className="status-saved">No changes</span>
)}
</div>
</div>
<div className="editor-header-center">
<span className="editor-stats">
{wordCount.toLocaleString()} words · {charCount.toLocaleString()} chars · {lineCount.toLocaleString()} lines
</span>
</div>
<div className="editor-header-right">
<Button
variant="ghost"
size="sm"
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: !prev.isOpen }))}
title="Find & Replace (Ctrl+F)"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
title={showPreview ? 'Hide Preview' : 'Show Preview'}
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDiff(!showDiff)}
disabled={!hasChanges}
title="Preview Changes"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
<Button
onClick={handleReview}
disabled={isReviewing || isSaving || !hasChanges}
size="sm"
>
{isReviewing ? 'Reviewing...' : 'Submit for Review'}
</Button>
</div>
</div>
{/* Find & Replace bar */}
{findReplace.isOpen && (
<div className="find-replace-bar">
<div className="find-replace-inputs">
<div className="find-input-group">
<input
type="text"
placeholder="Find..."
value={findReplace.findText}
onChange={(e) => setFindReplace(prev => ({ ...prev, findText: e.target.value }))}
className="find-input"
autoFocus
/>
{findReplace.matchCount > 0 && (
<span className="match-count">
{findReplace.currentMatch} of {findReplace.matchCount}
</span>
)}
</div>
<input
type="text"
placeholder="Replace..."
value={findReplace.replaceText}
onChange={(e) => setFindReplace(prev => ({ ...prev, replaceText: e.target.value }))}
className="replace-input"
/>
</div>
<div className="find-replace-actions">
<Button variant="ghost" size="xs" onClick={handleFindNext} disabled={!findReplace.findText}>
<ChevronDown className="w-4 h-4" />
</Button>
<Button variant="ghost" size="xs" onClick={handleReplace} disabled={!findReplace.findText}>
Replace
</Button>
<Button variant="ghost" size="xs" onClick={handleReplaceAll} disabled={!findReplace.findText}>
Replace All
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: false }))}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Toolbar */}
<EditorToolbar {...toolbarActions} disabled={isReviewing || isSaving} />
{/* Main editor area */}
<div className={`editor-main ${showPreview ? 'with-preview' : ''} ${showDiff ? 'with-diff' : ''}`}>
{showDiff && hasChanges ? (
<DiffPreview oldContent={initialContent} newContent={content} />
) : (
<>
<div className="editor-pane">
<textarea
ref={textareaRef}
className="editor-textarea-enhanced"
value={content}
onChange={(e) => updateContent(e.target.value)}
onScroll={handleEditorScroll}
disabled={isReviewing || isSaving}
placeholder="Write your article content in markdown..."
spellCheck
/>
</div>
{showPreview && (
<div className="preview-pane">
<div className="preview-header">
<Eye className="w-4 h-4" />
<span>Preview</span>
</div>
<div
ref={previewRef}
className="preview-content"
onScroll={handlePreviewScroll}
>
<EditorPreview content={content} />
</div>
</div>
)}
</>
)}
</div>
{/* Review section */}
{isReviewing && reviewResponse && (
<div className="review-stream">
<h4>AI Review in Progress...</h4>
<p className="review-stream-text">{reviewResponse}</p>
</div>
)}
{reviewResult && (
<ReviewResultPanel
approved={reviewResult.approved}
reason={reviewResult.reason}
onSave={handleSave}
onContinueEditing={handleContinueEditing}
isSaving={isSaving}
/>
)}
{/* Keyboard shortcuts help */}
<div className="editor-shortcuts-hint">
<span>Ctrl+B Bold</span>
<span>Ctrl+I Italic</span>
<span>Ctrl+K Link</span>
<span>Ctrl+Z Undo</span>
<span>Ctrl+F Find</span>
</div>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
'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>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect, useState } from 'react';
export function CursorGlow() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
if (!visible) setVisible(true);
};
const handleMouseLeave = () => setVisible(false);
const handleMouseEnter = () => setVisible(true);
window.addEventListener('mousemove', handleMouseMove);
document.body.addEventListener('mouseleave', handleMouseLeave);
document.body.addEventListener('mouseenter', handleMouseEnter);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
document.body.removeEventListener('mouseenter', handleMouseEnter);
};
}, [visible]);
return (
<div
className="pointer-events-none fixed inset-0 z-50 transition-opacity duration-300"
style={{ opacity: visible ? 1 : 0 }}
>
<div
className="absolute h-[300px] w-[300px] rounded-full bg-blue-500/20 blur-[80px] transition-transform duration-150 ease-out"
style={{
transform: `translate(${position.x - 150}px, ${position.y - 150}px)`,
}}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useMemo } from 'react';
import { diffLines } from 'diff';
interface DiffPreviewProps {
oldContent: string;
newContent: string;
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
content: string;
oldLineNum?: number;
newLineNum?: number;
}
export function DiffPreview({ oldContent, newContent }: DiffPreviewProps) {
const diffResult = useMemo(() => {
const changes = diffLines(oldContent, newContent);
const lines: DiffLine[] = [];
let oldLineNum = 1;
let newLineNum = 1;
changes.forEach((change) => {
const changeLines = change.value.split('\n');
// Remove the last empty line if the value ends with \n
if (changeLines[changeLines.length - 1] === '') {
changeLines.pop();
}
changeLines.forEach((line) => {
if (change.added) {
lines.push({
type: 'added',
content: line,
newLineNum: newLineNum++,
});
} else if (change.removed) {
lines.push({
type: 'removed',
content: line,
oldLineNum: oldLineNum++,
});
} else {
lines.push({
type: 'unchanged',
content: line,
oldLineNum: oldLineNum++,
newLineNum: newLineNum++,
});
}
});
});
return lines;
}, [oldContent, newContent]);
const stats = useMemo(() => {
let added = 0;
let removed = 0;
diffResult.forEach((line) => {
if (line.type === 'added') added++;
if (line.type === 'removed') removed++;
});
return { added, removed };
}, [diffResult]);
return (
<div className="diff-preview-container">
<div className="diff-preview-header">
<span className="diff-preview-title">Changes Preview</span>
<div className="diff-preview-stats">
<span className="diff-stat-added">+{stats.added}</span>
<span className="diff-stat-removed">-{stats.removed}</span>
</div>
</div>
<div className="diff-preview-content">
{diffResult.map((line, index) => (
<div
key={index}
className={`diff-preview-line diff-preview-${line.type}`}
>
<span className="diff-preview-gutter">
{line.type === 'removed' && <span className="diff-sign">-</span>}
{line.type === 'added' && <span className="diff-sign">+</span>}
{line.type === 'unchanged' && <span className="diff-sign"> </span>}
</span>
<span className="diff-preview-line-num diff-preview-old-num">
{line.oldLineNum || ''}
</span>
<span className="diff-preview-line-num diff-preview-new-num">
{line.newLineNum || ''}
</span>
<span className="diff-preview-code">
{line.content || '\u00A0'}
</span>
</div>
))}
</div>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { useMemo } from 'react';
import * as Diff from 'diff';
interface DiffViewerProps {
oldContent: string;
newContent: string;
}
interface DiffLine {
type: 'added' | 'removed' | 'unchanged';
content: string;
oldLineNum?: number;
newLineNum?: number;
}
export function DiffViewer({ oldContent, newContent }: DiffViewerProps) {
const diffLines = useMemo(() => {
const changes = Diff.diffLines(oldContent, newContent);
const lines: DiffLine[] = [];
let oldLineNum = 1;
let newLineNum = 1;
for (const change of changes) {
const changeLines = change.value.split('\n');
// Remove empty last line from split
if (changeLines[changeLines.length - 1] === '') {
changeLines.pop();
}
for (const line of changeLines) {
if (change.added) {
lines.push({
type: 'added',
content: line,
newLineNum: newLineNum++,
});
} else if (change.removed) {
lines.push({
type: 'removed',
content: line,
oldLineNum: oldLineNum++,
});
} else {
lines.push({
type: 'unchanged',
content: line,
oldLineNum: oldLineNum++,
newLineNum: newLineNum++,
});
}
}
}
return lines;
}, [oldContent, newContent]);
// Split into old and new columns
const oldLines = diffLines.filter(l => l.type !== 'added');
const newLines = diffLines.filter(l => l.type !== 'removed');
return (
<div className="diff-viewer">
<div className="diff-header">
<div className="diff-column-header">Previous Version</div>
<div className="diff-column-header">New Version</div>
</div>
<div className="diff-content">
<div className="diff-column diff-old">
{oldLines.map((line, i) => (
<div
key={i}
className={`diff-line ${line.type === 'removed' ? 'diff-removed' : ''}`}
>
<span className="diff-line-num">{line.oldLineNum || ''}</span>
<span className="diff-line-content">{line.content || '\u00A0'}</span>
</div>
))}
</div>
<div className="diff-column diff-new">
{newLines.map((line, i) => (
<div
key={i}
className={`diff-line ${line.type === 'added' ? 'diff-added' : ''}`}
>
<span className="diff-line-num">{line.newLineNum || ''}</span>
<span className="diff-line-content">{line.content || '\u00A0'}</span>
</div>
))}
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface EditorPreviewProps {
content: string;
}
export function EditorPreview({ content }: EditorPreviewProps) {
// If content is empty, show placeholder
if (!content.trim()) {
return (
<div className="article-content editor-preview-content editor-preview-empty">
<p>Start typing to see the preview...</p>
</div>
);
}
return (
<div className="article-content editor-preview-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</div>
);
}
+375
View File
@@ -0,0 +1,375 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
Link,
Image,
List,
ListOrdered,
Quote,
Code,
FileCode,
Table,
Undo2,
Redo2,
ChevronDown,
Plus,
Minus,
FileText,
AlertTriangle,
Info,
CheckCircle,
} from 'lucide-react';
interface EditorToolbarProps {
onBold: () => void;
onItalic: () => void;
onStrikethrough: () => void;
onHeading: (level: number) => void;
onLink: () => void;
onImage: () => void;
onBulletList: () => void;
onNumberedList: () => void;
onBlockquote: () => void;
onCode: () => void;
onCodeBlock: () => void;
onTable: () => void;
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
disabled?: boolean;
onInsertTemplate?: (template: string) => void;
}
const TEMPLATES = {
infobox: `> **Note:** This is an informational callout.`,
warning: `> **Warning:** This requires attention.`,
tip: `> **Tip:** Here's a helpful suggestion.`,
divider: `\n---\n`,
footnote: `[^1]: Reference text here.`,
definition: `**Term**\n: Definition of the term goes here.`,
collapse: `<details>\n<summary>Click to expand</summary>\n\nHidden content goes here.\n\n</details>`,
};
export function EditorToolbar({
onBold,
onItalic,
onStrikethrough,
onHeading,
onLink,
onImage,
onBulletList,
onNumberedList,
onBlockquote,
onCode,
onCodeBlock,
onTable,
onUndo,
onRedo,
canUndo,
canRedo,
disabled = false,
onInsertTemplate,
}: EditorToolbarProps) {
const [showHeadingMenu, setShowHeadingMenu] = useState(false);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const headingRef = useRef<HTMLDivElement>(null);
const insertRef = useRef<HTMLDivElement>(null);
// Close menus when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (headingRef.current && !headingRef.current.contains(e.target as Node)) {
setShowHeadingMenu(false);
}
if (insertRef.current && !insertRef.current.contains(e.target as Node)) {
setShowInsertMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="editor-toolbar-enhanced">
{/* Undo/Redo group */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onUndo}
disabled={disabled || !canUndo}
title="Undo (Ctrl+Z)"
className="toolbar-btn"
>
<Undo2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onRedo}
disabled={disabled || !canRedo}
title="Redo (Ctrl+Shift+Z)"
className="toolbar-btn"
>
<Redo2 className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Heading dropdown */}
<div className="toolbar-group heading-dropdown-container" ref={headingRef}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowHeadingMenu(!showHeadingMenu)}
disabled={disabled}
className="toolbar-btn heading-btn"
>
<Heading1 className="w-4 h-4" />
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showHeadingMenu && (
<div className="heading-dropdown">
<button
className="heading-option"
onClick={() => { onHeading(1); setShowHeadingMenu(false); }}
>
<Heading1 className="w-5 h-5" />
<span>Heading 1</span>
</button>
<button
className="heading-option"
onClick={() => { onHeading(2); setShowHeadingMenu(false); }}
>
<Heading2 className="w-5 h-5" />
<span>Heading 2</span>
</button>
<button
className="heading-option"
onClick={() => { onHeading(3); setShowHeadingMenu(false); }}
>
<Heading3 className="w-5 h-5" />
<span>Heading 3</span>
</button>
</div>
)}
</div>
<div className="toolbar-divider" />
{/* Text formatting group */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onBold}
disabled={disabled}
title="Bold (Ctrl+B)"
className="toolbar-btn"
>
<Bold className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onItalic}
disabled={disabled}
title="Italic (Ctrl+I)"
className="toolbar-btn"
>
<Italic className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onStrikethrough}
disabled={disabled}
title="Strikethrough"
className="toolbar-btn"
>
<Strikethrough className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Links and media group */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onLink}
disabled={disabled}
title="Insert Link (Ctrl+K)"
className="toolbar-btn"
>
<Link className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onImage}
disabled={disabled}
title="Insert Image"
className="toolbar-btn"
>
<Image className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Lists and quotes group */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onBulletList}
disabled={disabled}
title="Bullet List"
className="toolbar-btn"
>
<List className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onNumberedList}
disabled={disabled}
title="Numbered List"
className="toolbar-btn"
>
<ListOrdered className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onBlockquote}
disabled={disabled}
title="Blockquote"
className="toolbar-btn"
>
<Quote className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Code group */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onCode}
disabled={disabled}
title="Inline Code"
className="toolbar-btn"
>
<Code className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onCodeBlock}
disabled={disabled}
title="Code Block"
className="toolbar-btn"
>
<FileCode className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Table */}
<div className="toolbar-group">
<Button
variant="ghost"
size="icon-sm"
onClick={onTable}
disabled={disabled}
title="Insert Table"
className="toolbar-btn"
>
<Table className="w-4 h-4" />
</Button>
</div>
<div className="toolbar-divider" />
{/* Insert templates menu */}
{onInsertTemplate && (
<div className="toolbar-group heading-dropdown-container" ref={insertRef}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowInsertMenu(!showInsertMenu)}
disabled={disabled}
className="toolbar-btn heading-btn"
>
<Plus className="w-4 h-4" />
<span className="text-xs ml-1">Insert</span>
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
{showInsertMenu && (
<div className="heading-dropdown insert-dropdown">
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.infobox); setShowInsertMenu(false); }}
>
<Info className="w-4 h-4 text-blue-500" />
<span>Info Box</span>
</button>
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.warning); setShowInsertMenu(false); }}
>
<AlertTriangle className="w-4 h-4 text-yellow-500" />
<span>Warning Box</span>
</button>
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.tip); setShowInsertMenu(false); }}
>
<CheckCircle className="w-4 h-4 text-green-500" />
<span>Tip Box</span>
</button>
<div className="dropdown-divider" />
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.divider); setShowInsertMenu(false); }}
>
<Minus className="w-4 h-4" />
<span>Horizontal Rule</span>
</button>
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.definition); setShowInsertMenu(false); }}
>
<FileText className="w-4 h-4" />
<span>Definition</span>
</button>
<button
className="heading-option"
onClick={() => { onInsertTemplate(TEMPLATES.collapse); setShowInsertMenu(false); }}
>
<ChevronDown className="w-4 h-4" />
<span>Collapsible Section</span>
</button>
</div>
)}
</div>
)}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DiffViewer } from './diff-viewer';
interface HistoryEntry {
id: string;
oldContent: string;
newContent: string;
reason: string;
createdAt: string;
}
interface HistoryModalProps {
slug: string;
isOpen: boolean;
onClose: () => void;
}
export function HistoryModal({ slug, isOpen, onClose }: HistoryModalProps) {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<HistoryEntry | null>(null);
useEffect(() => {
if (!isOpen) return;
const fetchHistory = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/history`);
if (response.ok) {
const data = await response.json();
setHistory(data);
if (data.length > 0) {
setSelectedEntry(data[0]);
}
}
} catch (error) {
console.error('Failed to fetch history:', error);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, [slug, isOpen]);
if (!isOpen) return null;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
return (
<div className="history-modal-overlay" onClick={onClose}>
<div className="history-modal" onClick={(e) => e.stopPropagation()}>
<div className="history-modal-header">
<h2>Revision History</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<div className="history-modal-body">
{isLoading ? (
<div className="history-loading">Loading history...</div>
) : history.length === 0 ? (
<div className="history-empty">No revision history available.</div>
) : (
<>
<div className="history-list">
{history.map((entry) => (
<button
key={entry.id}
className={`history-item ${selectedEntry?.id === entry.id ? 'selected' : ''}`}
onClick={() => setSelectedEntry(entry)}
>
<div className="history-item-date">{formatDate(entry.createdAt)}</div>
<div className="history-item-reason">{entry.reason}</div>
</button>
))}
</div>
<div className="history-diff">
{selectedEntry && (
<DiffViewer
oldContent={selectedEntry.oldContent}
newContent={selectedEntry.newContent}
/>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
interface PageTransitionProps {
children: React.ReactNode;
href: string;
className?: string;
}
export function PageTransition({ children, href, className }: PageTransitionProps) {
const router = useRouter();
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
router.push(href);
}, [href, router]);
return (
<div onClick={handleClick} className={`inline-block cursor-pointer ${className || ''}`}>
{children}
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowRight, Info } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { SearchResult } from '@/app/api/search/route';
interface RelatedArticlesProps {
title: string;
currentSlug: string;
}
export function RelatedArticles({ title, currentSlug }: RelatedArticlesProps) {
const [articles, setArticles] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRelatedArticles = async () => {
try {
const response = await fetch(`/api/search?query=${encodeURIComponent(title)}`);
if (!response.ok) throw new Error('Failed to fetch related articles');
const data = await response.json();
const results = data.results || [];
// Filter out the current article and take top 5
const filtered = results
.filter((article: SearchResult) =>
article.title.replace(/ /g, '_').toLowerCase() !==
currentSlug.toLowerCase()
)
.slice(0, 5);
setArticles(filtered);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
fetchRelatedArticles();
}, [title, currentSlug]);
if (loading) {
return (
<aside className="space-y-4">
<h3 className="font-semibold text-foreground flex items-center gap-1.5">
Related articles
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3 h-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>This is currently in beta and may be inaccurate.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</h3>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-3 bg-muted rounded w-full"></div>
</div>
))}
</div>
</aside>
);
}
if (error || articles.length === 0) {
return null;
}
return (
<aside className="space-y-4">
<h3 className="font-semibold text-foreground flex items-center gap-1.5">
Related articles
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3 h-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>This is currently in beta and may be inaccurate.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</h3>
<ul className="space-y-3">
{articles.map((article) => {
const slug = article.title.replace(/ /g, '_').replace(/%20/g, '_');
return (
<li key={article.title}>
<Link
href={`/article/${slug}`}
className="group flex items-start gap-2 text-sm hover:opacity-80 transition-opacity"
>
<ArrowRight className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0 group-hover:text-blue-500 transition-colors" />
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
{article.title}
</span>
</Link>
</li>
);
})}
</ul>
</aside>
);
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { Check, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ReviewResultPanelProps {
approved: boolean;
reason: string;
onSave: () => void;
onContinueEditing: () => void;
isSaving: boolean;
}
export function ReviewResultPanel({
approved,
reason,
onSave,
onContinueEditing,
isSaving,
}: ReviewResultPanelProps) {
if (approved) {
return (
<div className="review-panel review-approved">
<div className="review-icon">
<Check className="w-6 h-6" />
</div>
<div className="review-content">
<h3 className="review-title">Changes Approved</h3>
<p className="review-reason">{reason}</p>
</div>
<div className="review-actions">
<Button
variant="outline"
onClick={onContinueEditing}
disabled={isSaving}
>
Continue Editing
</Button>
<Button
onClick={onSave}
disabled={isSaving}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
);
}
return (
<div className="review-panel review-rejected">
<div className="review-icon">
<X className="w-6 h-6" />
</div>
<div className="review-content">
<h3 className="review-title">Changes Not Approved</h3>
<p className="review-reason">{reason}</p>
</div>
<div className="review-actions">
<Button onClick={onContinueEditing}>
Continue Editing
</Button>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import type { TocItem } from './article-stream';
interface TableOfContentsProps {
headings: TocItem[];
}
export function TableOfContents({ headings }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('');
const updateActiveHeading = useCallback(() => {
if (headings.length === 0) return;
const headingElements = headings
.map((h) => ({
id: h.id,
element: document.getElementById(h.id),
}))
.filter((h) => h.element !== null);
if (headingElements.length === 0) return;
// Find the heading that's closest to the top of the viewport but still visible or just passed
const scrollPosition = window.scrollY + 120; // Account for sticky header
let currentHeading = headingElements[0].id;
for (const { id, element } of headingElements) {
if (element) {
const elementTop = element.offsetTop;
if (elementTop <= scrollPosition) {
currentHeading = id;
} else {
break;
}
}
}
setActiveId(currentHeading);
}, [headings]);
useEffect(() => {
// Initial update
updateActiveHeading();
// Throttled scroll handler
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveHeading();
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [updateActiveHeading]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault();
const element = document.getElementById(id);
if (element) {
const yOffset = -100;
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });
setActiveId(id);
}
};
if (headings.length === 0) {
return null;
}
return (
<nav className="sticky top-24">
<h4 className="text-sm font-semibold text-foreground mb-4">
On this page
</h4>
<ul className="space-y-1 text-sm border-l border-border">
{headings.map((heading) => (
<li
key={heading.id}
className={cn(
heading.level === 3 && 'ml-3'
)}
>
<a
href={`#${heading.id}`}
onClick={(e) => handleClick(e, heading.id)}
className={cn(
'block py-1.5 pl-4 -ml-px border-l-2 transition-colors',
activeId === heading.id
? 'border-blue-500 text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
interface WikiContentProps {
html: string;
}
export function WikiContent({ html }: WikiContentProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-5/6" />
<div className="h-4 bg-muted rounded w-4/6" />
<div className="h-4 bg-muted rounded w-full" />
<div className="h-4 bg-muted rounded w-3/6" />
</div>
);
}
return (
<div
className="wiki-content"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}