Compare commits
3 Commits
c9003a208a
...
94e35f7242
| Author | SHA1 | Date | |
|---|---|---|---|
| 94e35f7242 | |||
| a074f5a854 | |||
| 2a9e793971 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
frontend/node_modules
|
||||
backend/data
|
||||
73
IMPLEMENTATION_SUMMARY.md
Normal file
73
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Implementation Summary: File Listing Feature
|
||||
|
||||
## Problem
|
||||
The frontend was unable to list existing files because the backend API did not have an endpoint to retrieve the list of files in the data directory.
|
||||
|
||||
## Root Cause
|
||||
The backend API handler only supported individual file operations (`GET /api/{filename}.md`, `POST /api/{filename}.md`, etc.) but had no endpoint to list all files in the data directory. The frontend was attempting to fetch `/api` to get the file list, but this route was not configured.
|
||||
|
||||
## Solution
|
||||
Added a new endpoint `GET /api` that returns a JSON list of all markdown files in the data directory.
|
||||
|
||||
### Changes Made
|
||||
|
||||
#### 1. Backend API (`backend/internal/api/api.go`)
|
||||
- Added `handleListFiles()` method that:
|
||||
- Reads all files from the data directory
|
||||
- Filters to only include `.md` files
|
||||
- Returns JSON response with format: `{"files": ["file1.md", "file2.md", ...]}`
|
||||
- Modified `handleGet()` to check if filename is empty and call `handleListFiles()` if so
|
||||
|
||||
#### 2. Backend Server (`backend/internal/server/server.go`)
|
||||
- Added route handler for `/api` with GET method
|
||||
- Kept existing route handler for `/api/{filename:.+.md}`
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### New Endpoint
|
||||
- **Method:** GET
|
||||
- **Path:** `/api`
|
||||
- **Response:** `{"files": ["file1.md", "file2.md", ...]}`
|
||||
- **Status Codes:**
|
||||
- 200 OK - Success
|
||||
- 500 Internal Server Error - Failed to read directory
|
||||
|
||||
#### Existing Endpoints (Unchanged)
|
||||
- **GET** `/api/{filename}.md` - Get file content
|
||||
- **POST** `/api/{filename}.md` - Create file
|
||||
- **PUT** `/api/{filename}.md` - Update file
|
||||
- **DELETE** `/api/{filename}.md` - Delete file
|
||||
|
||||
### Testing
|
||||
Added comprehensive tests in `backend/tests/file_listing_test.go`:
|
||||
- `TestFileListing` - Verifies multiple markdown files are listed
|
||||
- `TestFileListingWithNonMarkdownFiles` - Verifies only `.md` files are returned
|
||||
- `TestFileListingEmptyDirectory` - Verifies empty array for empty directory
|
||||
|
||||
All existing tests continue to pass.
|
||||
|
||||
### Frontend Compatibility
|
||||
The frontend (`frontend/src/App.tsx`) already expects the correct response format:
|
||||
```typescript
|
||||
const loadFiles = async () => {
|
||||
const response = await fetch('/api')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setFiles(data.files || []) // Expects { files: string[] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No frontend changes were required.
|
||||
|
||||
## Verification
|
||||
The implementation was verified by:
|
||||
1. Running all existing tests - ✅ PASS
|
||||
2. Running new file listing tests - ✅ PASS
|
||||
3. Manual API testing with curl - ✅ WORKING
|
||||
4. Frontend build verification - ✅ SUCCESS
|
||||
|
||||
## Files Modified
|
||||
- `backend/internal/api/api.go` - Added file listing functionality
|
||||
- `backend/internal/server/server.go` - Added `/api` route
|
||||
- `backend/tests/file_listing_test.go` - Added comprehensive tests (NEW FILE)
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
GO=go
|
||||
NPX=npx
|
||||
|
||||
.PHONY: all backend-frontend backend-test frontend-test backend-run frontend-dev clean
|
||||
|
||||
all: backend-frontend
|
||||
|
||||
backend-frontend: backend-test frontend-test
|
||||
|
||||
backend-test:
|
||||
cd backend && $(GO) test -v ./tests
|
||||
|
||||
frontend-test:
|
||||
cd frontend && $(NPX) vitest run
|
||||
|
||||
backend-run:
|
||||
cd backend && $(GO) run ./cmd/backend
|
||||
|
||||
frontend-dev:
|
||||
cd frontend && $(NPX) vite
|
||||
|
||||
clean:
|
||||
cd backend && rm -rf bin
|
||||
rm -rf frontend/dist
|
||||
126
README.md
Normal file
126
README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# WYSIWYG Markdown Editor
|
||||
|
||||
A markdown editor with live preview, file management, and theme switching.
|
||||
|
||||
## Features
|
||||
|
||||
- **Markdown Editor**: Write markdown with live GitHub Flavored Markdown preview
|
||||
- **File Management**: Create, open, save, and delete markdown files
|
||||
- **Theme System**: Dark, Light, and System themes
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18+)
|
||||
- Go (v1.21+)
|
||||
- npm or yarn
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Build the backend
|
||||
cd backend
|
||||
make build
|
||||
|
||||
# Run the backend
|
||||
./bin/markdown-editor
|
||||
|
||||
# Or with custom flags
|
||||
./bin/markdown-editor --data-dir ./my-data --port 3000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Build the frontend
|
||||
npm run build
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running Both
|
||||
|
||||
1. Build the frontend:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Run the backend (it will serve the built frontend):
|
||||
```bash
|
||||
cd backend
|
||||
./bin/markdown-editor
|
||||
```
|
||||
|
||||
3. Open your browser to `http://localhost:8080`
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
cd backend
|
||||
make test
|
||||
|
||||
# Frontend tests
|
||||
cd frontend
|
||||
npm test
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
cmd/
|
||||
backend/
|
||||
main.go
|
||||
internal/
|
||||
api/
|
||||
api.go
|
||||
logger/
|
||||
logger.go
|
||||
server/
|
||||
server.go
|
||||
tests/
|
||||
api_test.go
|
||||
go.mod
|
||||
go.sum
|
||||
Makefile
|
||||
|
||||
frontend/
|
||||
src/
|
||||
App.tsx
|
||||
main.tsx
|
||||
index.css
|
||||
setupTests.ts
|
||||
App.test.tsx
|
||||
package.json
|
||||
vite.config.ts
|
||||
tailwind.config.js
|
||||
postcss.config.js
|
||||
tsconfig.json
|
||||
index.html
|
||||
|
||||
Makefile
|
||||
README.md
|
||||
SPEC.md
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/{filename}.md` - Get markdown file content
|
||||
- `POST /api/{filename}.md` - Create a new markdown file
|
||||
- `PUT /api/{filename}.md` - Update an existing markdown file
|
||||
- `DELETE /api/{filename}.md` - Delete a markdown file
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
17
backend/Makefile
Normal file
17
backend/Makefile
Normal file
@@ -0,0 +1,17 @@
|
||||
GO=go
|
||||
|
||||
.PHONY: all test build run clean
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
$(GO) build -o bin/markdown-editor ./cmd/backend
|
||||
|
||||
run:
|
||||
$(GO) run ./cmd/backend
|
||||
|
||||
test:
|
||||
$(GO) test -v ./tests
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
BIN
backend/bin/markdown-editor
Executable file
BIN
backend/bin/markdown-editor
Executable file
Binary file not shown.
52
backend/cmd/backend/main.go
Normal file
52
backend/cmd/backend/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/evanreichard/markdown-editor/internal/api"
|
||||
"github.com/evanreichard/markdown-editor/internal/logger"
|
||||
"github.com/evanreichard/markdown-editor/internal/server"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "markdown-editor",
|
||||
Short: "A WYSIWYG Markdown Editor",
|
||||
Long: `A WYSIWYG Markdown Editor with Go backend and React frontend`,
|
||||
}
|
||||
|
||||
var dataDir string
|
||||
var port int
|
||||
var host string
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", "./data", "Storage path for markdown files")
|
||||
rootCmd.PersistentFlags().IntVar(&port, "port", 8080, "Server port")
|
||||
rootCmd.PersistentFlags().StringVar(&host, "host", "127.0.0.1", "Bind address")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := logger.NewLogger()
|
||||
log.Info("Starting markdown editor server")
|
||||
|
||||
rootCmd.Run = func(cmd *cobra.Command, args []string) {
|
||||
// Initialize API
|
||||
apiHandler := api.NewAPIHandler(dataDir, log)
|
||||
|
||||
// Create server
|
||||
srv := server.NewServer(host, port, apiHandler, log)
|
||||
|
||||
// Start server
|
||||
log.Infof("Server starting on %s:%d", host, port)
|
||||
if err := srv.Start(); err != nil {
|
||||
log.Errorf("Server failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
19
backend/go.mod
Normal file
19
backend/go.mod
Normal file
@@ -0,0 +1,19 @@
|
||||
module github.com/evanreichard/markdown-editor
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/stretchr/testify v1.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
27
backend/go.sum
Normal file
27
backend/go.sum
Normal file
@@ -0,0 +1,27 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
151
backend/internal/api/api.go
Normal file
151
backend/internal/api/api.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type APIHandler struct {
|
||||
dataDir string
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewAPIHandler(dataDir string, log *logrus.Logger) *APIHandler {
|
||||
return &APIHandler{
|
||||
dataDir: dataDir,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleGet(w, r)
|
||||
case http.MethodPost:
|
||||
h.handlePost(w, r)
|
||||
case http.MethodPut:
|
||||
h.handlePut(w, r)
|
||||
case http.MethodDelete:
|
||||
h.handleDelete(w, r)
|
||||
default:
|
||||
h.writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *APIHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Info("GET request for file listing")
|
||||
|
||||
files, err := os.ReadDir(h.dataDir)
|
||||
if err != nil {
|
||||
h.log.Errorf("Error reading data directory: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list files")
|
||||
return
|
||||
}
|
||||
|
||||
filenames := []string{}
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && filepath.Ext(file.Name()) == ".md" {
|
||||
filenames = append(filenames, file.Name())
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string][]string{"files": filenames})
|
||||
}
|
||||
|
||||
func (h *APIHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
if filename == "" {
|
||||
h.handleListFiles(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
h.log.Infof("GET request for file: %s", filename)
|
||||
|
||||
filepath := filepath.Join(h.dataDir, filename)
|
||||
content, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
h.log.Errorf("Error reading file %s: %v", filename, err)
|
||||
h.writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
func (h *APIHandler) handlePost(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
h.log.Infof("POST request for file: %s", filename)
|
||||
|
||||
filepath := filepath.Join(h.dataDir, filename)
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.log.Errorf("Error reading request body: %v", err)
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath, content, 0644); err != nil {
|
||||
h.log.Errorf("Error writing file %s: %v", filename, err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create file")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h *APIHandler) handlePut(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
h.log.Infof("PUT request for file: %s", filename)
|
||||
|
||||
filepath := filepath.Join(h.dataDir, filename)
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.log.Errorf("Error reading request body: %v", err)
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath, content, 0644); err != nil {
|
||||
h.log.Errorf("Error writing file %s: %v", filename, err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update file")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *APIHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
h.log.Infof("DELETE request for file: %s", filename)
|
||||
|
||||
filepath := filepath.Join(h.dataDir, filename)
|
||||
if err := os.Remove(filepath); err != nil {
|
||||
h.log.Errorf("Error deleting file %s: %v", filename, err)
|
||||
h.writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *APIHandler) writeError(w http.ResponseWriter, statusCode int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
17
backend/internal/logger/logger.go
Normal file
17
backend/internal/logger/logger.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewLogger() *logrus.Logger {
|
||||
log := logrus.New()
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
})
|
||||
log.SetLevel(logrus.InfoLevel)
|
||||
return log
|
||||
}
|
||||
70
backend/internal/server/server.go
Normal file
70
backend/internal/server/server.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
host string
|
||||
port int
|
||||
handler http.Handler
|
||||
log *logrus.Logger
|
||||
}
|
||||
|
||||
func NewServer(host string, port int, handler http.Handler, log *logrus.Logger) *Server {
|
||||
return &Server{
|
||||
host: host,
|
||||
port: port,
|
||||
handler: handler,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/api", s.handler).Methods("GET")
|
||||
router.Handle("/api/{filename:.+.md}", s.handler)
|
||||
router.PathPrefix("/").Handler(http.FileServer(http.Dir("frontend/dist")))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// Start server
|
||||
go func() {
|
||||
s.log.Infof("Server listening on %s:%d", s.host, s.port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.log.Errorf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
s.log.Info("Shutting down server...")
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Shutdown server
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
s.log.Errorf("Server shutdown error: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.log.Info("Server stopped")
|
||||
return nil
|
||||
}
|
||||
125
backend/tests/api_test.go
Normal file
125
backend/tests/api_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/evanreichard/markdown-editor/internal/api"
|
||||
"github.com/evanreichard/markdown-editor/internal/logger"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func setupTestDir() (string, error) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "markdown-editor-test-"+randomString(10))
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tmpDir, nil
|
||||
}
|
||||
|
||||
func cleanupTestDir(dir string) {
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[i%len(letters)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestCRUDOperations(t *testing.T) {
|
||||
dataDir, err := setupTestDir()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test dir: %v", err)
|
||||
}
|
||||
defer cleanupTestDir(dataDir)
|
||||
|
||||
log := logger.NewLogger()
|
||||
handler := api.NewAPIHandler(dataDir, log)
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/api/{filename:.+\\.md}", handler)
|
||||
|
||||
testContent := "# Test Content\n\nThis is a test."
|
||||
|
||||
// Test POST (Create)
|
||||
req := httptest.NewRequest("POST", "/api/test.md", bytes.NewBufferString(testContent))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// Test GET (Read)
|
||||
req = httptest.NewRequest("GET", "/api/test.md", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
assert.Equal(t, testContent, string(body))
|
||||
|
||||
// Test PUT (Update)
|
||||
updatedContent := "# Updated Content\n\nThis has been updated."
|
||||
req = httptest.NewRequest("PUT", "/api/test.md", bytes.NewBufferString(updatedContent))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify update
|
||||
req = httptest.NewRequest("GET", "/api/test.md", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
body, _ = io.ReadAll(w.Body)
|
||||
assert.Equal(t, updatedContent, string(body))
|
||||
|
||||
// Test DELETE
|
||||
req = httptest.NewRequest("DELETE", "/api/test.md", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
|
||||
// Verify deletion
|
||||
req = httptest.NewRequest("GET", "/api/test.md", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestStaticAssetServing(t *testing.T) {
|
||||
dataDir, err := setupTestDir()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test dir: %v", err)
|
||||
}
|
||||
defer cleanupTestDir(dataDir)
|
||||
|
||||
// Test that FileServer can serve files
|
||||
fs := http.FileServer(http.Dir(dataDir))
|
||||
|
||||
// Create a test HTML file
|
||||
testHTML := `<!DOCTYPE html><html><head><title>Test</title></head><body><h1>Hello</h1></body></html>`
|
||||
testPath := filepath.Join(dataDir, "index.html")
|
||||
if err := os.WriteFile(testPath, []byte(testHTML), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test HTML: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
content, err := os.ReadFile(testPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test HTML: %v", err)
|
||||
}
|
||||
assert.Equal(t, testHTML, string(content))
|
||||
|
||||
// Test serving static file - just verify it doesn't error
|
||||
req := httptest.NewRequest("GET", "/index.html/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
fs.ServeHTTP(w, req)
|
||||
// FileServer may redirect, but we just verify it doesn't panic
|
||||
assert.NotEqual(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
141
backend/tests/file_listing_test.go
Normal file
141
backend/tests/file_listing_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/evanreichard/markdown-editor/internal/api"
|
||||
"github.com/evanreichard/markdown-editor/internal/logger"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileListing(t *testing.T) {
|
||||
dataDir, err := setupTestDir()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test dir: %v", err)
|
||||
}
|
||||
defer cleanupTestDir(dataDir)
|
||||
|
||||
// Create some test files
|
||||
testFiles := []string{
|
||||
"file1.md",
|
||||
"file2.md",
|
||||
"file3.md",
|
||||
}
|
||||
|
||||
for _, filename := range testFiles {
|
||||
filepath := filepath.Join(dataDir, filename)
|
||||
content := "# " + filename + "\n\nContent of " + filename
|
||||
if err := os.WriteFile(filepath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
log := logger.NewLogger()
|
||||
handler := api.NewAPIHandler(dataDir, log)
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/api", handler).Methods("GET")
|
||||
router.Handle("/api/{filename:.+\\.md}", handler)
|
||||
|
||||
// Test GET /api (list files)
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
assert.NoError(t, json.Unmarshal(body, &response))
|
||||
|
||||
// Verify all test files are returned
|
||||
assert.Len(t, response.Files, 3)
|
||||
assert.Contains(t, response.Files, "file1.md")
|
||||
assert.Contains(t, response.Files, "file2.md")
|
||||
assert.Contains(t, response.Files, "file3.md")
|
||||
}
|
||||
|
||||
func TestFileListingWithNonMarkdownFiles(t *testing.T) {
|
||||
dataDir, err := setupTestDir()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test dir: %v", err)
|
||||
}
|
||||
defer cleanupTestDir(dataDir)
|
||||
|
||||
// Create test files including non-markdown files
|
||||
testFiles := []string{
|
||||
"file1.md",
|
||||
"file2.txt",
|
||||
"file3.md",
|
||||
"file4.log",
|
||||
}
|
||||
|
||||
for _, filename := range testFiles {
|
||||
filepath := filepath.Join(dataDir, filename)
|
||||
content := "Content of " + filename
|
||||
if err := os.WriteFile(filepath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
log := logger.NewLogger()
|
||||
handler := api.NewAPIHandler(dataDir, log)
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/api", handler).Methods("GET")
|
||||
router.Handle("/api/{filename:.+\\.md}", handler)
|
||||
|
||||
// Test GET /api (list files)
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
assert.NoError(t, json.Unmarshal(body, &response))
|
||||
|
||||
// Verify only markdown files are returned
|
||||
assert.Len(t, response.Files, 2)
|
||||
assert.Contains(t, response.Files, "file1.md")
|
||||
assert.Contains(t, response.Files, "file3.md")
|
||||
assert.NotContains(t, response.Files, "file2.txt")
|
||||
assert.NotContains(t, response.Files, "file4.log")
|
||||
}
|
||||
|
||||
func TestFileListingEmptyDirectory(t *testing.T) {
|
||||
dataDir, err := setupTestDir()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test dir: %v", err)
|
||||
}
|
||||
defer cleanupTestDir(dataDir)
|
||||
|
||||
log := logger.NewLogger()
|
||||
handler := api.NewAPIHandler(dataDir, log)
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/api", handler).Methods("GET")
|
||||
router.Handle("/api/{filename:.+\\.md}", handler)
|
||||
|
||||
// Test GET /api (list files in empty directory)
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
assert.NoError(t, json.Unmarshal(body, &response))
|
||||
|
||||
// Verify empty array is returned
|
||||
assert.Len(t, response.Files, 0)
|
||||
}
|
||||
70
frontend/dist/assets/index-D8_kwvOB.js
vendored
Normal file
70
frontend/dist/assets/index-D8_kwvOB.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-DyDMOPN8.css
vendored
Normal file
1
frontend/dist/assets/index-DyDMOPN8.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markdown Editor</title>
|
||||
<script type="module" crossorigin src="/assets/index-D8_kwvOB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DyDMOPN8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markdown Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6401
frontend/package-lock.json
generated
Normal file
6401
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "markdown-editor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
53
frontend/src/App.test.tsx
Normal file
53
frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ files: [] }),
|
||||
text: () => Promise.resolve(''),
|
||||
})) as any
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the markdown editor', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Markdown Editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays theme selector', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('System')).toBeInTheDocument()
|
||||
expect(screen.getByText('Light')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dark')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles theme', () => {
|
||||
render(<App />)
|
||||
const select = screen.getByRole('combobox')
|
||||
fireEvent.change(select, { target: { value: 'dark' } })
|
||||
expect(select).toHaveValue('dark')
|
||||
})
|
||||
|
||||
it('displays files list', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Files')).toBeInTheDocument()
|
||||
expect(screen.getByText('New Document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays editor and preview', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays save button when file is selected', () => {
|
||||
render(<App />)
|
||||
expect(screen.queryByText('Save')).not.toBeInTheDocument()
|
||||
// After selecting a file, save button should appear
|
||||
})
|
||||
})
|
||||
184
frontend/src/App.tsx
Normal file
184
frontend/src/App.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface FileInfo {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const [files, setFiles] = useState<string[]>([])
|
||||
const [currentFile, setCurrentFile] = useState<string>('')
|
||||
const [markdownContent, setMarkdownContent] = useState<string>('')
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>('system')
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
applyTheme(theme)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFile) {
|
||||
loadFile(currentFile)
|
||||
}
|
||||
}, [currentFile])
|
||||
|
||||
const applyTheme = (theme: 'dark' | 'light' | 'system') => {
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
document.documentElement.classList.toggle('dark', prefersDark)
|
||||
} else {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setFiles(data.files || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading files:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFile = async (filename: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/${filename}`)
|
||||
if (response.ok) {
|
||||
const content = await response.text()
|
||||
setMarkdownContent(content)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const createFile = async (filename: string) => {
|
||||
try {
|
||||
await fetch(`/api/${filename}`, {
|
||||
method: 'POST',
|
||||
body: '# New Document\n\nWrite your markdown here...',
|
||||
})
|
||||
setCurrentFile(filename)
|
||||
await loadFiles()
|
||||
} catch (error) {
|
||||
console.error('Error creating file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFile = async (filename: string) => {
|
||||
try {
|
||||
await fetch(`/api/${filename}`, {
|
||||
method: 'PUT',
|
||||
body: markdownContent,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = async (filename: string) => {
|
||||
try {
|
||||
await fetch(`/api/${filename}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
setCurrentFile('')
|
||||
setMarkdownContent('')
|
||||
await loadFiles()
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-blue-600 text-white p-4">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">Markdown Editor</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
const newTheme = e.target.value as 'dark' | 'light' | 'system'
|
||||
setTheme(newTheme)
|
||||
applyTheme(newTheme)
|
||||
}}
|
||||
className="bg-blue-700 text-white p-2 rounded"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="container mx-auto p-4 flex flex-col lg:flex-row gap-4">
|
||||
<div className="w-full lg:w-1/4 bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Files</h2>
|
||||
<div className="space-y-2 mb-4">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className={`p-2 rounded cursor-pointer ${currentFile === file ? 'bg-blue-100 dark:bg-blue-900' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}`}
|
||||
onClick={() => setCurrentFile(file)}
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createFile(`document-${Date.now()}.md`)}
|
||||
className="w-full bg-green-500 text-white p-2 rounded hover:bg-green-600"
|
||||
>
|
||||
New Document
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center">
|
||||
{currentFile && (
|
||||
<h2 className="text-lg font-semibold">{currentFile}</h2>
|
||||
)}
|
||||
{currentFile && (
|
||||
<button
|
||||
onClick={() => saveFile(currentFile)}
|
||||
className="bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-medium mb-2">Editor</h3>
|
||||
<textarea
|
||||
value={markdownContent}
|
||||
onChange={(e) => setMarkdownContent(e.target.value)}
|
||||
className="w-full h-96 p-2 border rounded dark:bg-gray-900 dark:text-white dark:border-gray-700"
|
||||
placeholder="Write markdown here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 className="text-md font-medium mb-2">Preview</h3>
|
||||
<div className="w-full h-96 p-2 border rounded overflow-auto dark:bg-gray-900 dark:text-white dark:border-gray-700 prose dark:prose-invert">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdownContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html.dark {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
html.light {
|
||||
@apply bg-white text-gray-900;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
23
frontend/src/setupTests.ts
Normal file
23
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
||||
|
||||
// Extend expect with jest-dom matchers
|
||||
expect.extend(matchers)
|
||||
|
||||
// Mock window.matchMedia
|
||||
global.matchMedia = global.matchMedia || function() {
|
||||
return {
|
||||
matches: false,
|
||||
addListener: function() {},
|
||||
removeListener: function() {},
|
||||
addEventListener: function() {},
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function() {},
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
35
frontend/tailwind.config.js
Normal file
35
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
typography: (theme) => ({
|
||||
dark: {
|
||||
css: {
|
||||
'--tw-prose-body': theme('colors.gray.300'),
|
||||
'--tw-prose-headings': theme('colors.white'),
|
||||
'--tw-prose-links': theme('colors.blue.400'),
|
||||
'--tw-prose-links-hover': theme('colors.blue.300'),
|
||||
'--tw-prose-bold': theme('colors.white'),
|
||||
'--tw-prose-counters': theme('colors.gray.400'),
|
||||
'--tw-prose-bullets': theme('colors.gray.400'),
|
||||
'--tw-prose-hr': theme('colors.gray.700'),
|
||||
'--tw-prose-quotes': theme('colors.gray.200'),
|
||||
'--tw-prose-quote-borders': theme('colors.gray.700'),
|
||||
'--tw-prose-captions': theme('colors.gray.400'),
|
||||
'--tw-prose-code': theme('colors.gray.200'),
|
||||
'--tw-prose-pre-code': theme('colors.gray.200'),
|
||||
'--tw-prose-pre-bg': theme('colors.gray.800'),
|
||||
'--tw-prose-th-borders': theme('colors.gray.700'),
|
||||
'--tw-prose-td-borders': theme('colors.gray.700'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
}
|
||||
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
11
frontend/vitest.config.ts
Normal file
11
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/setupTests.ts',
|
||||
},
|
||||
})
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
3803
transcripts/1-initial-commit.html
Normal file
3803
transcripts/1-initial-commit.html
Normal file
File diff suppressed because one or more lines are too long
3803
transcripts/2-file-list-fix.html
Normal file
3803
transcripts/2-file-list-fix.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user