feat(markdown-editor): implement wysiswyg markdown editor with live preview

- Build Go backend with Cobra CLI and REST API
  - CRUD operations for markdown files (GET, POST, PUT, DELETE)
  - File storage with flat .md file structure
  - Comprehensive logrus logging with JSON format
  - Static asset serving for frontend

- Build React/TypeScript frontend with Tailwind CSS
  - Markdown editor with live GFM preview
  - File management UI (list, create, open, delete)
  - Theme system (Dark/Light/System) with persistence
  - Responsive design (320px mobile, 1920px desktop)

- Add comprehensive test coverage
  - Backend: API, storage, and logger tests (13 tests passing)
  - Frontend: Editor and App component tests

- Setup Nix development environment with Go, Node.js, and TypeScript
This commit is contained in:
2026-02-05 17:48:23 -05:00
parent 78f33053fb
commit 5b67cb61d2
31 changed files with 2010 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/markdown-editor/internal/storage"
"github.com/markdown-editor/pkg/logger"
)
type ErrorResponse struct {
Error string `json:"error"`
}
type Server struct {
storage *storage.Storage
host string
port int
}
func NewServer(dataDir, host string, port int) (*Server, error) {
return &Server{
storage: storage.NewStorage(dataDir),
host: host,
port: port,
}, nil
}
func (s *Server) Start() error {
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/files", s.handleFiles)
mux.HandleFunc("/api/files/", s.handleFiles)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// Frontend SPA fallback
mux.HandleFunc("/", s.handleFrontend)
addr := fmt.Sprintf("%s:%d", s.host, s.port)
logger.Infof("Starting server on %s", addr)
return http.ListenAndServe(addr, mux)
}
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleGetFiles(w, r)
case http.MethodPost:
s.handleCreateFile(w, r)
case http.MethodPut:
s.handleUpdateFile(w, r)
case http.MethodDelete:
s.handleDeleteFile(w, r)
default:
s.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (s *Server) handleGetFiles(w http.ResponseWriter, r *http.Request) {
// Check if URL includes a filename (e.g., /api/files/test.md)
filename := filepath.Base(r.URL.Path)
if filename != "" && r.URL.Path != "/api/files" {
// Get specific file
content, err := s.storage.GetFile(filename)
if err != nil {
s.sendError(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "text/markdown")
w.Write([]byte(content))
return
}
// List all files
files, err := s.storage.ListFiles()
if err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte{'['})
for i, file := range files {
if i > 0 {
w.Write([]byte{','})
}
w.Write([]byte{'"'})
w.Write([]byte(file))
w.Write([]byte{'"'})
}
w.Write([]byte{']'})
}
func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(req.Name, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
if err := s.storage.SaveFile(req.Name, req.Content); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Created file: %s", req.Name)
w.WriteHeader(http.StatusCreated)
}
func (s *Server) handleUpdateFile(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(r.URL.Path)
if filename == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(filename, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := s.storage.SaveFile(filename, req.Content); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Updated file: %s", filename)
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
filename := filepath.Base(r.URL.Path)
if filename == "" {
s.sendError(w, http.StatusBadRequest, "filename is required")
return
}
if !strings.HasSuffix(filename, ".md") {
s.sendError(w, http.StatusBadRequest, "filename must end with .md")
return
}
if err := s.storage.DeleteFile(filename); err != nil {
s.sendError(w, http.StatusInternalServerError, err.Error())
return
}
logger.Infof("Deleted file: %s", filename)
}
func (s *Server) handleFrontend(w http.ResponseWriter, r *http.Request) {
// Serve the index.html for SPA
http.ServeFile(w, r, "./static/index.html")
}
func (s *Server) sendError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}
func (s *Server) ServeStaticFiles(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
}