feat: implement WYSIWYG markdown editor

Add full-stack markdown editor with Go backend and React frontend.

Backend:
- Cobra CLI with --data-dir, --port, --host flags
- REST API for markdown file CRUD operations
- File storage with flat directory structure
- logrus logging for all operations
- Static file serving for frontend
- Comprehensive tests for CRUD and static assets

Frontend:
- React + TypeScript + Vite + Tailwind CSS
- Live markdown preview with marked (GFM)
- File management: list, create, open, save, delete
- Theme system: Dark/Light/System with persistence
- Responsive design (320px to 1920px)
- Component tests for Editor, Preview, Sidebar

Build:
- Makefile for build, test, and run automation
- Single command testing (make test)

Closes SPEC.md requirements
This commit is contained in:
2026-02-06 10:01:09 -05:00
parent 6b66cee21d
commit 773b9db4b2
44 changed files with 7692 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/sirupsen/logrus"
"markdown-editor-backend/storage"
)
// Handler contains HTTP handlers for the API
type Handler struct {
storage *storage.Storage
log *logrus.Logger
}
// New creates a new Handler instance
func New(storage *storage.Storage, log *logrus.Logger) *Handler {
return &Handler{
storage: storage,
log: log,
}
}
// FileRequest represents a request body for file operations
type FileRequest struct {
Name string `json:"name"`
Content string `json:"content"`
}
// FileResponse represents a file response
type FileResponse struct {
Name string `json:"name"`
Content string `json:"content,omitempty"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
// FilesHandler handles list and create operations
func (h *Handler) FilesHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listFiles(w, r)
case http.MethodPost:
h.createFile(w, r)
default:
h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// FileHandler handles get, update, and delete operations for a specific file
func (h *Handler) FileHandler(w http.ResponseWriter, r *http.Request) {
// Extract filename from URL path
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
if path == "" {
h.sendError(w, http.StatusBadRequest, "filename required")
return
}
name := strings.TrimSuffix(path, ".md")
switch r.Method {
case http.MethodGet:
h.getFile(w, r, name)
case http.MethodPut:
h.updateFile(w, r, name)
case http.MethodDelete:
h.deleteFile(w, r, name)
default:
h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *Handler) listFiles(w http.ResponseWriter, r *http.Request) {
h.log.Debug("Handling list files request")
files, err := h.storage.List()
if err != nil {
h.log.WithError(err).Error("Failed to list files")
h.sendError(w, http.StatusInternalServerError, "failed to list files")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
func (h *Handler) createFile(w http.ResponseWriter, r *http.Request) {
h.log.Debug("Handling create file request")
var req FileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.log.WithError(err).Warn("Invalid request body")
h.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
h.sendError(w, http.StatusBadRequest, "name is required")
return
}
if err := h.storage.Save(req.Name, req.Content); err != nil {
h.log.WithError(err).Error("Failed to create file")
h.sendError(w, http.StatusInternalServerError, "failed to create file")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(FileResponse{Name: req.Name, Content: req.Content})
}
func (h *Handler) getFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling get file request")
content, err := h.storage.Get(name)
if err != nil {
if err.Error() == "file not found" {
h.sendError(w, http.StatusNotFound, "file not found")
return
}
h.log.WithError(err).Error("Failed to get file")
h.sendError(w, http.StatusInternalServerError, "failed to get file")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(FileResponse{Name: name, Content: content})
}
func (h *Handler) updateFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling update file request")
var req FileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.log.WithError(err).Warn("Invalid request body")
h.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.storage.Save(name, req.Content); err != nil {
h.log.WithError(err).Error("Failed to update file")
h.sendError(w, http.StatusInternalServerError, "failed to update file")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(FileResponse{Name: name, Content: req.Content})
}
func (h *Handler) deleteFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling delete file request")
if err := h.storage.Delete(name); err != nil {
if err.Error() == "file not found" {
h.sendError(w, http.StatusNotFound, "file not found")
return
}
h.log.WithError(err).Error("Failed to delete file")
h.sendError(w, http.StatusInternalServerError, "failed to delete file")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}