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
172 lines
4.4 KiB
Go
172 lines
4.4 KiB
Go
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)
|
|
}
|