Major changes
This commit is contained in:
@@ -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) + `` + content.substring(end);
|
||||
updateContent(newText);
|
||||
} else {
|
||||
insertAtCursor('');
|
||||
}
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user