A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
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}