feat: implement WYSIWYG markdown editor

Add complete markdown editor with Go backend and React/TypeScript frontend.

Backend:
- Cobra CLI with configurable host, port, data-dir, static-dir flags
- REST API for CRUD operations on markdown files (GET, POST, PUT, DELETE)
- File storage with flat .md structure
- Comprehensive Logrus logging for all operations
- Static asset serving for frontend

Frontend:
- React 18 + TypeScript + Tailwind CSS
- Live markdown editor with GFM preview (react-markdown)
- File management UI (list, create, open, save, delete)
- Theme system (Light/Dark/System) with localStorage persistence
- Responsive design (320px - 1920px+)

Testing:
- 6 backend tests covering CRUD round-trip, validation, error handling
- 19 frontend tests covering API, theme system, and UI components
- All tests passing with single 'make test' command

Build:
- Frontend compiles to optimized assets in dist/
- Backend can serve frontend via --static-dir flag
This commit is contained in:
2026-02-06 08:53:52 -05:00
parent 5782d08950
commit a80de1730c
36 changed files with 9646 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/evanreichard/markdown-editor/internal/logging"
"github.com/evanreichard/markdown-editor/internal/storage"
)
// Handlers contains the HTTP handlers
type Handlers struct {
storage *storage.Storage
}
// New creates a new Handlers instance
func New(s *storage.Storage) *Handlers {
logging.Logger.Info("API handlers initialized")
return &Handlers{storage: s}
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
// sendError sends a JSON error response
func (h *Handlers) sendError(w http.ResponseWriter, r *http.Request, statusCode int, err error) {
logging.Logger.WithFields(map[string]interface{}{
"path": r.URL.Path,
"status": statusCode,
"error": err.Error(),
}).Warn("API error")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
}
// ListFiles handles GET /api/files
func (h *Handlers) ListFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.sendError(w, r, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
files, err := h.storage.List()
if err != nil {
h.sendError(w, r, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
// GetFile handles GET /api/files/:name
func (h *Handlers) GetFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.sendError(w, r, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/files/")
if name == "" {
h.sendError(w, r, http.StatusBadRequest, fmt.Errorf("file name required"))
return
}
file, err := h.storage.Get(name)
if err != nil {
if err == storage.ErrFileNotFound {
h.sendError(w, r, http.StatusNotFound, err)
return
}
h.sendError(w, r, http.StatusBadRequest, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(file)
}
// CreateFile handles POST /api/files
func (h *Handlers) CreateFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.sendError(w, r, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
var file storage.File
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
h.sendError(w, r, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
result, err := h.storage.Create(file.Name, file.Content)
if err != nil {
if err == storage.ErrInvalidName {
h.sendError(w, r, http.StatusBadRequest, err)
return
}
h.sendError(w, r, http.StatusConflict, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(result)
}
// UpdateFile handles PUT /api/files/:name
func (h *Handlers) UpdateFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
h.sendError(w, r, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/files/")
if name == "" {
h.sendError(w, r, http.StatusBadRequest, fmt.Errorf("file name required"))
return
}
var file storage.File
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
h.sendError(w, r, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
return
}
result, err := h.storage.Update(name, file.Content)
if err != nil {
if err == storage.ErrFileNotFound {
h.sendError(w, r, http.StatusNotFound, err)
return
}
h.sendError(w, r, http.StatusBadRequest, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// DeleteFile handles DELETE /api/files/:name
func (h *Handlers) DeleteFile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
h.sendError(w, r, http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/files/")
if name == "" {
h.sendError(w, r, http.StatusBadRequest, fmt.Errorf("file name required"))
return
}
err := h.storage.Delete(name)
if err != nil {
if err == storage.ErrFileNotFound {
h.sendError(w, r, http.StatusNotFound, err)
return
}
h.sendError(w, r, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}