feat: implement WYSIWYG markdown editor with Go backend and React frontend
This commit is contained in:
158
backend/internal/handlers/handlers.go
Normal file
158
backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
"markdown-editor/internal/storage"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
logger *logrus.Logger
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func NewHandlers(logger *logrus.Logger, storage storage.Storage) *Handlers {
|
||||
return &Handlers{
|
||||
logger: logger,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) ListFiles() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
files, err := h.storage.ListFiles()
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to list files: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) CreateFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Errorf("Failed to decode request body: %v", err)
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.storage.WriteFile(req.Filename, []byte(req.Content)); err != nil {
|
||||
h.logger.Errorf("Failed to create file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "file created successfully",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) GetFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
content, err := h.storage.ReadFile(filename)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Failed to read file: %v", err)
|
||||
h.writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"filename": filename,
|
||||
"content": string(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) UpdateFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Errorf("Failed to decode request body: %v", err)
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.storage.WriteFile(filename, []byte(req.Content)); err != nil {
|
||||
h.logger.Errorf("Failed to update file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "file updated successfully",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) DeleteFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.storage.DeleteFile(filename); err != nil {
|
||||
h.logger.Errorf("Failed to delete file: %v", err)
|
||||
h.writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "file deleted successfully",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
18
backend/internal/logger/logger.go
Normal file
18
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewLogger() *logrus.Logger {
|
||||
log := logrus.New()
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
ForceColors: true,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
return log
|
||||
}
|
||||
90
backend/internal/server/server.go
Normal file
90
backend/internal/server/server.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"markdown-editor/internal/handlers"
|
||||
"markdown-editor/internal/logger"
|
||||
"markdown-editor/internal/storage"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
logger *logrus.Logger
|
||||
storage storage.Storage
|
||||
handlers *handlers.Handlers
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
dataDir string
|
||||
port int
|
||||
host string
|
||||
}
|
||||
|
||||
func StartServer(dataDir string, port int, host string) error {
|
||||
log := logger.NewLogger()
|
||||
log.Info("Starting markdown editor server")
|
||||
|
||||
// Initialize storage
|
||||
storage, err := storage.NewFileStorage(dataDir)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to initialize storage: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
handlers := handlers.NewHandlers(log, storage)
|
||||
|
||||
// Create server
|
||||
s := &Server{
|
||||
logger: log,
|
||||
storage: storage,
|
||||
handlers: handlers,
|
||||
dataDir: dataDir,
|
||||
port: port,
|
||||
host: host,
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes()
|
||||
|
||||
// Start server
|
||||
s.logger.Infof("Server starting on %s:%d", host, port)
|
||||
return s.serve()
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router = mux.NewRouter()
|
||||
|
||||
// API routes
|
||||
apiRouter := s.router.PathPrefix("/api").Subrouter()
|
||||
apiRouter.Handle("/files", s.handlers.ListFiles()).Methods("GET")
|
||||
apiRouter.Handle("/files", s.handlers.CreateFile()).Methods("POST")
|
||||
apiRouter.Handle("/files/{filename}", s.handlers.GetFile()).Methods("GET")
|
||||
apiRouter.Handle("/files/{filename}", s.handlers.UpdateFile()).Methods("PUT")
|
||||
apiRouter.Handle("/files/{filename}", s.handlers.DeleteFile()).Methods("DELETE")
|
||||
|
||||
// Static file serving
|
||||
frontendDir := "frontend/build"
|
||||
if frontendDirEnv := os.Getenv("FRONTEND_BUILD_DIR"); frontendDirEnv != "" {
|
||||
frontendDir = frontendDirEnv
|
||||
}
|
||||
s.router.PathPrefix("/").Handler(http.FileServer(http.Dir(frontendDir)))
|
||||
}
|
||||
|
||||
func (s *Server) serve() error {
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.logger.Info("Shutting down server...")
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
111
backend/internal/storage/storage.go
Normal file
111
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
ListFiles() ([]string, error)
|
||||
ReadFile(filename string) ([]byte, error)
|
||||
WriteFile(filename string, content []byte) error
|
||||
DeleteFile(filename string) error
|
||||
FileExists(filename string) bool
|
||||
}
|
||||
|
||||
type FileStorage struct {
|
||||
dataDir string
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
func NewFileStorage(dataDir string) (*FileStorage, error) {
|
||||
// Create data directory if it doesn't exist
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := logrus.New()
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
ForceColors: true,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
|
||||
return &FileStorage{
|
||||
dataDir: dataDir,
|
||||
logger: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) ListFiles() ([]string, error) {
|
||||
files, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var markdownFiles []string
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && filepath.Ext(file.Name()) == ".md" {
|
||||
markdownFiles = append(markdownFiles, file.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return markdownFiles, nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) ReadFile(filename string) ([]byte, error) {
|
||||
if !s.FileExists(filename) {
|
||||
return nil, errors.New("file not found")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(s.dataDir, filename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) WriteFile(filename string, content []byte) error {
|
||||
// Ensure the file has .md extension
|
||||
if filepath.Ext(filename) != ".md" {
|
||||
filename += ".md"
|
||||
}
|
||||
|
||||
filepath := filepath.Join(s.dataDir, filename)
|
||||
err := os.WriteFile(filepath, content, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Infof("Saved file: %s", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) DeleteFile(filename string) error {
|
||||
filepath := filepath.Join(s.dataDir, filename)
|
||||
err := os.Remove(filepath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Infof("Deleted file: %s", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) FileExists(filename string) bool {
|
||||
filepath := filepath.Join(s.dataDir, filename)
|
||||
info, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
Reference in New Issue
Block a user