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:
171
backend/internal/api/handlers.go
Normal file
171
backend/internal/api/handlers.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user