Initial commit: WYSIWYG Markdown Editor - Go backend + React/TypeScript frontend with Tailwind CSS
Backend: - Cobra CLI with --data-dir, --port, --host flags - Gin HTTP server with REST API for markdown CRUD operations - File storage on disk (.md files only) - Comprehensive logrus logging - Backend tests with CRUD round-trip verification Frontend: - React 18 + TypeScript + Tailwind CSS - Markdown editor with live GFM preview (react-markdown + remark-gfm) - File management UI (list, create, open, save, delete) - Theme switcher with Dark/Light/System modes - Responsive design - Frontend tests with vitest Testing: - All backend tests pass (go test ./...) - All frontend tests pass (npm test)
This commit is contained in:
149
internal/api/api.go
Normal file
149
internal/api/api.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/storage"
|
||||
"markdown-editor/internal/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
storage *storage.Storage
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewAPI(storage *storage.Storage) *API {
|
||||
log := logger.GetLogger()
|
||||
return &API{
|
||||
storage: storage,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// FileInfo represents a file for API responses
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// File represents a file for API requests
|
||||
type File struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (a *API) RegisterRoutes(router *gin.Engine) {
|
||||
// File endpoints
|
||||
router.GET("/api/files", a.listFiles)
|
||||
router.GET("/api/files/:name", a.getFile)
|
||||
router.POST("/api/files", a.createFile)
|
||||
router.PUT("/api/files/:name", a.updateFile)
|
||||
router.DELETE("/api/files/:name", a.deleteFile)
|
||||
}
|
||||
|
||||
func (a *API) listFiles(c *gin.Context) {
|
||||
a.log.Info("Handling GET /api/files")
|
||||
|
||||
files, err := a.storage.ListFiles()
|
||||
if err != nil {
|
||||
a.log.Errorf("Failed to list files: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to list files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) getFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
a.log.WithField("filename", name).Info("Handling GET /api/files/:name")
|
||||
|
||||
file, err := a.storage.GetFile(name)
|
||||
if err != nil {
|
||||
a.log.Errorf("Failed to get file %s: %v", name, err)
|
||||
if err.Error() == "file not found: "+name {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, file)
|
||||
}
|
||||
|
||||
func (a *API) createFile(c *gin.Context) {
|
||||
var file File
|
||||
if err := c.ShouldBindJSON(&file); err != nil {
|
||||
a.log.Errorf("Invalid request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
a.log.WithField("filename", file.Name).Info("Handling POST /api/files")
|
||||
|
||||
if file.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.storage.CreateFile(file.Name, file.Content); err != nil {
|
||||
a.log.Errorf("Failed to create file %s: %v", file.Name, err)
|
||||
if err.Error() == "file already exists: "+file.Name {
|
||||
c.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, file)
|
||||
}
|
||||
|
||||
func (a *API) updateFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
var file File
|
||||
if err := c.ShouldBindJSON(&file); err != nil {
|
||||
a.log.Errorf("Invalid request body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
a.log.WithField("filename", name).Info("Handling PUT /api/files/:name")
|
||||
|
||||
if file.Name != name {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "File name in path and body must match"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.storage.UpdateFile(name, file.Content); err != nil {
|
||||
a.log.Errorf("Failed to update file %s: %v", name, err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, file)
|
||||
}
|
||||
|
||||
func (a *API) deleteFile(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
a.log.WithField("filename", name).Info("Handling DELETE /api/files/:name")
|
||||
|
||||
if err := a.storage.DeleteFile(name); err != nil {
|
||||
a.log.Errorf("Failed to delete file %s: %v", name, err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user