A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
1import { useState, useRef, useEffect } from 'react';
2import type { FileNode } from '../../lib/types/api';
3
4interface FileTreeProps {
5 files: FileNode[];
6 selectedFile: string | null;
7 onSelectFile: (path: string) => void;
8 owner: string;
9 repo: string;
10 onCreateFile?: (parentPath: string) => void;
11 onCreateFolder?: (parentPath: string) => void;
12 onRename?: (oldPath: string, newName: string) => void;
13}
14
15export function FileTree({
16 files,
17 selectedFile,
18 onSelectFile,
19 owner,
20 repo,
21 onCreateFile,
22 onCreateFolder,
23 onRename,
24}: FileTreeProps) {
25 return (
26 <div className="p-4 space-y-1">
27 {files.length === 0 ? (
28 <div className="text-sm text-gray-500 py-8 text-center">
29 No markdown files found in this repository
30 </div>
31 ) : (
32 files.map((file) => (
33 <FileTreeNode
34 key={file.path}
35 node={file}
36 selectedFile={selectedFile}
37 onSelectFile={onSelectFile}
38 level={0}
39 owner={owner}
40 repo={repo}
41 onCreateFile={onCreateFile}
42 onCreateFolder={onCreateFolder}
43 onRename={onRename}
44 />
45 ))
46 )}
47 </div>
48 );
49}
50
51interface FileTreeNodeProps {
52 node: FileNode;
53 selectedFile: string | null;
54 onSelectFile: (path: string) => void;
55 level: number;
56 owner: string;
57 repo: string;
58 onCreateFile?: (parentPath: string) => void;
59 onCreateFolder?: (parentPath: string) => void;
60 onRename?: (oldPath: string, newName: string) => void;
61}
62
63function FileTreeNode({
64 node,
65 selectedFile,
66 onSelectFile,
67 level,
68 owner,
69 repo,
70 onCreateFile,
71 onCreateFolder,
72 onRename,
73}: FileTreeNodeProps) {
74 const [isExpanded, setIsExpanded] = useState(true);
75 const [showActions, setShowActions] = useState(false);
76 const [isRenaming, setIsRenaming] = useState(false);
77 const [renameValue, setRenameValue] = useState(node.name);
78 const renameInputRef = useRef<HTMLInputElement>(null);
79 const isSelected = selectedFile === node.path;
80
81 useEffect(() => {
82 if (isRenaming && renameInputRef.current) {
83 renameInputRef.current.focus();
84 renameInputRef.current.select();
85 }
86 }, [isRenaming]);
87
88 const handleRenameSubmit = () => {
89 if (renameValue.trim() && renameValue !== node.name && onRename) {
90 // Replace spaces with hyphens
91 let sanitizedName = renameValue.trim().replace(/\s+/g, '-');
92
93 // For files, ensure .md extension if not present
94 if (node.type === 'file' && !sanitizedName.endsWith('.md') && !sanitizedName.endsWith('.mdx')) {
95 sanitizedName += '.md';
96 }
97
98 onRename(node.path, sanitizedName);
99 }
100 setIsRenaming(false);
101 setRenameValue(node.name);
102 };
103
104 const handleRenameCancel = () => {
105 setIsRenaming(false);
106 setRenameValue(node.name);
107 };
108
109 const handleKeyDown = (e: React.KeyboardEvent) => {
110 if (e.key === 'Enter') {
111 handleRenameSubmit();
112 } else if (e.key === 'Escape') {
113 handleRenameCancel();
114 }
115 };
116
117 if (node.type === 'file') {
118 return (
119 <div
120 className="group relative"
121 onMouseEnter={() => setShowActions(true)}
122 onMouseLeave={() => setShowActions(false)}
123 >
124 {isRenaming ? (
125 <div
126 className="flex items-center gap-2 px-3 py-2"
127 style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }}
128 >
129 <svg className="w-4 h-4 flex-shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
130 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
131 </svg>
132 <input
133 ref={renameInputRef}
134 type="text"
135 value={renameValue}
136 onChange={(e) => setRenameValue(e.target.value)}
137 onKeyDown={handleKeyDown}
138 onBlur={handleRenameSubmit}
139 className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-gray-900"
140 />
141 </div>
142 ) : (
143 <button
144 onClick={() => onSelectFile(node.path)}
145 className={`
146 w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2
147 transition-colors
148 ${isSelected
149 ? 'bg-gray-900 text-white font-medium'
150 : 'text-gray-700 hover:bg-gray-100'
151 }
152 `}
153 style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }}
154 >
155 <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
156 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
157 </svg>
158 <span className="truncate">{node.name}</span>
159 </button>
160 )}
161
162 {/* Actions menu for files */}
163 {showActions && !isRenaming && (
164 <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
165 <button
166 onClick={(e) => {
167 e.stopPropagation();
168 setIsRenaming(true);
169 }}
170 className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700"
171 title="Rename file"
172 >
173 <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
174 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
175 </svg>
176 </button>
177 </div>
178 )}
179 </div>
180 );
181 }
182
183 return (
184 <div>
185 <div
186 className="group relative"
187 onMouseEnter={() => setShowActions(true)}
188 onMouseLeave={() => setShowActions(false)}
189 >
190 {isRenaming ? (
191 <div
192 className="flex items-center gap-2 px-3 py-2"
193 style={{ paddingLeft: `${level * 12 + 12}px` }}
194 >
195 <svg
196 className={`w-4 h-4 flex-shrink-0 text-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
197 fill="none"
198 viewBox="0 0 24 24"
199 stroke="currentColor"
200 >
201 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
202 </svg>
203 <svg className="w-4 h-4 flex-shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
204 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
205 </svg>
206 <input
207 ref={renameInputRef}
208 type="text"
209 value={renameValue}
210 onChange={(e) => setRenameValue(e.target.value)}
211 onKeyDown={handleKeyDown}
212 onBlur={handleRenameSubmit}
213 className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-gray-900"
214 />
215 </div>
216 ) : (
217 <button
218 onClick={() => setIsExpanded(!isExpanded)}
219 className="w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 text-gray-700 hover:bg-gray-100 font-medium"
220 style={{ paddingLeft: `${level * 12 + 12}px` }}
221 >
222 <svg
223 className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
224 fill="none"
225 viewBox="0 0 24 24"
226 stroke="currentColor"
227 >
228 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
229 </svg>
230 <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
231 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
232 </svg>
233 <span className="truncate">{node.name}</span>
234 </button>
235 )}
236
237 {/* Actions menu for folders */}
238 {showActions && !isRenaming && (
239 <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
240 <button
241 onClick={(e) => {
242 e.stopPropagation();
243 onCreateFile?.(node.path);
244 }}
245 className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700"
246 title="Add file"
247 >
248 <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
249 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
250 </svg>
251 </button>
252 <button
253 onClick={(e) => {
254 e.stopPropagation();
255 onCreateFolder?.(node.path);
256 }}
257 className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700"
258 title="Add folder"
259 >
260 <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
261 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
262 </svg>
263 </button>
264 <button
265 onClick={(e) => {
266 e.stopPropagation();
267 setIsRenaming(true);
268 }}
269 className="p-1 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700"
270 title="Rename folder"
271 >
272 <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
273 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
274 </svg>
275 </button>
276 </div>
277 )}
278 </div>
279
280 {isExpanded && node.children && (
281 <div>
282 {node.children.map((child) => (
283 <FileTreeNode
284 key={child.path}
285 node={child}
286 selectedFile={selectedFile}
287 onSelectFile={onSelectFile}
288 level={level + 1}
289 owner={owner}
290 repo={repo}
291 onCreateFile={onCreateFile}
292 onCreateFolder={onCreateFolder}
293 onRename={onRename}
294 />
295 ))}
296 </div>
297 )}
298 </div>
299 );
300}