A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
at main 251 lines 8.3 kB view raw
1import { useState, useEffect, useCallback, useRef } from 'react'; 2import { toast } from 'sonner'; 3import { TipTapEditor } from './TipTapEditor'; 4import { FrontmatterEditor } from './FrontmatterEditor'; 5import { PublishDialog } from './PublishDialog'; 6import { PublishSuccessDialog } from './PublishSuccessDialog'; 7import { useFileContent, useUpdateFile } from '../../lib/hooks/useFileContent'; 8import { useBranchStatus, usePublish } from '../../lib/hooks/useBranch'; 9import { debounce } from '../../lib/utils/debounce'; 10import { Loading } from '../ui/Loading'; 11import type { PublishResponse } from '../../lib/api/branch'; 12 13interface EditorContainerProps { 14 owner: string; 15 repo: string; 16 path: string; 17 onClose?: () => void; 18} 19 20export function EditorContainer({ owner, repo, path, onClose }: EditorContainerProps) { 21 const { data: fileData, isLoading, error } = useFileContent(owner, repo, path); 22 const { data: branchStatus } = useBranchStatus(owner, repo); 23 const updateFile = useUpdateFile(); 24 const publish = usePublish(); 25 26 const [content, setContent] = useState(''); 27 const [frontmatter, setFrontmatter] = useState<Record<string, any>>({}); 28 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); 29 const [lastSaved, setLastSaved] = useState<Date | null>(null); 30 const [isSaving, setIsSaving] = useState(false); 31 const [showPublishDialog, setShowPublishDialog] = useState(false); 32 const [showSuccessDialog, setShowSuccessDialog] = useState(false); 33 const [publishResult, setPublishResult] = useState<PublishResponse | undefined>(); 34 35 // Keep track of initial content to detect changes 36 const initialContentRef = useRef<{ content: string; frontmatter: Record<string, any> } | null>(null); 37 38 // Load file content when data is available 39 useEffect(() => { 40 if (fileData) { 41 setContent(fileData.content || ''); 42 setFrontmatter(fileData.frontmatter || {}); 43 initialContentRef.current = { 44 content: fileData.content || '', 45 frontmatter: fileData.frontmatter || {}, 46 }; 47 setHasUnsavedChanges(false); 48 } 49 }, [fileData]); 50 51 // Auto-save function 52 const saveChanges = useCallback(async () => { 53 if (!hasUnsavedChanges) return; 54 55 setIsSaving(true); 56 try { 57 await updateFile.mutateAsync({ 58 owner, 59 repo, 60 path, 61 content, 62 frontmatter, 63 }); 64 setLastSaved(new Date()); 65 setHasUnsavedChanges(false); 66 } catch (error) { 67 console.error('Failed to save:', error); 68 toast.error('Failed to save changes', { 69 description: error instanceof Error ? error.message : 'Please try again', 70 }); 71 } finally { 72 setIsSaving(false); 73 } 74 }, [owner, repo, path, content, frontmatter, hasUnsavedChanges, updateFile]); 75 76 // Debounced auto-save (2 seconds) 77 const debouncedSave = useCallback( 78 debounce(() => { 79 saveChanges(); 80 }, 2000), 81 [saveChanges] 82 ); 83 84 // Handle content changes 85 const handleContentChange = (newContent: string) => { 86 setContent(newContent); 87 setHasUnsavedChanges(true); 88 debouncedSave(); 89 }; 90 91 // Handle frontmatter changes 92 const handleFrontmatterChange = (newFrontmatter: Record<string, any>) => { 93 setFrontmatter(newFrontmatter); 94 setHasUnsavedChanges(true); 95 debouncedSave(); 96 }; 97 98 // Warn before leaving if there are unsaved changes 99 useEffect(() => { 100 const handleBeforeUnload = (e: BeforeUnloadEvent) => { 101 if (hasUnsavedChanges) { 102 e.preventDefault(); 103 e.returnValue = ''; 104 } 105 }; 106 107 window.addEventListener('beforeunload', handleBeforeUnload); 108 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 109 }, [hasUnsavedChanges]); 110 111 // Handle publish 112 const handlePublish = async (commitMessage: string, prTitle: string, prDescription: string) => { 113 try { 114 const result = await publish.mutateAsync({ 115 owner, 116 repo, 117 commit_message: commitMessage, 118 pr_title: prTitle, 119 pr_description: prDescription, 120 files: [path], 121 }); 122 123 setPublishResult(result); 124 setShowPublishDialog(false); 125 setShowSuccessDialog(true); 126 setHasUnsavedChanges(false); 127 toast.success('Pull request created successfully!', { 128 description: `Branch: ${result.branch_name}`, 129 }); 130 } catch (error) { 131 console.error('Failed to publish:', error); 132 toast.error('Failed to publish changes', { 133 description: error instanceof Error ? error.message : 'Please try again', 134 }); 135 } 136 }; 137 138 if (isLoading) { 139 return ( 140 <div className="flex items-center justify-center h-full"> 141 <Loading size="lg" text="Loading file..." /> 142 </div> 143 ); 144 } 145 146 if (error) { 147 return ( 148 <div className="flex items-center justify-center h-full"> 149 <div className="text-center max-w-md"> 150 <div className="text-red-600 text-xl font-bold mb-2">Error Loading File</div> 151 <div className="text-gray-600 mb-4"> 152 {error instanceof Error ? error.message : 'Unknown error'} 153 </div> 154 {onClose && ( 155 <button 156 onClick={onClose} 157 className="px-4 py-2 bg-gray-900 text-white font-semibold hover:bg-gray-800" 158 > 159 Go Back 160 </button> 161 )} 162 </div> 163 </div> 164 ); 165 } 166 167 return ( 168 <div className="h-full flex flex-col"> 169 {/* Header */} 170 <div className="bg-white border-b-2 border-gray-900 px-6 py-4 flex items-center justify-between"> 171 <div className="flex-1"> 172 <h2 173 className="text-xl font-bold mb-1" 174 style={{ fontFamily: 'Archivo Black, sans-serif' }} 175 > 176 {path.split('/').pop()} 177 </h2> 178 <div className="text-sm text-gray-600 font-mono">{path}</div> 179 </div> 180 181 <div className="flex items-center gap-4"> 182 {/* Save status */} 183 <div className="text-sm"> 184 {isSaving ? ( 185 <span className="text-amber-600 font-semibold">Saving...</span> 186 ) : hasUnsavedChanges ? ( 187 <span className="text-gray-600">Unsaved changes</span> 188 ) : lastSaved ? ( 189 <span className="text-green-600 font-semibold"> 190 Saved {lastSaved.toLocaleTimeString()} 191 </span> 192 ) : null} 193 </div> 194 195 {/* Manual save button */} 196 <button 197 onClick={saveChanges} 198 disabled={!hasUnsavedChanges || isSaving} 199 className="px-4 py-2 bg-white text-gray-900 font-semibold border-2 border-gray-900 hover:bg-gray-100 disabled:bg-gray-200 disabled:cursor-not-allowed transition-colors" 200 > 201 {isSaving ? 'Saving...' : 'Save'} 202 </button> 203 204 {/* Publish button */} 205 <button 206 onClick={() => setShowPublishDialog(true)} 207 disabled={hasUnsavedChanges} 208 className="px-6 py-2 bg-amber-500 text-gray-900 font-bold border-2 border-gray-900 hover:bg-amber-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" 209 style={{ fontFamily: 'Archivo Black, sans-serif' }} 210 title={hasUnsavedChanges ? 'Save changes before publishing' : 'Create pull request'} 211 > 212 Publish 213 </button> 214 </div> 215 </div> 216 217 {/* Editor Content */} 218 <div className="flex-1 overflow-y-auto p-6 bg-gray-50"> 219 <div className="max-w-4xl mx-auto space-y-6"> 220 <FrontmatterEditor 221 frontmatter={frontmatter} 222 onChange={handleFrontmatterChange} 223 /> 224 225 <TipTapEditor 226 content={content} 227 onChange={handleContentChange} 228 placeholder="Start writing your post..." 229 /> 230 </div> 231 </div> 232 233 {/* Publish Dialog */} 234 <PublishDialog 235 isOpen={showPublishDialog} 236 onClose={() => setShowPublishDialog(false)} 237 onPublish={handlePublish} 238 branchStatus={branchStatus} 239 currentFilePath={path} 240 isPublishing={publish.isPending} 241 /> 242 243 {/* Success Dialog */} 244 <PublishSuccessDialog 245 isOpen={showSuccessDialog} 246 onClose={() => setShowSuccessDialog(false)} 247 publishResult={publishResult} 248 /> 249 </div> 250 ); 251}