A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
at main 300 lines 11 kB view raw
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}