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.

Add publish workflow with PR creation for Phase 3

- Create branch API client with status and publish endpoints
- Add useBranchStatus and usePublish React Query hooks
- Create PublishDialog component with commit and PR inputs
- Create PublishSuccessDialog showing PR link and commit info
- Integrate publish flow into EditorContainer
- Add Publish button that creates PRs from edited files
- Disable publish button when there are unsaved changes
- Show branch status and edited files in publish dialog
- Display success message with link to created PR

+401 -1
+60 -1
frontend/src/components/editor/EditorContainer.tsx
··· 1 1 import { useState, useEffect, useCallback, useRef } from 'react'; 2 2 import { TipTapEditor } from './TipTapEditor'; 3 3 import { FrontmatterEditor } from './FrontmatterEditor'; 4 + import { PublishDialog } from './PublishDialog'; 5 + import { PublishSuccessDialog } from './PublishSuccessDialog'; 4 6 import { useFileContent, useUpdateFile } from '../../lib/hooks/useFileContent'; 7 + import { useBranchStatus, usePublish } from '../../lib/hooks/useBranch'; 5 8 import { debounce } from '../../lib/utils/debounce'; 9 + import type { PublishResponse } from '../../lib/api/branch'; 6 10 7 11 interface EditorContainerProps { 8 12 owner: string; ··· 13 17 14 18 export function EditorContainer({ owner, repo, path, onClose }: EditorContainerProps) { 15 19 const { data: fileData, isLoading, error } = useFileContent(owner, repo, path); 20 + const { data: branchStatus } = useBranchStatus(owner, repo); 16 21 const updateFile = useUpdateFile(); 22 + const publish = usePublish(); 17 23 18 24 const [content, setContent] = useState(''); 19 25 const [frontmatter, setFrontmatter] = useState<Record<string, any>>({}); 20 26 const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); 21 27 const [lastSaved, setLastSaved] = useState<Date | null>(null); 22 28 const [isSaving, setIsSaving] = useState(false); 29 + const [showPublishDialog, setShowPublishDialog] = useState(false); 30 + const [showSuccessDialog, setShowSuccessDialog] = useState(false); 31 + const [publishResult, setPublishResult] = useState<PublishResponse | undefined>(); 23 32 24 33 // Keep track of initial content to detect changes 25 34 const initialContentRef = useRef<{ content: string; frontmatter: Record<string, any> } | null>(null); ··· 93 102 window.addEventListener('beforeunload', handleBeforeUnload); 94 103 return () => window.removeEventListener('beforeunload', handleBeforeUnload); 95 104 }, [hasUnsavedChanges]); 105 + 106 + // Handle publish 107 + const handlePublish = async (commitMessage: string, prTitle: string, prDescription: string) => { 108 + try { 109 + const result = await publish.mutateAsync({ 110 + owner, 111 + repo, 112 + commit_message: commitMessage, 113 + pr_title: prTitle, 114 + pr_description: prDescription, 115 + files: [path], 116 + }); 117 + 118 + setPublishResult(result); 119 + setShowPublishDialog(false); 120 + setShowSuccessDialog(true); 121 + setHasUnsavedChanges(false); 122 + } catch (error) { 123 + console.error('Failed to publish:', error); 124 + alert('Failed to publish changes. Please try again.'); 125 + } 126 + }; 96 127 97 128 if (isLoading) { 98 129 return ( ··· 158 189 <button 159 190 onClick={saveChanges} 160 191 disabled={!hasUnsavedChanges || isSaving} 161 - className="px-4 py-2 bg-gray-900 text-white font-semibold hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors border-2 border-gray-900" 192 + 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" 162 193 > 163 194 {isSaving ? 'Saving...' : 'Save'} 195 + </button> 196 + 197 + {/* Publish button */} 198 + <button 199 + onClick={() => setShowPublishDialog(true)} 200 + disabled={hasUnsavedChanges} 201 + 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" 202 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 203 + title={hasUnsavedChanges ? 'Save changes before publishing' : 'Create pull request'} 204 + > 205 + Publish → 164 206 </button> 165 207 </div> 166 208 </div> ··· 180 222 /> 181 223 </div> 182 224 </div> 225 + 226 + {/* Publish Dialog */} 227 + <PublishDialog 228 + isOpen={showPublishDialog} 229 + onClose={() => setShowPublishDialog(false)} 230 + onPublish={handlePublish} 231 + branchStatus={branchStatus} 232 + currentFilePath={path} 233 + isPublishing={publish.isPending} 234 + /> 235 + 236 + {/* Success Dialog */} 237 + <PublishSuccessDialog 238 + isOpen={showSuccessDialog} 239 + onClose={() => setShowSuccessDialog(false)} 240 + publishResult={publishResult} 241 + /> 183 242 </div> 184 243 ); 185 244 }
+153
frontend/src/components/editor/PublishDialog.tsx
··· 1 + import { useState } from 'react'; 2 + import type { BranchStatus } from '../../lib/api/branch'; 3 + 4 + interface PublishDialogProps { 5 + isOpen: boolean; 6 + onClose: () => void; 7 + onPublish: (commitMessage: string, prTitle: string, prDescription: string) => void; 8 + branchStatus?: BranchStatus; 9 + currentFilePath: string; 10 + isPublishing: boolean; 11 + } 12 + 13 + export function PublishDialog({ 14 + isOpen, 15 + onClose, 16 + onPublish, 17 + branchStatus, 18 + currentFilePath, 19 + isPublishing, 20 + }: PublishDialogProps) { 21 + const [commitMessage, setCommitMessage] = useState(''); 22 + const [prTitle, setPrTitle] = useState(''); 23 + const [prDescription, setPrDescription] = useState(''); 24 + 25 + if (!isOpen) return null; 26 + 27 + const handleSubmit = (e: React.FormEvent) => { 28 + e.preventDefault(); 29 + if (!commitMessage.trim()) return; 30 + onPublish(commitMessage, prTitle, prDescription); 31 + }; 32 + 33 + const fileName = currentFilePath.split('/').pop() || 'file'; 34 + 35 + return ( 36 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> 37 + <div className="bg-white border-4 border-gray-900 max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 38 + {/* Header */} 39 + <div className="bg-amber-500 border-b-4 border-gray-900 p-6"> 40 + <h2 41 + className="text-3xl font-bold text-gray-900" 42 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 43 + > 44 + Publish Changes 45 + </h2> 46 + <p className="text-gray-800 mt-2"> 47 + Create a pull request with your changes 48 + </p> 49 + </div> 50 + 51 + {/* Content */} 52 + <form onSubmit={handleSubmit} className="p-6 space-y-6"> 53 + {/* Branch Info */} 54 + {branchStatus && branchStatus.branch_name && ( 55 + <div className="bg-gray-50 border-2 border-gray-900 p-4"> 56 + <div className="text-sm font-semibold mb-2" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 57 + Branch Information 58 + </div> 59 + <div className="space-y-1 text-sm font-mono"> 60 + <div> 61 + <span className="text-gray-600">Branch:</span>{' '} 62 + <span className="font-semibold">{branchStatus.branch_name}</span> 63 + </div> 64 + <div> 65 + <span className="text-gray-600">Target:</span>{' '} 66 + <span className="font-semibold">{branchStatus.base_branch || 'main'}</span> 67 + </div> 68 + </div> 69 + </div> 70 + )} 71 + 72 + {/* Files to Publish */} 73 + <div className="bg-amber-50 border-2 border-gray-900 p-4"> 74 + <div className="text-sm font-semibold mb-2" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 75 + Files to Publish 76 + </div> 77 + <div className="flex items-center gap-2"> 78 + <div className="w-3 h-3 bg-amber-600"></div> 79 + <span className="font-mono text-sm">{currentFilePath}</span> 80 + </div> 81 + </div> 82 + 83 + {/* Commit Message */} 84 + <div> 85 + <label className="block text-sm font-bold mb-2" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 86 + Commit Message * 87 + </label> 88 + <input 89 + type="text" 90 + value={commitMessage} 91 + onChange={(e) => setCommitMessage(e.target.value)} 92 + placeholder={`Update ${fileName}`} 93 + className="w-full px-4 py-3 border-2 border-gray-900 bg-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-amber-500" 94 + required 95 + autoFocus 96 + /> 97 + <p className="text-xs text-gray-600 mt-1"> 98 + A brief description of your changes 99 + </p> 100 + </div> 101 + 102 + {/* PR Title */} 103 + <div> 104 + <label className="block text-sm font-bold mb-2" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 105 + Pull Request Title 106 + </label> 107 + <input 108 + type="text" 109 + value={prTitle} 110 + onChange={(e) => setPrTitle(e.target.value)} 111 + placeholder="Leave empty to use commit message" 112 + className="w-full px-4 py-3 border-2 border-gray-900 bg-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-amber-500" 113 + /> 114 + </div> 115 + 116 + {/* PR Description */} 117 + <div> 118 + <label className="block text-sm font-bold mb-2" style={{ fontFamily: 'Archivo Black, sans-serif' }}> 119 + Pull Request Description 120 + </label> 121 + <textarea 122 + value={prDescription} 123 + onChange={(e) => setPrDescription(e.target.value)} 124 + placeholder="Optional: Add more details about your changes" 125 + rows={4} 126 + className="w-full px-4 py-3 border-2 border-gray-900 bg-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-amber-500 resize-none" 127 + /> 128 + </div> 129 + 130 + {/* Actions */} 131 + <div className="flex gap-3 pt-4 border-t-2 border-gray-900"> 132 + <button 133 + type="button" 134 + onClick={onClose} 135 + disabled={isPublishing} 136 + className="flex-1 px-6 py-3 bg-gray-200 border-2 border-gray-900 text-gray-900 font-semibold hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 137 + > 138 + Cancel 139 + </button> 140 + <button 141 + type="submit" 142 + disabled={!commitMessage.trim() || isPublishing} 143 + className="flex-1 px-6 py-3 bg-amber-500 border-2 border-gray-900 text-gray-900 font-semibold hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" 144 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 145 + > 146 + {isPublishing ? 'Publishing...' : 'Create Pull Request →'} 147 + </button> 148 + </div> 149 + </form> 150 + </div> 151 + </div> 152 + ); 153 + }
+107
frontend/src/components/editor/PublishSuccessDialog.tsx
··· 1 + import type { PublishResponse } from '../../lib/api/branch'; 2 + 3 + interface PublishSuccessDialogProps { 4 + isOpen: boolean; 5 + onClose: () => void; 6 + publishResult?: PublishResponse; 7 + } 8 + 9 + export function PublishSuccessDialog({ 10 + isOpen, 11 + onClose, 12 + publishResult, 13 + }: PublishSuccessDialogProps) { 14 + if (!isOpen || !publishResult) return null; 15 + 16 + const hasPR = publishResult.pull_request; 17 + 18 + return ( 19 + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> 20 + <div className="bg-white border-4 border-gray-900 max-w-lg w-full"> 21 + {/* Header */} 22 + <div className="bg-green-500 border-b-4 border-gray-900 p-6"> 23 + <div className="flex items-center gap-4"> 24 + <div className="w-16 h-16 bg-gray-900 flex items-center justify-center text-4xl"> 25 + 26 + </div> 27 + <div> 28 + <h2 29 + className="text-2xl font-bold text-gray-900" 30 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 31 + > 32 + {hasPR ? 'Pull Request Created!' : 'Changes Published!'} 33 + </h2> 34 + <p className="text-gray-800 text-sm mt-1"> 35 + Your changes have been committed 36 + </p> 37 + </div> 38 + </div> 39 + </div> 40 + 41 + {/* Content */} 42 + <div className="p-6 space-y-4"> 43 + {/* Commit Info */} 44 + <div className="bg-gray-50 border-2 border-gray-900 p-4"> 45 + <div className="text-xs text-gray-600 mb-1">COMMIT SHA</div> 46 + <div className="font-mono text-sm font-semibold"> 47 + {publishResult.commit_sha.substring(0, 7)} 48 + </div> 49 + </div> 50 + 51 + {/* Branch Info */} 52 + <div className="bg-gray-50 border-2 border-gray-900 p-4"> 53 + <div className="text-xs text-gray-600 mb-1">BRANCH</div> 54 + <div className="font-mono text-sm font-semibold"> 55 + {publishResult.branch} 56 + </div> 57 + </div> 58 + 59 + {/* PR Link */} 60 + {hasPR && ( 61 + <div className="bg-amber-50 border-2 border-gray-900 p-4"> 62 + <div className="text-xs text-gray-600 mb-2">PULL REQUEST</div> 63 + <div className="flex items-center gap-2 mb-3"> 64 + <div className="w-3 h-3 bg-green-600"></div> 65 + <span className="font-mono text-sm font-semibold"> 66 + #{publishResult.pull_request.number} 67 + </span> 68 + <span className="text-sm text-gray-600"> 69 + {publishResult.pull_request.title} 70 + </span> 71 + </div> 72 + <a 73 + href={publishResult.pull_request.html_url} 74 + target="_blank" 75 + rel="noopener noreferrer" 76 + className="inline-flex items-center gap-2 px-4 py-2 bg-gray-900 text-white font-semibold hover:bg-gray-800 transition-colors text-sm" 77 + > 78 + View Pull Request → 79 + </a> 80 + </div> 81 + )} 82 + 83 + {/* Error Message */} 84 + {publishResult.error && ( 85 + <div className="bg-yellow-50 border-2 border-yellow-600 p-4"> 86 + <div className="text-xs text-yellow-800 font-bold mb-1">WARNING</div> 87 + <div className="text-sm text-yellow-900"> 88 + {publishResult.error} 89 + </div> 90 + </div> 91 + )} 92 + 93 + {/* Actions */} 94 + <div className="pt-4 border-t-2 border-gray-900"> 95 + <button 96 + onClick={onClose} 97 + className="w-full px-6 py-3 bg-gray-900 text-white font-semibold hover:bg-gray-800 transition-colors" 98 + style={{ fontFamily: 'Archivo Black, sans-serif' }} 99 + > 100 + Continue Editing 101 + </button> 102 + </div> 103 + </div> 104 + </div> 105 + </div> 106 + ); 107 + }
+53
frontend/src/lib/api/branch.ts
··· 1 + import { apiClient } from './client'; 2 + 3 + export interface BranchStatus { 4 + branch_name: string; 5 + base_branch: string; 6 + has_changes: boolean; 7 + last_push_at: string; 8 + edited_files: string[]; 9 + hours_since_push: number; 10 + has_draft_content: boolean; 11 + } 12 + 13 + export interface PublishParams { 14 + owner: string; 15 + repo: string; 16 + commit_message: string; 17 + pr_title?: string; 18 + pr_description?: string; 19 + files: string[]; 20 + } 21 + 22 + export interface PullRequest { 23 + number: number; 24 + url: string; 25 + html_url: string; 26 + title: string; 27 + } 28 + 29 + export interface PublishResponse { 30 + success: boolean; 31 + branch: string; 32 + commit_sha: string; 33 + pull_request?: PullRequest; 34 + error?: string; 35 + } 36 + 37 + export const branchApi = { 38 + async getBranchStatus(owner: string, repo: string): Promise<BranchStatus> { 39 + const response = await apiClient.get<BranchStatus>( 40 + `/api/repos/${owner}/${repo}/branch/status` 41 + ); 42 + return response.data; 43 + }, 44 + 45 + async publish(params: PublishParams): Promise<PublishResponse> { 46 + const { owner, repo, ...body } = params; 47 + const response = await apiClient.post<PublishResponse>( 48 + `/api/repos/${owner}/${repo}/publish`, 49 + body 50 + ); 51 + return response.data; 52 + }, 53 + };
+28
frontend/src/lib/hooks/useBranch.ts
··· 1 + import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 2 + import { branchApi, type PublishParams } from '../api/branch'; 3 + 4 + export function useBranchStatus(owner: string, repo: string) { 5 + return useQuery({ 6 + queryKey: ['branchStatus', owner, repo], 7 + queryFn: () => branchApi.getBranchStatus(owner, repo), 8 + enabled: !!owner && !!repo, 9 + refetchInterval: 30000, // Refetch every 30 seconds 10 + }); 11 + } 12 + 13 + export function usePublish() { 14 + const queryClient = useQueryClient(); 15 + 16 + return useMutation({ 17 + mutationFn: (params: PublishParams) => branchApi.publish(params), 18 + onSuccess: (_, variables) => { 19 + // Invalidate branch status and file content queries 20 + queryClient.invalidateQueries({ 21 + queryKey: ['branchStatus', variables.owner, variables.repo], 22 + }); 23 + queryClient.invalidateQueries({ 24 + queryKey: ['fileContent'], 25 + }); 26 + }, 27 + }); 28 + }