'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(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([initialContent]); const [redoStack, setRedoStack] = useState([]); const [findReplace, setFindReplace] = useState({ isOpen: false, findText: '', replaceText: '', matchCount: 0, currentMatch: 0, }); const textareaRef = useRef(null); const previewRef = useRef(null); const editorContainerRef = useRef(null); const lastSavedContent = useRef(initialContent); const isScrollingSyncRef = useRef(false); const autoSaveTimeoutRef = useRef(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(//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 (
{/* Recovery banner */} {showRecoveryBanner && (
A previous draft was found. Would you like to recover it?
)} {/* Header bar */}
{hasChanges ? ( Unsaved changes {autoSaveStatus === 'saving' && ' (saving...)'} {autoSaveStatus === 'saved' && ' (draft saved)'} ) : ( No changes )}
{wordCount.toLocaleString()} words · {charCount.toLocaleString()} chars · {lineCount.toLocaleString()} lines
{/* Find & Replace bar */} {findReplace.isOpen && (
setFindReplace(prev => ({ ...prev, findText: e.target.value }))} className="find-input" autoFocus /> {findReplace.matchCount > 0 && ( {findReplace.currentMatch} of {findReplace.matchCount} )}
setFindReplace(prev => ({ ...prev, replaceText: e.target.value }))} className="replace-input" />
)} {/* Toolbar */} {/* Main editor area */}
{showDiff && hasChanges ? ( ) : ( <>