A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Create dashboard with repository and file browsing

- Build Header component with user info and logout button
- Create RepoSelector with sorting options (updated, created, name)
- Implement FileTree with recursive folder expansion
* Shows file/folder icons
* Highlights selected file
* Supports nested directory structure
- Build main Dashboard component with:
* Three-column layout (sidebar + main content)
* Repository selection interface
* File tree navigation
* Empty states for no repo/file selected
* Loading states with skeleton animations
* Placeholder for editor (Phase 2)
- Set up React Query provider for data management
- Use client:only directive for React hydration
- Build test successful

+409
+179
frontend/src/components/dashboard/DashboardApp.tsx
··· 1 + import { useState } from 'react'; 2 + import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 + import { Header } from '../layout/Header'; 4 + import { RepoSelector } from './RepoSelector'; 5 + import { FileTree } from './FileTree'; 6 + import { useFiles } from '../../lib/hooks/useRepos'; 7 + import { useCurrentUser } from '../../lib/hooks/useAuth'; 8 + 9 + const queryClient = new QueryClient({ 10 + defaultOptions: { 11 + queries: { 12 + refetchOnWindowFocus: false, 13 + retry: 1, 14 + }, 15 + }, 16 + }); 17 + 18 + export function DashboardApp() { 19 + return ( 20 + <QueryClientProvider client={queryClient}> 21 + <Dashboard /> 22 + </QueryClientProvider> 23 + ); 24 + } 25 + 26 + function Dashboard() { 27 + const [selectedRepo, setSelectedRepo] = useState<string | null>(null); 28 + const [selectedFile, setSelectedFile] = useState<string | null>(null); 29 + const { data: user, isLoading: userLoading } = useCurrentUser(); 30 + 31 + // Parse owner and repo from selectedRepo 32 + const [owner, repo] = selectedRepo ? selectedRepo.split('/') : ['', '']; 33 + 34 + const { data: filesData, isLoading: filesLoading } = useFiles( 35 + owner, 36 + repo, 37 + '', 38 + '', 39 + ['md', 'mdx'] 40 + ); 41 + 42 + if (userLoading) { 43 + return ( 44 + <div className="min-h-screen bg-gray-50 flex items-center justify-center"> 45 + <div className="text-center"> 46 + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div> 47 + <div className="text-gray-600">Loading...</div> 48 + </div> 49 + </div> 50 + ); 51 + } 52 + 53 + if (!user) { 54 + // Redirect to home if not authenticated 55 + if (typeof window !== 'undefined') { 56 + window.location.href = '/'; 57 + } 58 + return null; 59 + } 60 + 61 + return ( 62 + <div className="min-h-screen bg-gray-50"> 63 + <Header /> 64 + 65 + <div className="flex h-[calc(100vh-73px)]"> 66 + {/* Sidebar */} 67 + <aside className="w-80 bg-white border-r border-gray-200 overflow-y-auto"> 68 + <RepoSelector 69 + selectedRepo={selectedRepo} 70 + onSelectRepo={(repo) => { 71 + setSelectedRepo(repo); 72 + setSelectedFile(null); 73 + }} 74 + /> 75 + 76 + {selectedRepo && ( 77 + <> 78 + {filesLoading ? ( 79 + <div className="p-4"> 80 + <div className="animate-pulse space-y-2"> 81 + <div className="h-8 bg-gray-200 rounded"></div> 82 + <div className="h-8 bg-gray-200 rounded"></div> 83 + <div className="h-8 bg-gray-200 rounded"></div> 84 + </div> 85 + </div> 86 + ) : filesData?.files ? ( 87 + <FileTree 88 + files={filesData.files} 89 + selectedFile={selectedFile} 90 + onSelectFile={setSelectedFile} 91 + /> 92 + ) : null} 93 + </> 94 + )} 95 + </aside> 96 + 97 + {/* Main Content */} 98 + <main className="flex-1 overflow-y-auto"> 99 + {!selectedRepo ? ( 100 + <div className="h-full flex items-center justify-center"> 101 + <div className="text-center max-w-md"> 102 + <svg 103 + className="w-24 h-24 text-gray-300 mx-auto mb-6" 104 + fill="none" 105 + viewBox="0 0 24 24" 106 + stroke="currentColor" 107 + > 108 + <path 109 + strokeLinecap="round" 110 + strokeLinejoin="round" 111 + strokeWidth={1.5} 112 + d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" 113 + /> 114 + </svg> 115 + <h2 116 + className="text-2xl font-bold text-gray-900 mb-2" 117 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 118 + > 119 + Welcome to MarkEdit 120 + </h2> 121 + <p className="text-gray-600"> 122 + Select a repository from the sidebar to start editing your markdown files. 123 + </p> 124 + </div> 125 + </div> 126 + ) : !selectedFile ? ( 127 + <div className="h-full flex items-center justify-center"> 128 + <div className="text-center max-w-md"> 129 + <svg 130 + className="w-24 h-24 text-gray-300 mx-auto mb-6" 131 + fill="none" 132 + viewBox="0 0 24 24" 133 + stroke="currentColor" 134 + > 135 + <path 136 + strokeLinecap="round" 137 + strokeLinejoin="round" 138 + strokeWidth={1.5} 139 + 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" 140 + /> 141 + </svg> 142 + <h2 143 + className="text-2xl font-bold text-gray-900 mb-2" 144 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 145 + > 146 + Select a File 147 + </h2> 148 + <p className="text-gray-600"> 149 + Choose a markdown file from the file tree to start editing. 150 + </p> 151 + </div> 152 + </div> 153 + ) : ( 154 + <div className="p-8"> 155 + <div className="max-w-4xl mx-auto"> 156 + <div className="bg-white rounded-lg border border-gray-200 p-8"> 157 + <h2 className="text-xl font-bold text-gray-900 mb-4"> 158 + {selectedFile.split('/').pop()} 159 + </h2> 160 + <p className="text-gray-600"> 161 + Editor will be implemented in Phase 2. For now, you can browse your files. 162 + </p> 163 + <div className="mt-4 p-4 bg-gray-50 rounded border border-gray-200"> 164 + <div className="text-sm text-gray-600"> 165 + <strong>Selected:</strong> {selectedFile} 166 + </div> 167 + <div className="text-sm text-gray-600 mt-1"> 168 + <strong>Repository:</strong> {selectedRepo} 169 + </div> 170 + </div> 171 + </div> 172 + </div> 173 + </div> 174 + )} 175 + </main> 176 + </div> 177 + </div> 178 + ); 179 + }
+100
frontend/src/components/dashboard/FileTree.tsx
··· 1 + import { useState } from 'react'; 2 + import type { FileNode } from '../../lib/types/api'; 3 + 4 + interface FileTreeProps { 5 + files: FileNode[]; 6 + selectedFile: string | null; 7 + onSelectFile: (path: string) => void; 8 + } 9 + 10 + export function FileTree({ files, selectedFile, onSelectFile }: FileTreeProps) { 11 + return ( 12 + <div className="p-4 space-y-1"> 13 + {files.length === 0 ? ( 14 + <div className="text-sm text-gray-500 py-8 text-center"> 15 + No markdown files found in this repository 16 + </div> 17 + ) : ( 18 + files.map((file) => ( 19 + <FileTreeNode 20 + key={file.path} 21 + node={file} 22 + selectedFile={selectedFile} 23 + onSelectFile={onSelectFile} 24 + level={0} 25 + /> 26 + )) 27 + )} 28 + </div> 29 + ); 30 + } 31 + 32 + interface FileTreeNodeProps { 33 + node: FileNode; 34 + selectedFile: string | null; 35 + onSelectFile: (path: string) => void; 36 + level: number; 37 + } 38 + 39 + function FileTreeNode({ node, selectedFile, onSelectFile, level }: FileTreeNodeProps) { 40 + const [isExpanded, setIsExpanded] = useState(true); 41 + const isSelected = selectedFile === node.path; 42 + 43 + if (node.type === 'file') { 44 + return ( 45 + <button 46 + onClick={() => onSelectFile(node.path)} 47 + className={` 48 + w-full text-left px-3 py-2 rounded text-sm flex items-center gap-2 49 + transition-colors 50 + ${isSelected 51 + ? 'bg-gray-900 text-white font-medium' 52 + : 'text-gray-700 hover:bg-gray-100' 53 + } 54 + `} 55 + style={{ paddingLeft: `${(level + 1) * 12 + 12}px` }} 56 + > 57 + <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 58 + <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" /> 59 + </svg> 60 + <span className="truncate">{node.name}</span> 61 + </button> 62 + ); 63 + } 64 + 65 + return ( 66 + <div> 67 + <button 68 + onClick={() => setIsExpanded(!isExpanded)} 69 + 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" 70 + style={{ paddingLeft: `${level * 12 + 12}px` }} 71 + > 72 + <svg 73 + className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} 74 + fill="none" 75 + viewBox="0 0 24 24" 76 + stroke="currentColor" 77 + > 78 + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> 79 + </svg> 80 + <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 81 + <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" /> 82 + </svg> 83 + <span className="truncate">{node.name}</span> 84 + </button> 85 + {isExpanded && node.children && ( 86 + <div> 87 + {node.children.map((child) => ( 88 + <FileTreeNode 89 + key={child.path} 90 + node={child} 91 + selectedFile={selectedFile} 92 + onSelectFile={onSelectFile} 93 + level={level + 1} 94 + /> 95 + ))} 96 + </div> 97 + )} 98 + </div> 99 + ); 100 + }
+68
frontend/src/components/dashboard/RepoSelector.tsx
··· 1 + import { useState } from 'react'; 2 + import { useRepositories } from '../../lib/hooks/useRepos'; 3 + 4 + interface RepoSelectorProps { 5 + selectedRepo: string | null; 6 + onSelectRepo: (repoFullName: string) => void; 7 + } 8 + 9 + export function RepoSelector({ selectedRepo, onSelectRepo }: RepoSelectorProps) { 10 + const [sortBy, setSortBy] = useState<'updated' | 'created' | 'name'>('updated'); 11 + const { data: repos, isLoading, error } = useRepositories(sortBy); 12 + 13 + if (isLoading) { 14 + return ( 15 + <div className="p-4 border-b border-gray-200"> 16 + <div className="animate-pulse"> 17 + <div className="h-4 bg-gray-200 rounded w-32 mb-2"></div> 18 + <div className="h-10 bg-gray-200 rounded"></div> 19 + </div> 20 + </div> 21 + ); 22 + } 23 + 24 + if (error) { 25 + return ( 26 + <div className="p-4 border-b border-gray-200"> 27 + <div className="text-sm text-red-600">Failed to load repositories</div> 28 + </div> 29 + ); 30 + } 31 + 32 + return ( 33 + <div className="p-4 border-b border-gray-200 space-y-3"> 34 + <div className="flex items-center justify-between"> 35 + <label className="text-sm font-semibold text-gray-900">Select Repository</label> 36 + <select 37 + value={sortBy} 38 + onChange={(e) => setSortBy(e.target.value as any)} 39 + className="text-xs px-2 py-1 border border-gray-300 rounded" 40 + > 41 + <option value="updated">Recently Updated</option> 42 + <option value="created">Recently Created</option> 43 + <option value="name">Name</option> 44 + </select> 45 + </div> 46 + 47 + <select 48 + value={selectedRepo || ''} 49 + onChange={(e) => onSelectRepo(e.target.value)} 50 + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" 51 + > 52 + <option value="">Choose a repository...</option> 53 + {repos?.map((repo) => ( 54 + <option key={repo.id} value={repo.full_name}> 55 + {repo.full_name} 56 + {repo.private && ' 🔒'} 57 + </option> 58 + ))} 59 + </select> 60 + 61 + {repos && ( 62 + <div className="text-xs text-gray-500"> 63 + {repos.length} {repos.length === 1 ? 'repository' : 'repositories'} found 64 + </div> 65 + )} 66 + </div> 67 + ); 68 + }
+42
frontend/src/components/layout/Header.tsx
··· 1 + import { useCurrentUser, useLogout } from '../../lib/hooks/useAuth'; 2 + import { Button } from '../ui/button'; 3 + 4 + export function Header() { 5 + const { data: user } = useCurrentUser(); 6 + const logout = useLogout(); 7 + 8 + return ( 9 + <header className="border-b border-gray-200 bg-white"> 10 + <div className="container mx-auto px-6 py-4 flex items-center justify-between"> 11 + <div className="flex items-center gap-8"> 12 + <h1 className="text-2xl font-bold" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 13 + MarkEdit 14 + </h1> 15 + </div> 16 + 17 + {user && ( 18 + <div className="flex items-center gap-4"> 19 + <div className="flex items-center gap-3"> 20 + {user.avatar_url && ( 21 + <img 22 + src={user.avatar_url} 23 + alt={user.username} 24 + className="w-8 h-8 rounded-full" 25 + /> 26 + )} 27 + <span className="text-sm font-medium text-gray-700">{user.username}</span> 28 + </div> 29 + <Button 30 + variant="ghost" 31 + size="sm" 32 + onClick={() => logout.mutate()} 33 + disabled={logout.isPending} 34 + > 35 + {logout.isPending ? 'Logging out...' : 'Logout'} 36 + </Button> 37 + </div> 38 + )} 39 + </div> 40 + </header> 41 + ); 42 + }
+20
frontend/src/pages/dashboard.astro
··· 1 + --- 2 + import '../styles/globals.css'; 3 + import { DashboardApp } from '../components/dashboard/DashboardApp'; 4 + --- 5 + 6 + <html lang="en"> 7 + <head> 8 + <meta charset="utf-8" /> 9 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 10 + <meta name="viewport" content="width=device-width" /> 11 + <meta name="generator" content={Astro.generator} /> 12 + <title>Dashboard - MarkEdit</title> 13 + <link rel="preconnect" href="https://fonts.googleapis.com"> 14 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 15 + <link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Crimson+Pro:wght@400;600&display=swap" rel="stylesheet"> 16 + </head> 17 + <body> 18 + <DashboardApp client:only="react" /> 19 + </body> 20 + </html>