mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5aa2dd45b | |||
| 43f35b7943 | |||
| f8728923a5 | |||
| 72817aa06a | |||
| a11a6b18bf | |||
| 0fe1186ea6 | |||
| 477ada70e8 | |||
| a73296451e | |||
| bef682e5cb | |||
| 1086c402ec | |||
| a62a6f8966 | |||
| 3ba9d57b11 | |||
| 91f0ebe99c | |||
| ad3fa28bc7 | |||
| 1695b411e1 | |||
| 87a2f12766 | |||
| d82f7e2b1e | |||
| d417b9f0c7 | |||
| 64a86152d6 | |||
| 69506f739c | |||
| 012465fb02 | |||
| 552016f61d | |||
| f1b5027e7f | |||
| 239b441aa6 | |||
| ba4a0dadca | |||
| b679af08e7 | |||
| c5e0937070 | |||
| 17c03c2d14 | |||
| 4544af8f0f | |||
| ab7b018f88 | |||
| eaad78730e | |||
| bbd7358d15 | |||
| 12312137b7 | |||
| fd313c1d7f | |||
| 05dd3a83b0 | |||
| a231fc48c2 | |||
| 4ade504b5b | |||
| 1c59f8da4f | |||
| ffe82b335a | |||
| 749461f11b | |||
| 2f87d87833 | |||
| 6eb3eecb24 | |||
| 3b7bf83073 | |||
| 1df4952300 | |||
| a24f0d637c | |||
| 68ec134bf5 | |||
| 403ded509a | |||
| 6cf141bfd9 | |||
| 071619cfb3 | |||
| d440ac0fd7 | |||
| 4953138154 | |||
| cbdf51db05 | |||
| 18bc50f5b4 | |||
| 97ebf1c08e | |||
| 2d2b596f2c | |||
| b36c5b30c6 | |||
| 903393953a | |||
| b6e8a93d86 | |||
| da5a3ebeee | |||
| 1341881968 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -129,6 +129,9 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
|
||||
|
||||
##### Go #####
|
||||
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -18,7 +18,8 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"gopls": {
|
||||
"usePlaceholders": true,
|
||||
|
||||
21
README.md
21
README.md
@@ -8,6 +8,7 @@ Yet another markdown editor. Work in progress
|
||||
- File tree navigation
|
||||
- Git integration for version control
|
||||
- Dark and light theme support
|
||||
- Multiple workspaces
|
||||
- Math equation support (KaTeX)
|
||||
- Code syntax highlighting
|
||||
|
||||
@@ -17,15 +18,29 @@ Yet another markdown editor. Work in progress
|
||||
- Node.js 20 or later
|
||||
- gcc (for go-sqlite3 package)
|
||||
|
||||
## Running the Backend
|
||||
## Setup
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
1. Navigate to the `backend` directory
|
||||
2. Set the following environment variables:
|
||||
- `CGO_ENABLED=1`: This is necessary for the go-sqlite3 package
|
||||
- `NOVAMD_DB_PATH`: Path to the SQLite database file (default: "./sqlite.db")
|
||||
- `NOVAMD_WORKDIR`: Directory for storing Markdown files (default: "./data")
|
||||
- `NOVAMD_STATIC_PATH`: Path to the frontend build files (default: "../frontend/dist")
|
||||
- `NOVAMD_PORT`: Port to run the server on (default: "8080")
|
||||
- `NOVAMD_ADMIN_EMAIL`: Admin user email
|
||||
- `NOVAMD_ADMIN_PASSWORD`: Admin user password
|
||||
- `NOVAMD_ENCRYPTION_KEY`: 32-byte key for encrypting sensitive data
|
||||
|
||||
To generate a secure encryption key you can use openssl:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Running the Backend
|
||||
|
||||
1. Navigate to the `backend` directory
|
||||
2. Ensure all environment variables are set
|
||||
3. Run the server:
|
||||
```
|
||||
go run cmd/server/main.go
|
||||
|
||||
@@ -4,23 +4,27 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"novamd/internal/api"
|
||||
"novamd/internal/config"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/user"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize database
|
||||
dbPath := os.Getenv("NOVAMD_DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./sqlite.db"
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load configuration:", err)
|
||||
}
|
||||
database, err := db.Init(dbPath)
|
||||
|
||||
// Initialize database
|
||||
database, err := db.Init(cfg.DBPath, cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -30,61 +34,36 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Workdir
|
||||
workdir := os.Getenv("NOVAMD_WORKDIR")
|
||||
if workdir == "" {
|
||||
workdir = "./data"
|
||||
}
|
||||
// Initialize filesystem
|
||||
fs := filesystem.New(cfg.WorkDir)
|
||||
|
||||
settings, err := database.GetSettings(1) // Assuming user ID 1 for now
|
||||
if err != nil {
|
||||
log.Print("Settings not found, using default settings")
|
||||
}
|
||||
fs := filesystem.New(workdir, &settings)
|
||||
// Initialize user service
|
||||
userService := user.NewUserService(database, fs)
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
if err := fs.InitializeGitRepo(); err != nil {
|
||||
// Create admin user
|
||||
if _, err := userService.SetupAdminUser(cfg.AdminEmail, cfg.AdminPassword); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up router
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Set up API routes
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
api.SetupRoutes(r, database, fs)
|
||||
})
|
||||
|
||||
// Set up static file server with path validation
|
||||
staticPath := os.Getenv("NOVAMD_STATIC_PATH")
|
||||
if staticPath == "" {
|
||||
staticPath = "../frontend/dist"
|
||||
}
|
||||
fileServer := http.FileServer(http.Dir(staticPath))
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath := r.URL.Path
|
||||
validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = os.Stat(validatedPath)
|
||||
if os.IsNotExist(err) {
|
||||
http.ServeFile(w, r, filepath.Join(staticPath, "index.html"))
|
||||
return
|
||||
}
|
||||
http.StripPrefix("/", fileServer).ServeHTTP(w, r)
|
||||
})
|
||||
// Handle all other routes with static file server
|
||||
r.Get("/*", api.NewStaticHandler(cfg.StaticPath).ServeHTTP)
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("NOVAMD_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, r))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.12.0
|
||||
github.com/go-playground/validator/v10 v10.22.1
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
golang.org/x/crypto v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -29,7 +30,6 @@ require (
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/mod v0.12.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
|
||||
171
backend/internal/api/file_handlers.go
Normal file
171
backend/internal/api/file_handlers.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := fs.ListFilesRecursively(userID, workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, files)
|
||||
}
|
||||
}
|
||||
|
||||
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
if filename == "" {
|
||||
http.Error(w, "Filename is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePaths, err := fs.FindFileByName(userID, workspaceID, filename)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string][]string{"paths": filePaths})
|
||||
}
|
||||
}
|
||||
|
||||
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := fs.GetFileContent(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.SaveFile(userID, workspaceID, filePath, content)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "File saved successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
err = fs.DeleteFile(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("File deleted successfully"))
|
||||
}
|
||||
}
|
||||
|
||||
func GetLastOpenedFile(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
_, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath, err := db.GetLastOpenedFile(workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the file path exists in the workspace
|
||||
if requestBody.FilePath != "" {
|
||||
if _, err := fs.ValidatePath(userID, workspaceID, requestBody.FilePath); err != nil {
|
||||
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.UpdateLastOpenedFile(workspaceID, requestBody.FilePath); err != nil {
|
||||
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"})
|
||||
}
|
||||
}
|
||||
58
backend/internal/api/git_handlers.go
Normal file
58
backend/internal/api/git_handlers.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/filesystem"
|
||||
)
|
||||
|
||||
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.Message == "" {
|
||||
http.Error(w, "Commit message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.Pull(userID, workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Pulled changes from remote"})
|
||||
}
|
||||
}
|
||||
37
backend/internal/api/handler_utils.go
Normal file
37
backend/internal/api/handler_utils.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func getUserID(r *http.Request) (int, error) {
|
||||
userIDStr := chi.URLParam(r, "userId")
|
||||
return strconv.Atoi(userIDStr)
|
||||
}
|
||||
|
||||
func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
return 0, 0, errors.New("invalid userId")
|
||||
}
|
||||
|
||||
workspaceIDStr := chi.URLParam(r, "workspaceId")
|
||||
workspaceID, err := strconv.Atoi(workspaceIDStr)
|
||||
if err != nil {
|
||||
return userID, 0, errors.New("invalid workspaceId")
|
||||
}
|
||||
|
||||
return userID, workspaceID, nil
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
files, err := fs.ListFilesRecursively()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(files); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filenameOrPath := r.URL.Query().Get("filename")
|
||||
if filenameOrPath == "" {
|
||||
http.Error(w, "Filename or path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePaths, err := fs.FindFileByName(filenameOrPath)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
content, err := fs.GetFileContent(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content type based on file extension
|
||||
contentType := "text/plain"
|
||||
switch filepath.Ext(filePath) {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
case ".gif":
|
||||
contentType = "image/gif"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".md":
|
||||
contentType = "text/markdown"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
if _, err := w.Write(content); err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.SaveFile(filePath, content)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "File saved successfully"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/")
|
||||
err := fs.DeleteFile(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("File deleted successfully")); err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetSettings(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.URL.Query().Get("userId")
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid userId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := db.GetSettings(userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
settings.SetDefaults()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(settings); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var settings models.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
settings.SetDefaults()
|
||||
|
||||
if err := settings.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := db.SaveSettings(settings)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
err := fs.SetupGitRepo(settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to setup git repo", http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
fs.DisableGitRepo()
|
||||
}
|
||||
|
||||
// Fetch the saved settings to return
|
||||
savedSettings, err := db.GetSettings(settings.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Settings saved but could not be retrieved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(savedSettings); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var requestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&requestBody)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.Message == "" {
|
||||
http.Error(w, "Commit message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = fs.StageCommitAndPush(requestBody.Message)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Changes staged, committed, and pushed successfully"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
err := fs.Pull()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"message": "Pulled changes from remote"}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,42 @@ import (
|
||||
|
||||
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
|
||||
r.Route("/", func(r chi.Router) {
|
||||
r.Route("/settings", func(r chi.Router) {
|
||||
r.Get("/", GetSettings(db))
|
||||
r.Post("/", UpdateSettings(db, fs))
|
||||
})
|
||||
// User routes
|
||||
r.Route("/users/{userId}", func(r chi.Router) {
|
||||
r.Get("/", GetUser(db))
|
||||
|
||||
// Workspace routes
|
||||
r.Route("/workspaces", func(r chi.Router) {
|
||||
r.Get("/", ListWorkspaces(db))
|
||||
r.Post("/", CreateWorkspace(db, fs))
|
||||
r.Get("/last", GetLastWorkspace(db))
|
||||
r.Put("/last", UpdateLastWorkspace(db))
|
||||
|
||||
r.Route("/{workspaceId}", func(r chi.Router) {
|
||||
r.Get("/", GetWorkspace(db))
|
||||
r.Put("/", UpdateWorkspace(db, fs))
|
||||
r.Delete("/", DeleteWorkspace(db))
|
||||
|
||||
// File routes
|
||||
r.Route("/files", func(r chi.Router) {
|
||||
r.Get("/", ListFiles(fs))
|
||||
r.Get("/*", GetFileContent(fs))
|
||||
r.Get("/last", GetLastOpenedFile(db))
|
||||
r.Put("/last", UpdateLastOpenedFile(db, fs))
|
||||
r.Get("/lookup", LookupFileByName(fs)) // Moved here
|
||||
|
||||
r.Post("/*", SaveFile(fs))
|
||||
r.Get("/*", GetFileContent(fs))
|
||||
r.Delete("/*", DeleteFile(fs))
|
||||
r.Get("/lookup", LookupFileByName(fs))
|
||||
|
||||
})
|
||||
|
||||
// Git routes
|
||||
r.Route("/git", func(r chi.Router) {
|
||||
r.Post("/commit", StageCommitAndPush(fs))
|
||||
r.Post("/pull", PullChanges(fs))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
70
backend/internal/api/static_handler.go
Normal file
70
backend/internal/api/static_handler.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaticHandler serves static files with support for SPA routing and pre-compressed files
|
||||
type StaticHandler struct {
|
||||
staticPath string
|
||||
}
|
||||
|
||||
func NewStaticHandler(staticPath string) *StaticHandler {
|
||||
return &StaticHandler{
|
||||
staticPath: staticPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the requested path
|
||||
requestedPath := r.URL.Path
|
||||
fullPath := filepath.Join(h.staticPath, requestedPath)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
// Security check to prevent directory traversal
|
||||
if !strings.HasPrefix(cleanPath, h.staticPath) {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set cache headers for assets
|
||||
if strings.HasPrefix(requestedPath, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year
|
||||
}
|
||||
|
||||
// Check if file exists (not counting .gz files)
|
||||
stat, err := os.Stat(cleanPath)
|
||||
if err != nil || stat.IsDir() {
|
||||
// Serve index.html for SPA routing
|
||||
indexPath := filepath.Join(h.staticPath, "index.html")
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for pre-compressed version
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gzPath := cleanPath + ".gz"
|
||||
if _, err := os.Stat(gzPath); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
|
||||
// Set proper content type based on original file
|
||||
switch filepath.Ext(cleanPath) {
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case ".html":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, gzPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Serve original file
|
||||
http.ServeFile(w, r, cleanPath)
|
||||
}
|
||||
25
backend/internal/api/user_handlers.go
Normal file
25
backend/internal/api/user_handlers.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/db"
|
||||
)
|
||||
|
||||
func GetUser(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := db.GetUserByID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
247
backend/internal/api/workspace_handlers.go
Normal file
247
backend/internal/api/workspace_handlers.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func ListWorkspaces(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := db.GetWorkspacesByUserID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspaces)
|
||||
}
|
||||
}
|
||||
|
||||
func CreateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspace.UserID = userID
|
||||
if err := db.CreateWorkspace(&workspace); err != nil {
|
||||
http.Error(w, "Failed to create workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := fs.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
|
||||
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func GetWorkspace(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspace, err := db.GetWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Workspace not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if workspace.UserID != userID {
|
||||
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set IDs from the request
|
||||
workspace.ID = workspaceID
|
||||
workspace.UserID = userID
|
||||
|
||||
// Validate the workspace
|
||||
if err := workspace.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current workspace for comparison
|
||||
currentWorkspace, err := db.GetWorkspaceByID(workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Workspace not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if currentWorkspace.UserID != userID {
|
||||
http.Error(w, "Unauthorized access to workspace", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Git repository setup/teardown if Git settings changed
|
||||
if workspace.GitEnabled != currentWorkspace.GitEnabled ||
|
||||
(workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL ||
|
||||
workspace.GitUser != currentWorkspace.GitUser ||
|
||||
workspace.GitToken != currentWorkspace.GitToken)) {
|
||||
if workspace.GitEnabled {
|
||||
err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fs.DisableGitRepo(userID, workspaceID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.UpdateWorkspace(&workspace); err != nil {
|
||||
http.Error(w, "Failed to update workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteWorkspace(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, workspaceID, err := getUserAndWorkspaceIDs(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is the user's last workspace
|
||||
workspaces, err := db.GetWorkspacesByUserID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) <= 1 {
|
||||
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Find another workspace to set as last
|
||||
var nextWorkspaceID int
|
||||
for _, ws := range workspaces {
|
||||
if ws.ID != workspaceID {
|
||||
nextWorkspaceID = ws.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update last workspace ID first
|
||||
err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the workspace
|
||||
err = db.DeleteWorkspaceTx(tx, workspaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the next workspace ID in the response so frontend knows where to redirect
|
||||
respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID})
|
||||
}
|
||||
}
|
||||
|
||||
func GetLastWorkspace(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID, err := db.GetLastWorkspaceID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID})
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLastWorkspace(db *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
WorkspaceID int `json:"workspaceId"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil {
|
||||
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"})
|
||||
}
|
||||
}
|
||||
86
backend/internal/config/config.go
Normal file
86
backend/internal/config/config.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"novamd/internal/crypto"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBPath string
|
||||
WorkDir string
|
||||
StaticPath string
|
||||
Port string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
EncryptionKey string
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
DBPath: "./novamd.db",
|
||||
WorkDir: "./data",
|
||||
StaticPath: "../frontend/dist",
|
||||
Port: "8080",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.AdminEmail == "" || c.AdminPassword == "" {
|
||||
return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set")
|
||||
}
|
||||
|
||||
// Validate encryption key
|
||||
if err := crypto.ValidateKey(c.EncryptionKey); err != nil {
|
||||
return fmt.Errorf("invalid NOVAMD_ENCRYPTION_KEY: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load creates a new Config instance with values from environment variables
|
||||
func Load() (*Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" {
|
||||
config.DBPath = dbPath
|
||||
}
|
||||
if err := ensureDir(filepath.Dir(config.DBPath)); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
if workDir := os.Getenv("NOVAMD_WORKDIR"); workDir != "" {
|
||||
config.WorkDir = workDir
|
||||
}
|
||||
if err := ensureDir(config.WorkDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create work directory: %w", err)
|
||||
}
|
||||
|
||||
if staticPath := os.Getenv("NOVAMD_STATIC_PATH"); staticPath != "" {
|
||||
config.StaticPath = staticPath
|
||||
}
|
||||
|
||||
if port := os.Getenv("NOVAMD_PORT"); port != "" {
|
||||
config.Port = port
|
||||
}
|
||||
|
||||
config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL")
|
||||
config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD")
|
||||
config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY")
|
||||
|
||||
// Validate all settings
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func ensureDir(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
115
backend/internal/crypto/crypto.go
Normal file
115
backend/internal/crypto/crypto.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrKeyRequired = errors.New("encryption key is required")
|
||||
ErrInvalidKeySize = errors.New("encryption key must be 32 bytes (256 bits) when decoded")
|
||||
)
|
||||
|
||||
type Crypto struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// ValidateKey checks if the provided key is suitable for AES-256
|
||||
func ValidateKey(key string) error {
|
||||
if key == "" {
|
||||
return ErrKeyRequired
|
||||
}
|
||||
|
||||
// Attempt to decode base64
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base64 encoding: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) != 32 {
|
||||
return fmt.Errorf("%w: got %d bytes", ErrInvalidKeySize, len(keyBytes))
|
||||
}
|
||||
|
||||
// Verify the key can be used for AES
|
||||
_, err = aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid encryption key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new Crypto instance with the provided base64-encoded key
|
||||
func New(key string) (*Crypto, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, _ := base64.StdEncoding.DecodeString(key)
|
||||
return &Crypto{key: keyBytes}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts the plaintext using AES-256-GCM
|
||||
func (c *Crypto) Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the ciphertext using AES-256-GCM
|
||||
func (c *Crypto) Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -2,15 +2,19 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"novamd/internal/crypto"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
crypto *crypto.Crypto
|
||||
}
|
||||
|
||||
func Init(dbPath string) (*DB, error) {
|
||||
func Init(dbPath string, encryptionKey string) (*DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -20,7 +24,17 @@ func Init(dbPath string) (*DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database := &DB{db}
|
||||
// Initialize crypto service
|
||||
cryptoService, err := crypto.New(encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize encryption: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: db,
|
||||
crypto: cryptoService,
|
||||
}
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,3 +45,18 @@ func Init(dbPath string) (*DB, error) {
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
|
||||
// Helper methods for token encryption/decryption
|
||||
func (db *DB) encryptToken(token string) (string, error) {
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
return db.crypto.Encrypt(token)
|
||||
}
|
||||
|
||||
func (db *DB) decryptToken(token string) (string, error) {
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
return db.crypto.Decrypt(token)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,37 @@ type Migration struct {
|
||||
var migrations = []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
SQL: `CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
settings JSON NOT NULL
|
||||
)`,
|
||||
SQL: `
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_workspace_id INTEGER
|
||||
);
|
||||
|
||||
-- Create workspaces table with integrated settings
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_opened_file_path TEXT,
|
||||
-- Settings fields
|
||||
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
|
||||
auto_save BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_url TEXT,
|
||||
git_user TEXT,
|
||||
git_token TEXT,
|
||||
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func (db *DB) GetSettings(userID int) (models.Settings, error) {
|
||||
var settings models.Settings
|
||||
var settingsJSON []byte
|
||||
|
||||
err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// If no settings found, return default settings
|
||||
settings.UserID = userID
|
||||
settings.Settings = models.UserSettings{} // This will be filled with defaults later
|
||||
return settings, nil
|
||||
}
|
||||
return settings, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(settingsJSON, &settings.Settings)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (db *DB) SaveSettings(settings models.Settings) error {
|
||||
if err := settings.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.Exec("INSERT OR REPLACE INTO settings (user_id, settings) VALUES (?, json(?))", settings.UserID, string(settingsJSON))
|
||||
return err
|
||||
}
|
||||
154
backend/internal/db/users.go
Normal file
154
backend/internal/db/users.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func (db *DB) CreateUser(user *models.User) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO users (email, display_name, password_hash, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
user.Email, user.DisplayName, user.PasswordHash, user.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.ID = int(userID)
|
||||
|
||||
// Create default workspace with default settings
|
||||
defaultWorkspace := &models.Workspace{
|
||||
UserID: user.ID,
|
||||
Name: "Main",
|
||||
}
|
||||
defaultWorkspace.GetDefaultSettings() // Initialize default settings
|
||||
|
||||
// Create workspace with settings
|
||||
err = db.createWorkspaceTx(tx, defaultWorkspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update user's last workspace ID
|
||||
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.LastWorkspaceID = defaultWorkspace.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO workspaces (
|
||||
user_id, name,
|
||||
theme, auto_save,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
workspace.UserID, workspace.Name,
|
||||
workspace.Theme, workspace.AutoSave,
|
||||
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken,
|
||||
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByID(id int) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, email, display_name, role, created_at,
|
||||
last_workspace_id
|
||||
FROM users
|
||||
WHERE id = ?`, id).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt,
|
||||
&user.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, email, display_name, password_hash, role, created_at,
|
||||
last_workspace_id
|
||||
FROM users
|
||||
WHERE email = ?`, email).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
|
||||
&user.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUser(user *models.User) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users
|
||||
SET email = ?, display_name = ?, role = ?, last_workspace_id = ?
|
||||
WHERE id = ?`,
|
||||
user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error {
|
||||
_, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteUser(id int) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete all user's workspaces first
|
||||
_, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
_, err = tx.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) GetLastWorkspaceID(userID int) (int, error) {
|
||||
var workspaceID int
|
||||
err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID)
|
||||
return workspaceID, err
|
||||
}
|
||||
209
backend/internal/db/workspaces.go
Normal file
209
backend/internal/db/workspaces.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
|
||||
// Set default settings if not provided
|
||||
if workspace.Theme == "" {
|
||||
workspace.GetDefaultSettings()
|
||||
}
|
||||
|
||||
// Encrypt token if present
|
||||
encryptedToken, err := db.encryptToken(workspace.GitToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO workspaces (
|
||||
user_id, name, theme, auto_save,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave,
|
||||
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken,
|
||||
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
).Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
|
||||
// Encrypt token before storing
|
||||
encryptedToken, err := db.encryptToken(workspace.GitToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
UPDATE workspaces
|
||||
SET
|
||||
name = ?,
|
||||
theme = ?,
|
||||
auto_save = ?,
|
||||
git_enabled = ?,
|
||||
git_url = ?,
|
||||
git_user = ?,
|
||||
git_token = ?,
|
||||
git_auto_commit = ?,
|
||||
git_commit_msg_template = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
workspace.Name,
|
||||
workspace.Theme,
|
||||
workspace.AutoSave,
|
||||
workspace.GitEnabled,
|
||||
workspace.GitURL,
|
||||
workspace.GitUser,
|
||||
encryptedToken,
|
||||
workspace.GitAutoCommit,
|
||||
workspace.GitCommitMsgTemplate,
|
||||
workspace.ID,
|
||||
workspace.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces
|
||||
WHERE user_id = ?`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var workspaces []*models.Workspace
|
||||
for rows.Next() {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
err := rows.Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
// UpdateWorkspaceSettings updates only the settings portion of a workspace
|
||||
// This is useful when you don't want to modify the name or other core workspace properties
|
||||
func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE workspaces
|
||||
SET
|
||||
theme = ?,
|
||||
auto_save = ?,
|
||||
git_enabled = ?,
|
||||
git_url = ?,
|
||||
git_user = ?,
|
||||
git_token = ?,
|
||||
git_auto_commit = ?,
|
||||
git_commit_msg_template = ?
|
||||
WHERE id = ?`,
|
||||
workspace.Theme,
|
||||
workspace.AutoSave,
|
||||
workspace.GitEnabled,
|
||||
workspace.GitURL,
|
||||
workspace.GitUser,
|
||||
workspace.GitToken,
|
||||
workspace.GitAutoCommit,
|
||||
workspace.GitCommitMsgTemplate,
|
||||
workspace.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteWorkspace(id int) error {
|
||||
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
|
||||
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
|
||||
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error {
|
||||
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
|
||||
var filePath sql.NullString
|
||||
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !filePath.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return filePath.String, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"novamd/internal/gitutils"
|
||||
"novamd/internal/models"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -13,8 +12,7 @@ import (
|
||||
|
||||
type FileSystem struct {
|
||||
RootDir string
|
||||
GitRepo *gitutils.GitRepo
|
||||
Settings *models.Settings
|
||||
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
|
||||
}
|
||||
|
||||
type FileNode struct {
|
||||
@@ -24,67 +22,52 @@ type FileNode struct {
|
||||
Children []FileNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func New(rootDir string, settings *models.Settings) *FileSystem {
|
||||
fs := &FileSystem{
|
||||
func New(rootDir string) *FileSystem {
|
||||
return &FileSystem{
|
||||
RootDir: rootDir,
|
||||
Settings: settings,
|
||||
GitRepos: make(map[int]map[int]*gitutils.GitRepo),
|
||||
}
|
||||
}
|
||||
|
||||
if settings.Settings.GitEnabled {
|
||||
fs.GitRepo = gitutils.New(
|
||||
settings.Settings.GitURL,
|
||||
settings.Settings.GitUser,
|
||||
settings.Settings.GitToken,
|
||||
rootDir,
|
||||
)
|
||||
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string {
|
||||
return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
|
||||
}
|
||||
|
||||
return fs
|
||||
func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
err := os.MkdirAll(workspacePath, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace directory: %w", err)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error {
|
||||
fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir)
|
||||
return fs.InitializeGitRepo()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *FileSystem) DisableGitRepo() {
|
||||
fs.GitRepo = nil;
|
||||
func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
err := os.RemoveAll(workspacePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete workspace directory: %w", err)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) InitializeGitRepo() error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fs.GitRepo.EnsureRepo()
|
||||
}
|
||||
|
||||
func ValidatePath(rootDir, path string) (string, error) {
|
||||
fullPath := filepath.Join(rootDir, path)
|
||||
func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
fullPath := filepath.Join(workspacePath, path)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) {
|
||||
return "", fmt.Errorf("invalid path: outside of root directory")
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(rootDir, cleanPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return "", fmt.Errorf("invalid path: outside of root directory")
|
||||
if !strings.HasPrefix(cleanPath, workspacePath) {
|
||||
return "", fmt.Errorf("invalid path: outside of workspace")
|
||||
}
|
||||
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
func (fs *FileSystem) validatePath(path string) (string, error) {
|
||||
return ValidatePath(fs.RootDir, path)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) {
|
||||
return fs.walkDirectory(fs.RootDir, "")
|
||||
func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
return fs.walkDirectory(workspacePath, "")
|
||||
}
|
||||
|
||||
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
|
||||
@@ -93,67 +76,77 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var folders []FileNode
|
||||
var files []FileNode
|
||||
|
||||
// Split entries into directories and files
|
||||
var dirs, files []os.DirEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, entry)
|
||||
} else {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort directories and files separately
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name())
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name())
|
||||
})
|
||||
|
||||
// Create combined slice with directories first, then files
|
||||
nodes := make([]FileNode, 0, len(entries))
|
||||
|
||||
// Add directories first
|
||||
for _, entry := range dirs {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
if entry.IsDir() {
|
||||
children, err := fs.walkDirectory(fullPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folders = append(folders, FileNode{
|
||||
ID: path, // Using path as ID ensures uniqueness
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
Children: children,
|
||||
})
|
||||
} else {
|
||||
files = append(files, FileNode{
|
||||
ID: path, // Using path as ID ensures uniqueness
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
// Then add files
|
||||
for _, entry := range files {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
// Sort folders and files alphabetically
|
||||
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name })
|
||||
|
||||
// Combine folders and files, with folders first
|
||||
return append(folders, files...), nil
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
|
||||
func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
|
||||
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
|
||||
var foundPaths []string
|
||||
var searchPattern string
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
|
||||
// If no extension is provided, assume .md
|
||||
if !strings.Contains(filenameOrPath, ".") {
|
||||
searchPattern = filenameOrPath + ".md"
|
||||
} else {
|
||||
searchPattern = filenameOrPath
|
||||
}
|
||||
|
||||
err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
relPath, err := filepath.Rel(fs.RootDir, path)
|
||||
relPath, err := filepath.Rel(workspacePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the file matches the search pattern
|
||||
if strings.HasSuffix(relPath, searchPattern) ||
|
||||
strings.EqualFold(info.Name(), searchPattern) {
|
||||
if strings.EqualFold(info.Name(), filename) {
|
||||
foundPaths = append(foundPaths, relPath)
|
||||
}
|
||||
}
|
||||
@@ -171,16 +164,16 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
|
||||
return foundPaths, nil
|
||||
}
|
||||
|
||||
func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(fullPath)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -190,38 +183,67 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if fs.Settings.Settings.GitAutoCommit && fs.GitRepo != nil {
|
||||
message := strings.Replace(fs.Settings.Settings.GitCommitMsgTemplate, "${filename}", filePath, -1)
|
||||
return fs.StageCommitAndPush(message)
|
||||
}
|
||||
|
||||
return os.WriteFile(fullPath, content, 0644)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) DeleteFile(filePath string) error {
|
||||
fullPath, err := fs.validatePath(filePath)
|
||||
func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
func (fs *FileSystem) StageCommitAndPush(message string) error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
|
||||
dir := fs.GetWorkspacePath(userID, workspaceID)
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
if err := fs.GitRepo.Commit(message); err != nil {
|
||||
func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
if _, ok := fs.GitRepos[userID]; !ok {
|
||||
fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo)
|
||||
}
|
||||
fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath)
|
||||
return fs.GitRepos[userID][workspaceID].EnsureRepo()
|
||||
}
|
||||
|
||||
func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) {
|
||||
if userRepos, ok := fs.GitRepos[userID]; ok {
|
||||
delete(userRepos, workspaceID)
|
||||
if len(userRepos) == 0 {
|
||||
delete(fs.GitRepos, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error {
|
||||
repo, ok := fs.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return errors.New("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
if err := repo.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fs.GitRepo.Push()
|
||||
return repo.Push()
|
||||
}
|
||||
|
||||
func (fs *FileSystem) Pull() error {
|
||||
if fs.GitRepo == nil {
|
||||
return errors.New("git settings not configured")
|
||||
func (fs *FileSystem) Pull(userID, workspaceID int) error {
|
||||
repo, ok := fs.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return errors.New("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
return fs.GitRepo.Pull()
|
||||
return repo.Pull()
|
||||
}
|
||||
|
||||
func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) {
|
||||
userRepos, ok := fs.GitRepos[userID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
repo, ok := userRepos[workspaceID]
|
||||
return repo, ok
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `json:"theme" validate:"oneof=light dark"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
GitEnabled bool `json:"gitEnabled"`
|
||||
GitURL string `json:"gitUrl" validate:"required_with=GitEnabled"`
|
||||
GitUser string `json:"gitUser" validate:"required_with=GitEnabled"`
|
||||
GitToken string `json:"gitToken" validate:"required_with=GitEnabled"`
|
||||
GitAutoCommit bool `json:"gitAutoCommit"`
|
||||
GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
UserID int `json:"userId" validate:"required,min=1"`
|
||||
Settings UserSettings `json:"settings" validate:"required"`
|
||||
}
|
||||
|
||||
var defaultUserSettings = UserSettings{
|
||||
Theme: "light",
|
||||
AutoSave: false,
|
||||
GitEnabled: false,
|
||||
GitCommitMsgTemplate: "Update ${filename}",
|
||||
}
|
||||
|
||||
var validate = validator.New()
|
||||
|
||||
func (s *Settings) Validate() error {
|
||||
return validate.Struct(s)
|
||||
}
|
||||
|
||||
func (s *Settings) SetDefaults() {
|
||||
if s.Settings.Theme == "" {
|
||||
s.Settings.Theme = defaultUserSettings.Theme
|
||||
}
|
||||
if s.Settings.GitCommitMsgTemplate == "" {
|
||||
s.Settings.GitCommitMsgTemplate = defaultUserSettings.GitCommitMsgTemplate
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Settings) UnmarshalJSON(data []byte) error {
|
||||
type Alias Settings
|
||||
aux := &struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(s),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Validate()
|
||||
}
|
||||
31
backend/internal/models/user.go
Normal file
31
backend/internal/models/user.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var validate = validator.New()
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleEditor UserRole = "editor"
|
||||
RoleViewer UserRole = "viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id" validate:"required,min=1"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role UserRole `json:"role" validate:"required,oneof=admin editor viewer"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastWorkspaceID int `json:"lastWorkspaceId"`
|
||||
}
|
||||
|
||||
func (u *User) Validate() error {
|
||||
return validate.Struct(u)
|
||||
}
|
||||
38
backend/internal/models/workspace.go
Normal file
38
backend/internal/models/workspace.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Workspace struct {
|
||||
ID int `json:"id" validate:"required,min=1"`
|
||||
UserID int `json:"userId" validate:"required,min=1"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastOpenedFilePath string `json:"lastOpenedFilePath"`
|
||||
|
||||
// Integrated settings
|
||||
Theme string `json:"theme" validate:"oneof=light dark"`
|
||||
AutoSave bool `json:"autoSave"`
|
||||
GitEnabled bool `json:"gitEnabled"`
|
||||
GitURL string `json:"gitUrl" validate:"required_if=GitEnabled true"`
|
||||
GitUser string `json:"gitUser" validate:"required_if=GitEnabled true"`
|
||||
GitToken string `json:"gitToken" validate:"required_if=GitEnabled true"`
|
||||
GitAutoCommit bool `json:"gitAutoCommit"`
|
||||
GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"`
|
||||
}
|
||||
|
||||
func (w *Workspace) Validate() error {
|
||||
return validate.Struct(w)
|
||||
}
|
||||
|
||||
func (w *Workspace) GetDefaultSettings() {
|
||||
w.Theme = "light"
|
||||
w.AutoSave = false
|
||||
w.GitEnabled = false
|
||||
w.GitURL = ""
|
||||
w.GitUser = ""
|
||||
w.GitToken = ""
|
||||
w.GitAutoCommit = false
|
||||
w.GitCommitMsgTemplate = "${action} ${filename}"
|
||||
}
|
||||
115
backend/internal/user/user.go
Normal file
115
backend/internal/user/user.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
DB *db.DB
|
||||
FS *filesystem.FileSystem
|
||||
}
|
||||
|
||||
func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService {
|
||||
return &UserService{
|
||||
DB: database,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.User, error) {
|
||||
// Check if admin user exists
|
||||
adminUser, err := s.DB.GetUserByEmail(adminEmail)
|
||||
if adminUser != nil {
|
||||
return adminUser, nil // Admin user already exists
|
||||
} else if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
adminUser = &models.User{
|
||||
Email: adminEmail,
|
||||
DisplayName: "Admin",
|
||||
PasswordHash: string(hashedPassword),
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
|
||||
err = s.DB.CreateUser(adminUser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create admin user: %w", err)
|
||||
}
|
||||
|
||||
// Initialize workspace directory
|
||||
err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID)
|
||||
|
||||
return adminUser, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateUser(user *models.User) error {
|
||||
err := s.DB.CreateUser(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize user workspace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserByID(id int) (*models.User, error) {
|
||||
return s.DB.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
|
||||
return s.DB.GetUserByEmail(email)
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(user *models.User) error {
|
||||
return s.DB.UpdateUser(user)
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteUser(id int) error {
|
||||
// First, get the user to check if they exist
|
||||
user, err := s.DB.GetUserByID(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
// Get user's workspaces
|
||||
workspaces, err := s.DB.GetWorkspacesByUserID(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user's workspaces: %w", err)
|
||||
}
|
||||
|
||||
// Delete workspace directories
|
||||
for _, workspace := range workspaces {
|
||||
err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete workspace files: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user from database (this will cascade delete workspaces)
|
||||
return s.DB.DeleteUser(id)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { "runtime": "automatic" }]
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
}
|
||||
7323
frontend/package-lock.json
generated
7323
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,11 @@
|
||||
"name": "novamd-frontend",
|
||||
"version": "0.1.0",
|
||||
"description": "Yet another markdown editor",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production"
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -43,11 +44,16 @@
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.80.4",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-compression2": "^1.3.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -60,26 +66,5 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.7",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.7",
|
||||
"@babel/plugin-transform-runtime": "^7.25.7",
|
||||
"@babel/preset-env": "^7.25.7",
|
||||
"@babel/preset-react": "^7.25.7",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"sass": "^1.79.4",
|
||||
"sass-loader": "^16.0.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import Layout from './components/Layout';
|
||||
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
|
||||
import { WorkspaceProvider } from './contexts/WorkspaceContext';
|
||||
import { ModalProvider } from './contexts/ModalContext';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './App.scss';
|
||||
|
||||
function AppContent() {
|
||||
const { loading } = useSettings();
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return <Layout />;
|
||||
}
|
||||
|
||||
@@ -26,11 +20,11 @@ function App() {
|
||||
<MantineProvider defaultColorScheme="light">
|
||||
<Notifications />
|
||||
<ModalsProvider>
|
||||
<SettingsProvider>
|
||||
<WorkspaceProvider>
|
||||
<ModalProvider>
|
||||
<AppContent />
|
||||
</ModalProvider>
|
||||
</SettingsProvider>
|
||||
</WorkspaceProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</>
|
||||
@@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
const { settings } = useSettings();
|
||||
const { colorScheme } = useWorkspace();
|
||||
const editorRef = useRef();
|
||||
const viewRef = useRef();
|
||||
|
||||
@@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
color: settings.theme === 'dark' ? '#858585' : '#999',
|
||||
backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
|
||||
color: colorScheme === 'dark' ? '#858585' : '#999',
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8',
|
||||
backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
}
|
||||
}),
|
||||
theme,
|
||||
settings.theme === 'dark' ? oneDark : [],
|
||||
colorScheme === 'dark' ? oneDark : [],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
|
||||
return () => {
|
||||
view.destroy();
|
||||
};
|
||||
}, [settings.theme, handleContentChange]);
|
||||
}, [colorScheme, handleContentChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && content !== viewRef.current.state.doc.toString()) {
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
IconGitPullRequest,
|
||||
IconGitCommit,
|
||||
} from '@tabler/icons-react';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
const FileActions = ({ handlePullChanges, selectedFile }) => {
|
||||
const { settings } = useSettings();
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
setNewFileModalVisible,
|
||||
setDeleteFileModalVisible,
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Group, Text, ActionIcon, Avatar } from '@mantine/core';
|
||||
import { IconSettings } from '@tabler/icons-react';
|
||||
import Settings from './Settings';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
|
||||
const Header = () => {
|
||||
const { setSettingsModalVisible } = useModalContext();
|
||||
|
||||
const openSettings = () => setSettingsModalVisible(true);
|
||||
|
||||
return (
|
||||
<Group justify="space-between" h={60} px="md">
|
||||
<Text fw={700} size="lg">
|
||||
NovaMD
|
||||
</Text>
|
||||
<Group>
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
<ActionIcon variant="subtle" onClick={openSettings} size="lg">
|
||||
<IconSettings size={24} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Settings />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
21
frontend/src/components/Header.jsx
Normal file
21
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Group, Text, Avatar } from '@mantine/core';
|
||||
import WorkspaceSwitcher from './WorkspaceSwitcher';
|
||||
import Settings from './Settings';
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<Group justify="space-between" h={60} px="md">
|
||||
<Text fw={700} size="lg">
|
||||
NovaMD
|
||||
</Text>
|
||||
<Group>
|
||||
<WorkspaceSwitcher />
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
</Group>
|
||||
<Settings />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -1,13 +1,29 @@
|
||||
import React from 'react';
|
||||
import { AppShell, Container } from '@mantine/core';
|
||||
import { AppShell, Container, Loader, Center } from '@mantine/core';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import MainContent from './MainContent';
|
||||
import { useFileNavigation } from '../hooks/useFileNavigation';
|
||||
import { useFileList } from '../hooks/useFileList';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
const Layout = () => {
|
||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||
const { selectedFile, handleFileSelect, handleLinkClick } =
|
||||
useFileNavigation();
|
||||
const { files, loadFileList } = useFileList();
|
||||
|
||||
if (workspaceLoading) {
|
||||
return (
|
||||
<Center style={{ height: '100vh' }}>
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return <div>No workspace found. Please create a workspace.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 60 }} padding="md">
|
||||
@@ -27,11 +43,14 @@ const Layout = () => {
|
||||
<Sidebar
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
files={files}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
<MainContent
|
||||
selectedFile={selectedFile}
|
||||
handleFileSelect={handleFileSelect}
|
||||
handleLinkClick={handleLinkClick}
|
||||
loadFileList={loadFileList}
|
||||
/>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
@@ -10,11 +10,16 @@ import CommitMessageModal from './modals/CommitMessageModal';
|
||||
import { useFileContent } from '../hooks/useFileContent';
|
||||
import { useFileOperations } from '../hooks/useFileOperations';
|
||||
import { useGitOperations } from '../hooks/useGitOperations';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
|
||||
const MainContent = ({
|
||||
selectedFile,
|
||||
handleFileSelect,
|
||||
handleLinkClick,
|
||||
loadFileList,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('source');
|
||||
const { settings } = useSettings();
|
||||
const { settings } = useWorkspace();
|
||||
const {
|
||||
content,
|
||||
hasUnsavedChanges,
|
||||
@@ -33,38 +38,32 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
|
||||
let success = await handleSave(filePath, content);
|
||||
if (success) {
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
if (settings.gitAutoCommit && settings.gitEnabled) {
|
||||
const commitMessage = settings.gitCommitMsgTemplate.replace(
|
||||
'${filename}',
|
||||
filePath
|
||||
);
|
||||
success = await handleCommitAndPush(commitMessage);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
},
|
||||
[handleSave, setHasUnsavedChanges, settings, handleCommitAndPush]
|
||||
[handleSave, setHasUnsavedChanges]
|
||||
);
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
async (fileName) => {
|
||||
const success = await handleCreate(fileName);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(fileName);
|
||||
}
|
||||
},
|
||||
[handleCreate, handleFileSelect]
|
||||
[handleCreate, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
async (filePath) => {
|
||||
const success = await handleDelete(filePath);
|
||||
if (success) {
|
||||
loadFileList();
|
||||
handleFileSelect(null);
|
||||
}
|
||||
},
|
||||
[handleDelete, handleFileSelect]
|
||||
[handleDelete, handleFileSelect, loadFileList]
|
||||
);
|
||||
|
||||
const renderBreadcrumbs = useMemo(() => {
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Badge, Button, Group, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import AppearanceSettings from './settings/AppearanceSettings';
|
||||
import EditorSettings from './settings/EditorSettings';
|
||||
import GitSettings from './settings/GitSettings';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'RESET':
|
||||
return {
|
||||
...state,
|
||||
localSettings: state.initialSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const { settings, updateSettings, colorScheme } = useSettings();
|
||||
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: 'UPDATE_LOCAL_SETTINGS',
|
||||
payload: { theme: colorScheme },
|
||||
});
|
||||
}, [colorScheme]);
|
||||
|
||||
const handleInputChange = useCallback((key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await updateSettings(state.localSettings);
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
notifications.show({
|
||||
message: 'Settings saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setSettingsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
notifications.show({
|
||||
message: 'Failed to save settings: ' + error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (state.hasUnsavedChanges) {
|
||||
dispatch({ type: 'RESET' });
|
||||
}
|
||||
setSettingsModalVisible(false);
|
||||
}, [state.hasUnsavedChanges, setSettingsModalVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={settingsModalVisible}
|
||||
onClose={handleClose}
|
||||
title={<Title order={2}>Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light" mb="md">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
<AppearanceSettings
|
||||
themeSettings={state.localSettings.theme}
|
||||
onThemeChange={(newTheme) => handleInputChange('theme', newTheme)}
|
||||
/>
|
||||
<EditorSettings
|
||||
autoSave={state.localSettings.autoSave}
|
||||
onAutoSaveChange={(value) => handleInputChange('autoSave', value)}
|
||||
/>
|
||||
<GitSettings
|
||||
gitEnabled={state.localSettings.gitEnabled}
|
||||
gitUrl={state.localSettings.gitUrl}
|
||||
gitUser={state.localSettings.gitUser}
|
||||
gitToken={state.localSettings.gitToken}
|
||||
gitAutoCommit={state.localSettings.gitAutoCommit}
|
||||
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Changes</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
231
frontend/src/components/Settings.jsx
Normal file
231
frontend/src/components/Settings.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useReducer, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Title,
|
||||
Stack,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import AppearanceSettings from './settings/AppearanceSettings';
|
||||
import EditorSettings from './settings/EditorSettings';
|
||||
import GitSettings from './settings/GitSettings';
|
||||
import GeneralSettings from './settings/GeneralSettings';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
import DangerZoneSettings from './settings/DangerZoneSettings';
|
||||
|
||||
const initialState = {
|
||||
localSettings: {},
|
||||
initialSettings: {},
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
|
||||
function settingsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT_SETTINGS':
|
||||
return {
|
||||
...state,
|
||||
localSettings: action.payload,
|
||||
initialSettings: action.payload,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
case 'UPDATE_LOCAL_SETTINGS':
|
||||
const newLocalSettings = { ...state.localSettings, ...action.payload };
|
||||
const hasChanges =
|
||||
JSON.stringify(newLocalSettings) !==
|
||||
JSON.stringify(state.initialSettings);
|
||||
return {
|
||||
...state,
|
||||
localSettings: newLocalSettings,
|
||||
hasUnsavedChanges: hasChanges,
|
||||
};
|
||||
case 'MARK_SAVED':
|
||||
return {
|
||||
...state,
|
||||
initialSettings: state.localSettings,
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const AccordionControl = ({ children }) => (
|
||||
<Accordion.Control>
|
||||
<Title order={4}>{children}</Title>
|
||||
</Accordion.Control>
|
||||
);
|
||||
|
||||
const Settings = () => {
|
||||
const { currentWorkspace, updateSettings } = useWorkspace();
|
||||
const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
|
||||
const [state, dispatch] = useReducer(settingsReducer, initialState);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
const settings = {
|
||||
name: currentWorkspace.name,
|
||||
theme: currentWorkspace.theme,
|
||||
autoSave: currentWorkspace.autoSave,
|
||||
gitEnabled: currentWorkspace.gitEnabled,
|
||||
gitUrl: currentWorkspace.gitUrl,
|
||||
gitUser: currentWorkspace.gitUser,
|
||||
gitToken: currentWorkspace.gitToken,
|
||||
gitAutoCommit: currentWorkspace.gitAutoCommit,
|
||||
gitCommitMsgTemplate: currentWorkspace.gitCommitMsgTemplate,
|
||||
};
|
||||
dispatch({ type: 'INIT_SETTINGS', payload: settings });
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const handleInputChange = useCallback((key, value) => {
|
||||
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!state.localSettings.name?.trim()) {
|
||||
notifications.show({
|
||||
message: 'Workspace name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSettings(state.localSettings);
|
||||
dispatch({ type: 'MARK_SAVED' });
|
||||
notifications.show({
|
||||
message: 'Settings saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setSettingsModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
notifications.show({
|
||||
message: 'Failed to save settings: ' + error.message,
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSettingsModalVisible(false);
|
||||
}, [setSettingsModalVisible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={settingsModalVisible}
|
||||
onClose={handleClose}
|
||||
title={<Title order={2}>Settings</Title>}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack spacing="xl">
|
||||
{state.hasUnsavedChanges && (
|
||||
<Badge color="yellow" variant="light">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
|
||||
multiple
|
||||
styles={(theme) => ({
|
||||
control: {
|
||||
paddingTop: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
item: {
|
||||
borderBottom: `1px solid ${
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[3]
|
||||
}`,
|
||||
'&[data-active]': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
chevron: {
|
||||
'&[data-rotate]': {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value="general">
|
||||
<AccordionControl>General</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GeneralSettings
|
||||
name={state.localSettings.name}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="appearance">
|
||||
<AccordionControl>Appearance</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<AppearanceSettings
|
||||
themeSettings={state.localSettings.theme}
|
||||
onThemeChange={(newTheme) =>
|
||||
handleInputChange('theme', newTheme)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="editor">
|
||||
<AccordionControl>Editor</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<EditorSettings
|
||||
autoSave={state.localSettings.autoSave}
|
||||
onAutoSaveChange={(value) =>
|
||||
handleInputChange('autoSave', value)
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="git">
|
||||
<AccordionControl>Git Integration</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<GitSettings
|
||||
gitEnabled={state.localSettings.gitEnabled}
|
||||
gitUrl={state.localSettings.gitUrl}
|
||||
gitUser={state.localSettings.gitUser}
|
||||
gitToken={state.localSettings.gitToken}
|
||||
gitAutoCommit={state.localSettings.gitAutoCommit}
|
||||
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="danger">
|
||||
<AccordionControl>Danger Zone</AccordionControl>
|
||||
<Accordion.Panel>
|
||||
<DangerZoneSettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>Save Changes</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -2,18 +2,16 @@ import React, { useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import FileActions from './FileActions';
|
||||
import FileTree from './FileTree';
|
||||
import { useFileList } from '../hooks/useFileList';
|
||||
import { useGitOperations } from '../hooks/useGitOperations';
|
||||
import { useSettings } from '../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
const Sidebar = ({ selectedFile, handleFileSelect }) => {
|
||||
const { settings } = useSettings();
|
||||
const { files, loadFileList } = useFileList();
|
||||
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
|
||||
const { settings } = useWorkspace();
|
||||
const { handlePull } = useGitOperations(settings.gitEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileList();
|
||||
}, [settings.gitEnabled, loadFileList]);
|
||||
}, [loadFileList]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
197
frontend/src/components/WorkspaceSwitcher.jsx
Normal file
197
frontend/src/components/WorkspaceSwitcher.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Popover,
|
||||
Stack,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../contexts/ModalContext';
|
||||
import { listWorkspaces } from '../services/api';
|
||||
import CreateWorkspaceModal from './modals/CreateWorkspaceModal';
|
||||
|
||||
const WorkspaceSwitcher = () => {
|
||||
const { currentWorkspace, switchWorkspace } = useWorkspace();
|
||||
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const loadWorkspaces = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listWorkspaces();
|
||||
setWorkspaces(list);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setPopoverOpened(false);
|
||||
setCreateWorkspaceModalVisible(true);
|
||||
};
|
||||
|
||||
const handleWorkspaceCreated = async (newWorkspace) => {
|
||||
await loadWorkspaces();
|
||||
switchWorkspace(newWorkspace.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
width={300}
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setPopoverOpened((o) => !o);
|
||||
if (!popoverOpened) {
|
||||
loadWorkspaces();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconFolders size={20} />
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentWorkspace?.name || 'No workspace'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group justify="space-between" mb="md" px="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Workspaces
|
||||
</Text>
|
||||
<Tooltip label="Create New Workspace">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="md"
|
||||
onClick={handleCreateWorkspace}
|
||||
>
|
||||
<IconFolderPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={400} offsetScrollbars>
|
||||
<Stack gap="xs">
|
||||
{loading ? (
|
||||
<Center p="md">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
workspaces.map((workspace) => {
|
||||
const isSelected = workspace.id === currentWorkspace?.id;
|
||||
return (
|
||||
<Paper
|
||||
key={workspace.id}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 8 : 1
|
||||
]
|
||||
: undefined,
|
||||
borderColor: isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 7 : 5
|
||||
]
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<UnstyledButton
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
switchWorkspace(workspace.id);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
truncate
|
||||
c={
|
||||
isSelected
|
||||
? theme.colors.blue[
|
||||
theme.colorScheme === 'dark' ? 0 : 9
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
isSelected
|
||||
? theme.colorScheme === 'dark'
|
||||
? theme.colors.blue[2]
|
||||
: theme.colors.blue[7]
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
{new Date(
|
||||
workspace.createdAt
|
||||
).toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
{isSelected && (
|
||||
<Tooltip label="Workspace Settings">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color={
|
||||
theme.colorScheme === 'dark'
|
||||
? 'blue.2'
|
||||
: 'blue.7'
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSettingsModalVisible(true);
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<IconSettings size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<CreateWorkspaceModal onWorkspaceCreated={handleWorkspaceCreated} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSwitcher;
|
||||
82
frontend/src/components/modals/CreateWorkspaceModal.jsx
Normal file
82
frontend/src/components/modals/CreateWorkspaceModal.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
import { createWorkspace } from '../../services/api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
|
||||
useModalContext();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Workspace name is required',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspace = await createWorkspace(name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
setName('');
|
||||
setCreateWorkspaceModalVisible(false);
|
||||
if (onWorkspaceCreated) {
|
||||
onWorkspaceCreated(workspace);
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create workspace',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={createWorkspaceModalVisible}
|
||||
onClose={() => setCreateWorkspaceModalVisible(false)}
|
||||
title="Create New Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Box maw={400} mx="auto">
|
||||
<TextInput
|
||||
label="Workspace Name"
|
||||
placeholder="Enter workspace name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
mb="md"
|
||||
w="100%"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCreateWorkspaceModalVisible(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateWorkspaceModal;
|
||||
35
frontend/src/components/modals/DeleteWorkspaceModal.jsx
Normal file
35
frontend/src/components/modals/DeleteWorkspaceModal.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
|
||||
const DeleteWorkspaceModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
workspaceName,
|
||||
}) => (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Delete Workspace"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
Are you sure you want to delete workspace "{workspaceName}"? This action
|
||||
cannot be undone and all files in this workspace will be permanently
|
||||
deleted.
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm}>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default DeleteWorkspaceModal;
|
||||
@@ -1,20 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Text, Switch, Group, Box, Title } from '@mantine/core';
|
||||
import { useSettings } from '../../contexts/SettingsContext';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
|
||||
const AppearanceSettings = ({ onThemeChange }) => {
|
||||
const { colorScheme, toggleColorScheme } = useSettings();
|
||||
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
|
||||
const { colorScheme, updateColorScheme } = useWorkspace();
|
||||
|
||||
const handleThemeChange = () => {
|
||||
toggleColorScheme();
|
||||
onThemeChange(colorScheme === 'dark' ? 'light' : 'dark');
|
||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
updateColorScheme(newTheme);
|
||||
onThemeChange(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Title order={3} mb="md">
|
||||
Appearance
|
||||
</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Dark Mode</Text>
|
||||
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
|
||||
46
frontend/src/components/settings/DangerZoneSettings.jsx
Normal file
46
frontend/src/components/settings/DangerZoneSettings.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Title } from '@mantine/core';
|
||||
import DeleteWorkspaceModal from '../modals/DeleteWorkspaceModal';
|
||||
import { useWorkspace } from '../../contexts/WorkspaceContext';
|
||||
import { useModalContext } from '../../contexts/ModalContext';
|
||||
|
||||
const DangerZoneSettings = () => {
|
||||
const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
|
||||
useWorkspace();
|
||||
const { setSettingsModalVisible } = useModalContext();
|
||||
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteCurrentWorkspace();
|
||||
setDeleteModalOpened(false);
|
||||
setSettingsModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={() => setDeleteModalOpened(true)}
|
||||
fullWidth
|
||||
disabled={workspaces.length <= 1}
|
||||
title={
|
||||
workspaces.length <= 1
|
||||
? 'Cannot delete the last workspace'
|
||||
: 'Delete this workspace'
|
||||
}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
|
||||
<DeleteWorkspaceModal
|
||||
opened={deleteModalOpened}
|
||||
onClose={() => setDeleteModalOpened(false)}
|
||||
onConfirm={handleDelete}
|
||||
workspaceName={currentWorkspace?.name}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DangerZoneSettings;
|
||||
@@ -4,9 +4,6 @@ import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
|
||||
const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Title order={3} mb="md">
|
||||
Editor
|
||||
</Title>
|
||||
<Tooltip label="Auto Save feature is coming soon!" position="left">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">Auto Save</Text>
|
||||
26
frontend/src/components/settings/GeneralSettings.jsx
Normal file
26
frontend/src/components/settings/GeneralSettings.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Title, Box, TextInput, Text, Grid } from '@mantine/core';
|
||||
|
||||
const GeneralSettings = ({ name, onInputChange }) => {
|
||||
return (
|
||||
<Box mb="md">
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Workspace Name</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
value={name || ''}
|
||||
onChange={(event) =>
|
||||
onInputChange('name', event.currentTarget.value)
|
||||
}
|
||||
placeholder="Enter workspace name"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
@@ -21,7 +21,6 @@ const GitSettings = ({
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing="md">
|
||||
<Title order={3}>Git Integration</Title>
|
||||
<Grid gutter="md" align="center">
|
||||
<Grid.Col span={6}>
|
||||
<Text size="sm">Enable Git</Text>
|
||||
@@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => {
|
||||
const [commitMessageModalVisible, setCommitMessageModalVisible] =
|
||||
useState(false);
|
||||
const [settingsModalVisible, setSettingsModalVisible] = useState(false);
|
||||
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
|
||||
useState(false);
|
||||
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
|
||||
useState(false);
|
||||
|
||||
const value = {
|
||||
newFileModalVisible,
|
||||
@@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => {
|
||||
setCommitMessageModalVisible,
|
||||
settingsModalVisible,
|
||||
setSettingsModalVisible,
|
||||
switchWorkspaceModalVisible,
|
||||
setSwitchWorkspaceModalVisible,
|
||||
createWorkspaceModalVisible,
|
||||
setCreateWorkspaceModalVisible,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { fetchUserSettings, saveUserSettings } from '../services/api';
|
||||
import { DEFAULT_SETTINGS } from '../utils/constants';
|
||||
|
||||
const SettingsContext = createContext();
|
||||
|
||||
export const useSettings = () => useContext(SettingsContext);
|
||||
|
||||
export const SettingsProvider = ({ children }) => {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const userSettings = await fetchUserSettings(1);
|
||||
setSettings(userSettings.settings);
|
||||
setColorScheme(userSettings.settings.theme);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (newSettings) => {
|
||||
try {
|
||||
await saveUserSettings({
|
||||
userId: 1,
|
||||
settings: newSettings,
|
||||
});
|
||||
setSettings(newSettings);
|
||||
if (newSettings.theme) {
|
||||
setColorScheme(newSettings.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[setColorScheme]
|
||||
);
|
||||
|
||||
const toggleColorScheme = useCallback(() => {
|
||||
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
setColorScheme(newTheme);
|
||||
updateSettings({ ...settings, theme: newTheme });
|
||||
}, [colorScheme, settings, setColorScheme, updateSettings]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
updateSettings,
|
||||
toggleColorScheme,
|
||||
loading,
|
||||
colorScheme,
|
||||
}),
|
||||
[settings, updateSettings, toggleColorScheme, loading, colorScheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
211
frontend/src/contexts/WorkspaceContext.jsx
Normal file
211
frontend/src/contexts/WorkspaceContext.jsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { useMantineColorScheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
fetchLastWorkspaceId,
|
||||
getWorkspace,
|
||||
updateWorkspace,
|
||||
updateLastWorkspace,
|
||||
deleteWorkspace,
|
||||
listWorkspaces,
|
||||
} from '../services/api';
|
||||
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
|
||||
|
||||
const WorkspaceContext = createContext();
|
||||
|
||||
export const WorkspaceProvider = ({ children }) => {
|
||||
const [currentWorkspace, setCurrentWorkspace] = useState(null);
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
try {
|
||||
const workspaceList = await listWorkspaces();
|
||||
setWorkspaces(workspaceList);
|
||||
return workspaceList;
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspaces:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspaces list',
|
||||
color: 'red',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadWorkspaceData = useCallback(async (workspaceId) => {
|
||||
try {
|
||||
const workspace = await getWorkspace(workspaceId);
|
||||
setCurrentWorkspace(workspace);
|
||||
setColorScheme(workspace.theme);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace data:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspace data',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadFirstAvailableWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const allWorkspaces = await listWorkspaces();
|
||||
if (allWorkspaces.length > 0) {
|
||||
const firstWorkspace = allWorkspaces[0];
|
||||
await updateLastWorkspace(firstWorkspace.id);
|
||||
await loadWorkspaceData(firstWorkspace.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load first available workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load workspace',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeWorkspace = async () => {
|
||||
try {
|
||||
const { lastWorkspaceId } = await fetchLastWorkspaceId();
|
||||
if (lastWorkspaceId) {
|
||||
await loadWorkspaceData(lastWorkspaceId);
|
||||
} else {
|
||||
await loadFirstAvailableWorkspace();
|
||||
}
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize workspace:', error);
|
||||
await loadFirstAvailableWorkspace();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeWorkspace();
|
||||
}, []);
|
||||
|
||||
const switchWorkspace = useCallback(async (workspaceId) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateLastWorkspace(workspaceId);
|
||||
await loadWorkspaceData(workspaceId);
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to switch workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to switch workspace',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteCurrentWorkspace = useCallback(async () => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
const allWorkspaces = await loadWorkspaces();
|
||||
if (allWorkspaces.length <= 1) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message:
|
||||
'Cannot delete the last workspace. At least one workspace must exist.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete workspace and get the next workspace ID
|
||||
const response = await deleteWorkspace(currentWorkspace.id);
|
||||
|
||||
// Load the new workspace data
|
||||
await loadWorkspaceData(response.nextWorkspaceId);
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Workspace deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workspace:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete workspace',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const updateSettings = useCallback(
|
||||
async (newSettings) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
const updatedWorkspace = {
|
||||
...currentWorkspace,
|
||||
...newSettings,
|
||||
};
|
||||
|
||||
const response = await updateWorkspace(
|
||||
currentWorkspace.id,
|
||||
updatedWorkspace
|
||||
);
|
||||
setCurrentWorkspace(response);
|
||||
setColorScheme(response.theme);
|
||||
await loadWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[currentWorkspace, setColorScheme]
|
||||
);
|
||||
|
||||
const updateColorScheme = useCallback(
|
||||
(newTheme) => {
|
||||
setColorScheme(newTheme);
|
||||
},
|
||||
[setColorScheme]
|
||||
);
|
||||
|
||||
const value = {
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
|
||||
updateSettings,
|
||||
loading,
|
||||
colorScheme,
|
||||
updateColorScheme,
|
||||
switchWorkspace,
|
||||
deleteCurrentWorkspace,
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkspaceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useWorkspace = () => {
|
||||
const context = useContext(WorkspaceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useWorkspace must be used within a WorkspaceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -2,19 +2,24 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { fetchFileContent } from '../services/api';
|
||||
import { isImageFile } from '../utils/fileHelpers';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useFileContent = (selectedFile) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const [content, setContent] = useState(DEFAULT_FILE.content);
|
||||
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const loadFileContent = useCallback(async (filePath) => {
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
let newContent;
|
||||
if (filePath === DEFAULT_FILE.path) {
|
||||
newContent = DEFAULT_FILE.content;
|
||||
} else if (!isImageFile(filePath)) {
|
||||
newContent = await fetchFileContent(filePath);
|
||||
newContent = await fetchFileContent(currentWorkspace.id, filePath);
|
||||
} else {
|
||||
newContent = ''; // Set empty content for image files
|
||||
}
|
||||
@@ -27,13 +32,15 @@ export const useFileContent = (selectedFile) => {
|
||||
setOriginalContent('');
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[currentWorkspace]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
if (selectedFile && currentWorkspace) {
|
||||
loadFileContent(selectedFile);
|
||||
}
|
||||
}, [selectedFile, loadFileContent]);
|
||||
}, [selectedFile, currentWorkspace, loadFileContent]);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(newContent) => {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchFileList } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useFileList = () => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
|
||||
|
||||
const loadFileList = useCallback(async () => {
|
||||
if (!currentWorkspace || workspaceLoading) return;
|
||||
|
||||
try {
|
||||
const fileList = await fetchFileList();
|
||||
const fileList = await fetchFileList(currentWorkspace.id);
|
||||
if (Array.isArray(fileList)) {
|
||||
setFiles(fileList);
|
||||
} else {
|
||||
@@ -14,8 +18,9 @@ export const useFileList = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file list:', error);
|
||||
setFiles([]);
|
||||
}
|
||||
}, []);
|
||||
}, [currentWorkspace, workspaceLoading]);
|
||||
|
||||
return { files, loadFileList };
|
||||
};
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { lookupFileByName } from '../services/api';
|
||||
import { DEFAULT_FILE } from '../utils/constants';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useLastOpenedFile } from './useLastOpenedFile';
|
||||
|
||||
export const useFileNavigation = () => {
|
||||
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
|
||||
const [isNewFile, setIsNewFile] = useState(true);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
|
||||
|
||||
const handleFileSelect = useCallback((filePath) => {
|
||||
setSelectedFile(filePath);
|
||||
setIsNewFile(filePath === DEFAULT_FILE.path);
|
||||
}, []);
|
||||
const handleFileSelect = useCallback(
|
||||
async (filePath) => {
|
||||
const newPath = filePath || DEFAULT_FILE.path;
|
||||
setSelectedFile(newPath);
|
||||
setIsNewFile(!filePath);
|
||||
|
||||
if (filePath) {
|
||||
await saveLastOpenedFile(filePath);
|
||||
}
|
||||
},
|
||||
[saveLastOpenedFile]
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
async (filename) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
const filePaths = await lookupFileByName(filename);
|
||||
const filePaths = await lookupFileByName(currentWorkspace.id, filename);
|
||||
if (filePaths.length >= 1) {
|
||||
handleFileSelect(filePaths[0]);
|
||||
} else {
|
||||
@@ -34,8 +48,22 @@ export const useFileNavigation = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleFileSelect]
|
||||
[currentWorkspace, handleFileSelect]
|
||||
);
|
||||
|
||||
// Load last opened file when workspace changes
|
||||
useEffect(() => {
|
||||
const initializeFile = async () => {
|
||||
const lastFile = await loadLastOpenedFile();
|
||||
if (lastFile) {
|
||||
handleFileSelect(lastFile);
|
||||
} else {
|
||||
handleFileSelect(null);
|
||||
}
|
||||
};
|
||||
|
||||
initializeFile();
|
||||
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);
|
||||
|
||||
return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
|
||||
};
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { saveFileContent, deleteFile } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useGitOperations } from './useGitOperations';
|
||||
|
||||
export const useFileOperations = () => {
|
||||
const handleSave = useCallback(async (filePath, content) => {
|
||||
const { currentWorkspace, settings } = useWorkspace();
|
||||
const { handleCommitAndPush } = useGitOperations();
|
||||
|
||||
const autoCommit = useCallback(
|
||||
async (filePath, action) => {
|
||||
if (settings.gitAutoCommit && settings.gitEnabled) {
|
||||
let commitMessage = settings.gitCommitMsgTemplate
|
||||
.replace('${filename}', filePath)
|
||||
.replace('${action}', action);
|
||||
|
||||
commitMessage =
|
||||
commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
|
||||
|
||||
await handleCommitAndPush(commitMessage);
|
||||
}
|
||||
},
|
||||
[settings]
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (filePath, content) => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
try {
|
||||
await saveFileContent(filePath, content);
|
||||
await saveFileContent(currentWorkspace.id, filePath, content);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File saved successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(filePath, 'update');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
@@ -21,16 +46,22 @@ export const useFileOperations = () => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
const handleDelete = useCallback(async (filePath) => {
|
||||
try {
|
||||
await deleteFile(filePath);
|
||||
await deleteFile(currentWorkspace.id, filePath);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(filePath, 'delete');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
@@ -41,16 +72,22 @@ export const useFileOperations = () => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (fileName, initialContent = '') => {
|
||||
if (!currentWorkspace) return false;
|
||||
|
||||
const handleCreate = useCallback(async (fileName, initialContent = '') => {
|
||||
try {
|
||||
await saveFileContent(fileName, initialContent);
|
||||
await saveFileContent(currentWorkspace.id, fileName, initialContent);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'File created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
autoCommit(fileName, 'create');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating new file:', error);
|
||||
@@ -61,7 +98,9 @@ export const useFileOperations = () => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[currentWorkspace, autoCommit]
|
||||
);
|
||||
|
||||
return { handleSave, handleDelete, handleCreate };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { pullChanges, commitAndPush } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useGitOperations = () => {
|
||||
const { currentWorkspace, settings } = useWorkspace();
|
||||
|
||||
export const useGitOperations = (gitEnabled) => {
|
||||
const handlePull = useCallback(async () => {
|
||||
if (!gitEnabled) return false;
|
||||
if (!currentWorkspace || !settings.gitEnabled) return false;
|
||||
|
||||
try {
|
||||
await pullChanges();
|
||||
await pullChanges(currentWorkspace.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully pulled latest changes',
|
||||
@@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [gitEnabled]);
|
||||
}, [currentWorkspace, settings.gitEnabled]);
|
||||
|
||||
const handleCommitAndPush = useCallback(
|
||||
async (message) => {
|
||||
if (!gitEnabled) return false;
|
||||
if (!currentWorkspace || !settings.gitEnabled) return false;
|
||||
|
||||
try {
|
||||
await commitAndPush(message);
|
||||
await commitAndPush(currentWorkspace.id, message);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Successfully committed and pushed changes',
|
||||
@@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[gitEnabled]
|
||||
[currentWorkspace, settings.gitEnabled]
|
||||
);
|
||||
|
||||
return { handlePull, handleCommitAndPush };
|
||||
|
||||
37
frontend/src/hooks/useLastOpenedFile.js
Normal file
37
frontend/src/hooks/useLastOpenedFile.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import { getLastOpenedFile, updateLastOpenedFile } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
|
||||
export const useLastOpenedFile = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const loadLastOpenedFile = useCallback(async () => {
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
try {
|
||||
const response = await getLastOpenedFile(currentWorkspace.id);
|
||||
return response.lastOpenedFilePath || null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load last opened file:', error);
|
||||
return null;
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
const saveLastOpenedFile = useCallback(
|
||||
async (filePath) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
||||
try {
|
||||
await updateLastOpenedFile(currentWorkspace.id, filePath);
|
||||
} catch (error) {
|
||||
console.error('Failed to save last opened file:', error);
|
||||
}
|
||||
},
|
||||
[currentWorkspace]
|
||||
);
|
||||
|
||||
return {
|
||||
loadLastOpenedFile,
|
||||
saveLastOpenedFile,
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NovaMD</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,76 +16,163 @@ const apiCall = async (url, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchFileList = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files`);
|
||||
export const fetchLastWorkspaceId = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchFileContent = async (filePath) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`);
|
||||
export const fetchFileList = async (workspaceId) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files`
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchFileContent = async (workspaceId, filePath) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`
|
||||
);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const saveFileContent = async (filePath, content) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
|
||||
export const saveFileContent = async (workspaceId, filePath, content) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
}
|
||||
);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const deleteFile = async (filePath) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`, {
|
||||
export const deleteFile = async (workspaceId, filePath) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const fetchUserSettings = async (userId) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/settings?userId=${userId}`);
|
||||
export const getWorkspace = async (workspaceId) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const saveUserSettings = async (settings) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/settings`, {
|
||||
method: 'POST',
|
||||
// Combined function to update workspace data including settings
|
||||
export const updateWorkspace = async (workspaceId, workspaceData) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
body: JSON.stringify(workspaceData),
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const pullChanges = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/git/pull`, {
|
||||
export const pullChanges = async (workspaceId) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/pull`,
|
||||
{
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const commitAndPush = async (message) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/git/commit`, {
|
||||
export const commitAndPush = async (workspaceId, message) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/git/commit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getFileUrl = (filePath) => {
|
||||
return `${API_BASE_URL}/files/${filePath}`;
|
||||
export const getFileUrl = (workspaceId, filePath) => {
|
||||
return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`;
|
||||
};
|
||||
|
||||
export const lookupFileByName = async (filename) => {
|
||||
export const lookupFileByName = async (workspaceId, filename) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}`
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent(
|
||||
filename
|
||||
)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.paths;
|
||||
};
|
||||
|
||||
export const updateLastOpenedFile = async (workspaceId, filePath) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filePath }),
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getLastOpenedFile = async (workspaceId) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/last`
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const listWorkspaces = async () => {
|
||||
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createWorkspace = async (name) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteWorkspace = async (workspaceId) => {
|
||||
const response = await apiCall(
|
||||
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateLastWorkspace = async (workspaceId) => {
|
||||
const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
return response.json();
|
||||
};
|
||||
|
||||
@@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [
|
||||
'.svg',
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
// Renamed from DEFAULT_SETTINGS to be more specific
|
||||
export const DEFAULT_WORKSPACE_SETTINGS = {
|
||||
theme: THEMES.LIGHT,
|
||||
autoSave: false,
|
||||
gitEnabled: false,
|
||||
@@ -34,7 +35,13 @@ export const DEFAULT_SETTINGS = {
|
||||
gitUser: '',
|
||||
gitToken: '',
|
||||
gitAutoCommit: false,
|
||||
gitCommitMsgTemplate: 'Update ${filename}',
|
||||
gitCommitMsgTemplate: '${action} ${filename}',
|
||||
};
|
||||
|
||||
// Template for creating new workspaces
|
||||
export const DEFAULT_WORKSPACE = {
|
||||
name: '',
|
||||
...DEFAULT_WORKSPACE_SETTINGS,
|
||||
};
|
||||
|
||||
export const DEFAULT_FILE = {
|
||||
|
||||
136
frontend/vite.config.js
Normal file
136
frontend/vite.config.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import postcssPresetMantine from 'postcss-preset-mantine';
|
||||
import postcssSimpleVars from 'postcss-simple-vars';
|
||||
import { compression } from 'vite-plugin-compression2';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
react({
|
||||
include: ['**/*.jsx', '**/*.js'],
|
||||
}),
|
||||
compression(),
|
||||
],
|
||||
|
||||
root: 'src',
|
||||
publicDir: '../public',
|
||||
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
sourcemap: mode === 'development',
|
||||
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'src/index.html'),
|
||||
},
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React core libraries
|
||||
'react-core': ['react', 'react-dom'],
|
||||
|
||||
// Mantine UI components and related
|
||||
mantine: [
|
||||
'@mantine/core',
|
||||
'@mantine/hooks',
|
||||
'@mantine/modals',
|
||||
'@mantine/notifications',
|
||||
],
|
||||
|
||||
// Editor related packages
|
||||
editor: [
|
||||
'codemirror',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/lang-markdown',
|
||||
'@codemirror/state',
|
||||
'@codemirror/theme-one-dark',
|
||||
'@codemirror/view',
|
||||
],
|
||||
|
||||
// Markdown processing
|
||||
markdown: [
|
||||
'react-markdown',
|
||||
'react-syntax-highlighter',
|
||||
'rehype-katex',
|
||||
'remark-math',
|
||||
'katex',
|
||||
],
|
||||
|
||||
// Icons and utilities
|
||||
utils: [
|
||||
'@tabler/icons-react',
|
||||
'@react-hook/resize-observer',
|
||||
'react-arborist',
|
||||
],
|
||||
},
|
||||
// Optimize chunk naming for better caching
|
||||
chunkFileNames: (chunkInfo) => {
|
||||
const name = chunkInfo.name;
|
||||
if (name === 'react-core') return 'assets/react.[hash].js';
|
||||
if (name === 'mantine') return 'assets/mantine.[hash].js';
|
||||
if (name === 'editor') return 'assets/editor.[hash].js';
|
||||
if (name === 'markdown') return 'assets/markdown.[hash].js';
|
||||
if (name === 'utils') return 'assets/utils.[hash].js';
|
||||
return 'assets/[name].[hash].js';
|
||||
},
|
||||
// Optimize asset naming
|
||||
assetFileNames: 'assets/[name].[hash][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
|
||||
define: {
|
||||
'window.API_BASE_URL': JSON.stringify(
|
||||
mode === 'production' ? '/api/v1' : 'http://localhost:8080/api/v1'
|
||||
),
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern',
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
plugins: [
|
||||
postcssPresetMantine(),
|
||||
postcssSimpleVars({
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
extensions: ['.js', '.jsx', '.json'],
|
||||
},
|
||||
|
||||
// Add performance optimization options
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'@mantine/core',
|
||||
'@mantine/hooks',
|
||||
'codemirror',
|
||||
'react-markdown',
|
||||
],
|
||||
},
|
||||
}));
|
||||
@@ -1,49 +0,0 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProduction = argv.mode === 'production';
|
||||
|
||||
return {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: ['babel-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/index.html',
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'window.API_BASE_URL': JSON.stringify(
|
||||
isProduction ? '/api/v1' : 'http://localhost:8080/api/v1'
|
||||
),
|
||||
}),
|
||||
],
|
||||
devServer: {
|
||||
static: {
|
||||
directory: path.join(__dirname, 'public'),
|
||||
},
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user