60 Commits

Author SHA1 Message Date
a5aa2dd45b Merge pull request #11 from LordMathis/feat/encrypt-tokens
Feat/encrypt tokens
2024-10-31 21:24:40 +01:00
43f35b7943 Update README 2024-10-31 21:19:47 +01:00
f8728923a5 Encrypt git token 2024-10-31 21:15:38 +01:00
72817aa06a Merge pull request #10 from LordMathis/feat/central-config
Centralize env vars loading
2024-10-30 22:13:02 +01:00
a11a6b18bf Centralize env vars loading 2024-10-30 22:07:17 +01:00
0fe1186ea6 Merge pull request #9 from LordMathis/feat/last-opened-file
Feat/last opened file
2024-10-30 21:55:23 +01:00
477ada70e8 Fix admin user error check 2024-10-30 21:47:37 +01:00
a73296451e Load last opened file on frontend 2024-10-30 21:43:37 +01:00
bef682e5cb Move last opened file to workspaces table 2024-10-30 21:37:07 +01:00
1086c402ec Merge pull request #8 from LordMathis/fix/file-list
Fix/file list
2024-10-29 22:18:28 +01:00
a62a6f8966 Fix workspace initialization 2024-10-29 22:01:13 +01:00
3ba9d57b11 Select default files when no files listed 2024-10-29 21:46:59 +01:00
91f0ebe99c Recursively sort filelist 2024-10-29 21:38:36 +01:00
ad3fa28bc7 Show file list on load 2024-10-29 21:38:15 +01:00
1695b411e1 Unify env var reading in backend 2024-10-29 20:41:11 +01:00
87a2f12766 Merge pull request #7 from LordMathis/feat/vite
Feat/vite
2024-10-28 21:51:12 +01:00
d82f7e2b1e Serve precompressed files 2024-10-28 21:45:11 +01:00
d417b9f0c7 Set up compression 2024-10-28 21:29:38 +01:00
64a86152d6 Split bundle to chunks 2024-10-28 21:11:52 +01:00
69506f739c Update sass api 2024-10-28 20:43:23 +01:00
012465fb02 Rename files to .jsx 2024-10-28 20:36:04 +01:00
552016f61d Set up vite configuration 2024-10-28 20:32:42 +01:00
f1b5027e7f Merge pull request #6 from LordMathis/feat/workspaces
Feat/workspaces
2024-10-27 21:40:35 +01:00
239b441aa6 Improve Settings modal 2024-10-27 21:34:59 +01:00
ba4a0dadca Implement workspace deletion 2024-10-27 21:19:42 +01:00
b679af08e7 Enable workspace renaming 2024-10-27 18:15:11 +01:00
c5e0937070 Add rename and delete workspace ui elements 2024-10-27 17:39:56 +01:00
17c03c2d14 Update Workspace Settings on frontend 2024-10-27 16:53:15 +01:00
4544af8f0f Combine settings and workspaces tables 2024-10-27 16:00:06 +01:00
ab7b018f88 Update WorkspaceSwitcher ui 2024-10-27 14:50:21 +01:00
eaad78730e Save default settings with workspace creation 2024-10-27 12:44:01 +01:00
bbd7358d15 Add CreateWorkspaceModal 2024-10-26 23:29:33 +02:00
12312137b7 Implement workspace switching 2024-10-26 23:15:23 +02:00
fd313c1d7f Add Workspace menu 2024-10-22 21:47:10 +02:00
05dd3a83b0 Fix settings saving 2024-10-22 21:21:18 +02:00
a231fc48c2 Remove debug print 2024-10-22 18:32:46 +02:00
4ade504b5b Run go mod tidy 2024-10-22 18:32:33 +02:00
1c59f8da4f Fix file list loading 2024-10-22 00:11:03 +02:00
ffe82b335a Get full workspace object 2024-10-21 23:48:50 +02:00
749461f11b Fix router 2024-10-19 14:10:49 +02:00
2f87d87833 Fix admin user creation 2024-10-19 13:49:11 +02:00
6eb3eecb24 Add Workspace context 2024-10-19 13:48:37 +02:00
3b7bf83073 Set up admin user 2024-10-19 12:07:58 +02:00
1df4952300 Create workspace on user create 2024-10-19 12:07:45 +02:00
a24f0d637c Fix chi error 2024-10-15 22:33:59 +02:00
68ec134bf5 Fix migration error 2024-10-15 22:33:50 +02:00
403ded509a Fix main 2024-10-15 22:23:09 +02:00
6cf141bfd9 Rework api 2024-10-15 22:17:34 +02:00
071619cfb3 Rework db schema 2024-10-15 21:05:28 +02:00
d440ac0fd7 Update handlers to pass user id 2024-10-14 22:43:00 +02:00
4953138154 Fix filesystem paths 2024-10-14 22:36:37 +02:00
cbdf51db05 Fix filesystem 2024-10-14 22:06:38 +02:00
18bc50f5b4 Update handlers for workspaces 2024-10-14 22:01:17 +02:00
97ebf1c08e Update fs for workspaces 2024-10-14 21:26:31 +02:00
2d2b596f2c Add db support for workspaces 2024-10-14 21:08:37 +02:00
b36c5b30c6 Merge pull request #5 from LordMathis/fix/autocommit-on-delete
Fix/autocommit on delete
2024-10-13 17:16:08 +02:00
903393953a Dont await autocommit 2024-10-13 16:58:54 +02:00
b6e8a93d86 Fix file list reload after delete 2024-10-13 16:13:47 +02:00
da5a3ebeee Remove autocommit from backend 2024-10-13 16:00:17 +02:00
1341881968 Enable autocommit for delete and create 2024-10-13 15:02:31 +02:00
68 changed files with 4294 additions and 7194 deletions

3
.gitignore vendored
View File

@@ -129,6 +129,9 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
# Vite
.vite
##### Go ##### ##### Go #####

View File

@@ -18,7 +18,8 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": "explicit" "source.organizeImports": "explicit"
} },
"editor.defaultFormatter": "golang.go"
}, },
"gopls": { "gopls": {
"usePlaceholders": true, "usePlaceholders": true,

View File

@@ -8,6 +8,7 @@ Yet another markdown editor. Work in progress
- File tree navigation - File tree navigation
- Git integration for version control - Git integration for version control
- Dark and light theme support - Dark and light theme support
- Multiple workspaces
- Math equation support (KaTeX) - Math equation support (KaTeX)
- Code syntax highlighting - Code syntax highlighting
@@ -17,15 +18,29 @@ Yet another markdown editor. Work in progress
- Node.js 20 or later - Node.js 20 or later
- gcc (for go-sqlite3 package) - gcc (for go-sqlite3 package)
## Setup
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 ## Running the Backend
1. Navigate to the `backend` directory 1. Navigate to the `backend` directory
2. Set the following environment variables: 2. Ensure all environment variables are set
- `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")
3. Run the server: 3. Run the server:
``` ```
go run cmd/server/main.go go run cmd/server/main.go

View File

@@ -4,23 +4,27 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"novamd/internal/api" "novamd/internal/api"
"novamd/internal/config"
"novamd/internal/db" "novamd/internal/db"
"novamd/internal/filesystem" "novamd/internal/filesystem"
"novamd/internal/user"
) )
func main() { func main() {
// Initialize database
dbPath := os.Getenv("NOVAMD_DB_PATH") // Load configuration
if dbPath == "" { cfg, err := config.Load()
dbPath = "./sqlite.db" 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -30,22 +34,15 @@ func main() {
} }
}() }()
// Workdir // Initialize filesystem
workdir := os.Getenv("NOVAMD_WORKDIR") fs := filesystem.New(cfg.WorkDir)
if workdir == "" {
workdir = "./data"
}
settings, err := database.GetSettings(1) // Assuming user ID 1 for now // Initialize user service
if err != nil { userService := user.NewUserService(database, fs)
log.Print("Settings not found, using default settings")
}
fs := filesystem.New(workdir, &settings)
if settings.Settings.GitEnabled { // Create admin user
if err := fs.InitializeGitRepo(); err != nil { if _, err := userService.SetupAdminUser(cfg.AdminEmail, cfg.AdminPassword); err != nil {
log.Fatal(err) log.Fatal(err)
}
} }
// Set up router // Set up router
@@ -53,38 +50,20 @@ func main() {
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
// Set up API routes // API routes
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
api.SetupRoutes(r, database, fs) api.SetupRoutes(r, database, fs)
}) })
// Set up static file server with path validation // Handle all other routes with static file server
staticPath := os.Getenv("NOVAMD_STATIC_PATH") r.Get("/*", api.NewStaticHandler(cfg.StaticPath).ServeHTTP)
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)
})
// Start server // Start server
port := os.Getenv("NOVAMD_PORT") port := os.Getenv("NOVAMD_PORT")
if port == "" { if port == "" {
port = "8080" port = "8080"
} }
log.Printf("Server starting on port %s", port) log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, r)) log.Fatal(http.ListenAndServe(":"+port, r))
} }

View File

@@ -7,6 +7,7 @@ require (
github.com/go-git/go-git/v5 v5.12.0 github.com/go-git/go-git/v5 v5.12.0
github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/validator/v10 v10.22.1
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.23
golang.org/x/crypto v0.21.0
) )
require ( require (
@@ -29,7 +30,6 @@ require (
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect github.com/skeema/knownhosts v1.2.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // 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/mod v0.12.0 // indirect
golang.org/x/net v0.23.0 // indirect golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect

View 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"})
}
}

View 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"})
}
}

View 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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -9,20 +9,42 @@ import (
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) {
r.Route("/", func(r chi.Router) { r.Route("/", func(r chi.Router) {
r.Route("/settings", func(r chi.Router) { // User routes
r.Get("/", GetSettings(db)) r.Route("/users/{userId}", func(r chi.Router) {
r.Post("/", UpdateSettings(db, fs)) r.Get("/", GetUser(db))
})
r.Route("/files", func(r chi.Router) { // Workspace routes
r.Get("/", ListFiles(fs)) r.Route("/workspaces", func(r chi.Router) {
r.Get("/*", GetFileContent(fs)) r.Get("/", ListWorkspaces(db))
r.Post("/*", SaveFile(fs)) r.Post("/", CreateWorkspace(db, fs))
r.Delete("/*", DeleteFile(fs)) r.Get("/last", GetLastWorkspace(db))
r.Get("/lookup", LookupFileByName(fs)) r.Put("/last", UpdateLastWorkspace(db))
})
r.Route("/git", func(r chi.Router) { r.Route("/{workspaceId}", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs)) r.Get("/", GetWorkspace(db))
r.Post("/pull", PullChanges(fs)) r.Put("/", UpdateWorkspace(db, fs))
r.Delete("/", DeleteWorkspace(db))
// File routes
r.Route("/files", func(r chi.Router) {
r.Get("/", ListFiles(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))
})
// Git routes
r.Route("/git", func(r chi.Router) {
r.Post("/commit", StageCommitAndPush(fs))
r.Post("/pull", PullChanges(fs))
})
})
})
}) })
}) })
} }

View 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)
}

View 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)
}
}

View 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"})
}
}

View 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)
}

View 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
}

View File

@@ -2,15 +2,19 @@ package db
import ( import (
"database/sql" "database/sql"
"fmt"
"novamd/internal/crypto"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type DB struct { type DB struct {
*sql.DB *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) db, err := sql.Open("sqlite3", dbPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -20,7 +24,17 @@ func Init(dbPath string) (*DB, error) {
return nil, err 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 { if err := database.Migrate(); err != nil {
return nil, err return nil, err
} }
@@ -31,3 +45,18 @@ func Init(dbPath string) (*DB, error) {
func (db *DB) Close() error { func (db *DB) Close() error {
return db.DB.Close() 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)
}

View File

@@ -13,10 +13,37 @@ type Migration struct {
var migrations = []Migration{ var migrations = []Migration{
{ {
Version: 1, Version: 1,
SQL: `CREATE TABLE IF NOT EXISTS settings ( SQL: `
user_id INTEGER PRIMARY KEY, -- Create users table
settings JSON NOT NULL 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)
);
`,
}, },
} }

View File

@@ -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
}

View 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
}

View 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
}

View File

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"novamd/internal/gitutils" "novamd/internal/gitutils"
"novamd/internal/models"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -13,8 +12,7 @@ import (
type FileSystem struct { type FileSystem struct {
RootDir string RootDir string
GitRepo *gitutils.GitRepo GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
Settings *models.Settings
} }
type FileNode struct { type FileNode struct {
@@ -24,67 +22,52 @@ type FileNode struct {
Children []FileNode `json:"children,omitempty"` Children []FileNode `json:"children,omitempty"`
} }
func New(rootDir string, settings *models.Settings) *FileSystem { func New(rootDir string) *FileSystem {
fs := &FileSystem{ return &FileSystem{
RootDir: rootDir, RootDir: rootDir,
Settings: settings, GitRepos: make(map[int]map[int]*gitutils.GitRepo),
}
}
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string {
return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
}
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)
} }
if settings.Settings.GitEnabled { return nil
fs.GitRepo = gitutils.New( }
settings.Settings.GitURL,
settings.Settings.GitUser, func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error {
settings.Settings.GitToken, workspacePath := fs.GetWorkspacePath(userID, workspaceID)
rootDir, err := os.RemoveAll(workspacePath)
) if err != nil {
return fmt.Errorf("failed to delete workspace directory: %w", err)
} }
return fs return nil
} }
func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error { func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir) workspacePath := fs.GetWorkspacePath(userID, workspaceID)
return fs.InitializeGitRepo() fullPath := filepath.Join(workspacePath, path)
}
func (fs *FileSystem) DisableGitRepo() {
fs.GitRepo = nil;
}
func (fs *FileSystem) InitializeGitRepo() error {
if fs.GitRepo == nil {
return errors.New("git settings not configured")
}
return fs.GitRepo.EnsureRepo()
}
func ValidatePath(rootDir, path string) (string, error) {
fullPath := filepath.Join(rootDir, path)
cleanPath := filepath.Clean(fullPath) cleanPath := filepath.Clean(fullPath)
if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) { if !strings.HasPrefix(cleanPath, workspacePath) {
return "", fmt.Errorf("invalid path: outside of root directory") return "", fmt.Errorf("invalid path: outside of workspace")
}
relPath, err := filepath.Rel(rootDir, cleanPath)
if err != nil {
return "", err
}
if strings.HasPrefix(relPath, "..") {
return "", fmt.Errorf("invalid path: outside of root directory")
} }
return cleanPath, nil return cleanPath, nil
} }
func (fs *FileSystem) validatePath(path string) (string, error) { func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
return ValidatePath(fs.RootDir, path) workspacePath := fs.GetWorkspacePath(userID, workspaceID)
} return fs.walkDirectory(workspacePath, "")
func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) {
return fs.walkDirectory(fs.RootDir, "")
} }
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { 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 return nil, err
} }
var folders []FileNode // Split entries into directories and files
var files []FileNode var dirs, files []os.DirEntry
for _, entry := range entries { 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() name := entry.Name()
path := filepath.Join(prefix, name) path := filepath.Join(prefix, name)
fullPath := filepath.Join(dir, name) fullPath := filepath.Join(dir, name)
if entry.IsDir() { children, err := fs.walkDirectory(fullPath, path)
children, err := fs.walkDirectory(fullPath, path) if err != nil {
if err != nil { return nil, err
return nil, err
}
folders = append(folders, FileNode{
ID: path, // Using path as ID ensures uniqueness
Name: name,
Path: path,
Children: children,
})
} else {
files = append(files, FileNode{
ID: path, // Using path as ID ensures uniqueness
Name: name,
Path: path,
})
} }
node := FileNode{
ID: path,
Name: name,
Path: path,
Children: children,
}
nodes = append(nodes, node)
} }
// Sort folders and files alphabetically // Then add files
sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) for _, entry := range files {
sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name }) name := entry.Name()
path := filepath.Join(prefix, name)
// Combine folders and files, with folders first node := FileNode{
return append(folders, files...), nil ID: path,
Name: name,
Path: path,
}
nodes = append(nodes, node)
}
return nodes, nil
} }
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
var foundPaths []string var foundPaths []string
var searchPattern string workspacePath := fs.GetWorkspacePath(userID, workspaceID)
// If no extension is provided, assume .md err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
if !strings.Contains(filenameOrPath, ".") {
searchPattern = filenameOrPath + ".md"
} else {
searchPattern = filenameOrPath
}
err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if !info.IsDir() { if !info.IsDir() {
relPath, err := filepath.Rel(fs.RootDir, path) relPath, err := filepath.Rel(workspacePath, path)
if err != nil { if err != nil {
return err return err
} }
if strings.EqualFold(info.Name(), filename) {
// Check if the file matches the search pattern
if strings.HasSuffix(relPath, searchPattern) ||
strings.EqualFold(info.Name(), searchPattern) {
foundPaths = append(foundPaths, relPath) foundPaths = append(foundPaths, relPath)
} }
} }
@@ -171,16 +164,16 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) {
return foundPaths, nil return foundPaths, nil
} }
func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return os.ReadFile(fullPath) return os.ReadFile(fullPath)
} }
func (fs *FileSystem) SaveFile(filePath string, content []byte) error { func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
} }
@@ -190,38 +183,67 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error {
return err 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) return os.WriteFile(fullPath, content, 0644)
} }
func (fs *FileSystem) DeleteFile(filePath string) error { func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
fullPath, err := fs.validatePath(filePath) fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
if err != nil { if err != nil {
return err return err
} }
return os.Remove(fullPath) return os.Remove(fullPath)
} }
func (fs *FileSystem) StageCommitAndPush(message string) error { func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
if fs.GitRepo == nil { dir := fs.GetWorkspacePath(userID, workspaceID)
return errors.New("git settings not configured") return os.MkdirAll(dir, 0755)
}
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 := fs.GitRepo.Commit(message); err != nil { if err := repo.Commit(message); err != nil {
return err return err
} }
return fs.GitRepo.Push() return repo.Push()
} }
func (fs *FileSystem) Pull() error { func (fs *FileSystem) Pull(userID, workspaceID int) error {
if fs.GitRepo == nil { repo, ok := fs.getGitRepo(userID, workspaceID)
return errors.New("git settings not configured") 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
} }

View File

@@ -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()
}

View 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)
}

View 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}"
}

View 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)
}

View File

@@ -1,10 +0,0 @@
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
],
"plugins": [
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-runtime"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,11 @@
"name": "novamd-frontend", "name": "novamd-frontend",
"version": "0.1.0", "version": "0.1.0",
"description": "Yet another markdown editor", "description": "Yet another markdown editor",
"main": "index.js", "type": "module",
"scripts": { "scripts": {
"start": "webpack serve --mode development --open", "start": "vite",
"build": "webpack --mode production" "build": "vite build",
"preview": "vite preview"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -43,11 +44,16 @@
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-math": "^6.0.0" "remark-math": "^6.0.0"
}, },
"eslintConfig": { "devDependencies": {
"extends": [ "@types/react": "^18.2.67",
"react-app", "@types/react-dom": "^18.2.22",
"react-app/jest" "@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": { "browserslist": {
"production": [ "production": [
@@ -60,26 +66,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari 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"
} }
} }

View File

@@ -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',
},
},
},
};

View File

@@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import Layout from './components/Layout'; import Layout from './components/Layout';
import { SettingsProvider, useSettings } from './contexts/SettingsContext'; import { WorkspaceProvider } from './contexts/WorkspaceContext';
import { ModalProvider } from './contexts/ModalContext'; import { ModalProvider } from './contexts/ModalContext';
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import './App.scss'; import './App.scss';
function AppContent() { function AppContent() {
const { loading } = useSettings();
if (loading) {
return <div>Loading...</div>;
}
return <Layout />; return <Layout />;
} }
@@ -26,11 +20,11 @@ function App() {
<MantineProvider defaultColorScheme="light"> <MantineProvider defaultColorScheme="light">
<Notifications /> <Notifications />
<ModalsProvider> <ModalsProvider>
<SettingsProvider> <WorkspaceProvider>
<ModalProvider> <ModalProvider>
<AppContent /> <AppContent />
</ModalProvider> </ModalProvider>
</SettingsProvider> </WorkspaceProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
</> </>

View File

@@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
const { settings } = useSettings(); const { colorScheme } = useWorkspace();
const editorRef = useRef(); const editorRef = useRef();
const viewRef = useRef(); const viewRef = useRef();
@@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
overflow: 'auto', overflow: 'auto',
}, },
'.cm-gutters': { '.cm-gutters': {
backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5', backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5',
color: settings.theme === 'dark' ? '#858585' : '#999', color: colorScheme === 'dark' ? '#858585' : '#999',
border: 'none', border: 'none',
}, },
'.cm-activeLineGutter': { '.cm-activeLineGutter': {
backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8', backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8',
}, },
}); });
@@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
} }
}), }),
theme, theme,
settings.theme === 'dark' ? oneDark : [], colorScheme === 'dark' ? oneDark : [],
], ],
}); });
@@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => { return () => {
view.destroy(); view.destroy();
}; };
}, [settings.theme, handleContentChange]); }, [colorScheme, handleContentChange]);
useEffect(() => { useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) { if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

@@ -6,11 +6,11 @@ import {
IconGitPullRequest, IconGitPullRequest,
IconGitCommit, IconGitCommit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useSettings } from '../contexts/SettingsContext';
import { useModalContext } from '../contexts/ModalContext'; import { useModalContext } from '../contexts/ModalContext';
import { useWorkspace } from '../contexts/WorkspaceContext';
const FileActions = ({ handlePullChanges, selectedFile }) => { const FileActions = ({ handlePullChanges, selectedFile }) => {
const { settings } = useSettings(); const { settings } = useWorkspace();
const { const {
setNewFileModalVisible, setNewFileModalVisible,
setDeleteFileModalVisible, setDeleteFileModalVisible,

View File

@@ -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;

View 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;

View File

@@ -1,13 +1,29 @@
import React from 'react'; import React from 'react';
import { AppShell, Container } from '@mantine/core'; import { AppShell, Container, Loader, Center } from '@mantine/core';
import Header from './Header'; import Header from './Header';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import MainContent from './MainContent'; import MainContent from './MainContent';
import { useFileNavigation } from '../hooks/useFileNavigation'; import { useFileNavigation } from '../hooks/useFileNavigation';
import { useFileList } from '../hooks/useFileList';
import { useWorkspace } from '../contexts/WorkspaceContext';
const Layout = () => { const Layout = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect, handleLinkClick } = const { selectedFile, handleFileSelect, handleLinkClick } =
useFileNavigation(); 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 ( return (
<AppShell header={{ height: 60 }} padding="md"> <AppShell header={{ height: 60 }} padding="md">
@@ -27,11 +43,14 @@ const Layout = () => {
<Sidebar <Sidebar
selectedFile={selectedFile} selectedFile={selectedFile}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
files={files}
loadFileList={loadFileList}
/> />
<MainContent <MainContent
selectedFile={selectedFile} selectedFile={selectedFile}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
handleLinkClick={handleLinkClick} handleLinkClick={handleLinkClick}
loadFileList={loadFileList}
/> />
</Container> </Container>
</AppShell.Main> </AppShell.Main>

View File

@@ -10,11 +10,16 @@ import CommitMessageModal from './modals/CommitMessageModal';
import { useFileContent } from '../hooks/useFileContent'; import { useFileContent } from '../hooks/useFileContent';
import { useFileOperations } from '../hooks/useFileOperations'; import { useFileOperations } from '../hooks/useFileOperations';
import { useGitOperations } from '../hooks/useGitOperations'; 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 [activeTab, setActiveTab] = useState('source');
const { settings } = useSettings(); const { settings } = useWorkspace();
const { const {
content, content,
hasUnsavedChanges, hasUnsavedChanges,
@@ -33,38 +38,32 @@ const MainContent = ({ selectedFile, handleFileSelect, handleLinkClick }) => {
let success = await handleSave(filePath, content); let success = await handleSave(filePath, content);
if (success) { if (success) {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
if (settings.gitAutoCommit && settings.gitEnabled) {
const commitMessage = settings.gitCommitMsgTemplate.replace(
'${filename}',
filePath
);
success = await handleCommitAndPush(commitMessage);
}
} }
return success; return success;
}, },
[handleSave, setHasUnsavedChanges, settings, handleCommitAndPush] [handleSave, setHasUnsavedChanges]
); );
const handleCreateFile = useCallback( const handleCreateFile = useCallback(
async (fileName) => { async (fileName) => {
const success = await handleCreate(fileName); const success = await handleCreate(fileName);
if (success) { if (success) {
loadFileList();
handleFileSelect(fileName); handleFileSelect(fileName);
} }
}, },
[handleCreate, handleFileSelect] [handleCreate, handleFileSelect, loadFileList]
); );
const handleDeleteFile = useCallback( const handleDeleteFile = useCallback(
async (filePath) => { async (filePath) => {
const success = await handleDelete(filePath); const success = await handleDelete(filePath);
if (success) { if (success) {
loadFileList();
handleFileSelect(null); handleFileSelect(null);
} }
}, },
[handleDelete, handleFileSelect] [handleDelete, handleFileSelect, loadFileList]
); );
const renderBreadcrumbs = useMemo(() => { const renderBreadcrumbs = useMemo(() => {

View File

@@ -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;

View 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;

View File

@@ -2,18 +2,16 @@ import React, { useEffect } from 'react';
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
import FileActions from './FileActions'; import FileActions from './FileActions';
import FileTree from './FileTree'; import FileTree from './FileTree';
import { useFileList } from '../hooks/useFileList';
import { useGitOperations } from '../hooks/useGitOperations'; import { useGitOperations } from '../hooks/useGitOperations';
import { useSettings } from '../contexts/SettingsContext'; import { useWorkspace } from '../contexts/WorkspaceContext';
const Sidebar = ({ selectedFile, handleFileSelect }) => { const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
const { settings } = useSettings(); const { settings } = useWorkspace();
const { files, loadFileList } = useFileList();
const { handlePull } = useGitOperations(settings.gitEnabled); const { handlePull } = useGitOperations(settings.gitEnabled);
useEffect(() => { useEffect(() => {
loadFileList(); loadFileList();
}, [settings.gitEnabled, loadFileList]); }, [loadFileList]);
return ( return (
<Box <Box

View 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;

View 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;

View 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;

View File

@@ -1,20 +1,18 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core'; import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useSettings } from '../../contexts/SettingsContext'; import { useWorkspace } from '../../contexts/WorkspaceContext';
const AppearanceSettings = ({ onThemeChange }) => { const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, toggleColorScheme } = useSettings(); const { colorScheme, updateColorScheme } = useWorkspace();
const handleThemeChange = () => { const handleThemeChange = () => {
toggleColorScheme(); const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
onThemeChange(colorScheme === 'dark' ? 'light' : 'dark'); updateColorScheme(newTheme);
onThemeChange(newTheme);
}; };
return ( return (
<Box mb="md"> <Box mb="md">
<Title order={3} mb="md">
Appearance
</Title>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text> <Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} /> <Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />

View 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;

View File

@@ -4,9 +4,6 @@ import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core';
const EditorSettings = ({ autoSave, onAutoSaveChange }) => { const EditorSettings = ({ autoSave, onAutoSaveChange }) => {
return ( return (
<Box mb="md"> <Box mb="md">
<Title order={3} mb="md">
Editor
</Title>
<Tooltip label="Auto Save feature is coming soon!" position="left"> <Tooltip label="Auto Save feature is coming soon!" position="left">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="sm">Auto Save</Text> <Text size="sm">Auto Save</Text>

View 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;

View File

@@ -21,7 +21,6 @@ const GitSettings = ({
}) => { }) => {
return ( return (
<Stack spacing="md"> <Stack spacing="md">
<Title order={3}>Git Integration</Title>
<Grid gutter="md" align="center"> <Grid gutter="md" align="center">
<Grid.Col span={6}> <Grid.Col span={6}>
<Text size="sm">Enable Git</Text> <Text size="sm">Enable Git</Text>

View File

@@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => {
const [commitMessageModalVisible, setCommitMessageModalVisible] = const [commitMessageModalVisible, setCommitMessageModalVisible] =
useState(false); useState(false);
const [settingsModalVisible, setSettingsModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false);
const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] =
useState(false);
const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] =
useState(false);
const value = { const value = {
newFileModalVisible, newFileModalVisible,
@@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => {
setCommitMessageModalVisible, setCommitMessageModalVisible,
settingsModalVisible, settingsModalVisible,
setSettingsModalVisible, setSettingsModalVisible,
switchWorkspaceModalVisible,
setSwitchWorkspaceModalVisible,
createWorkspaceModalVisible,
setCreateWorkspaceModalVisible,
}; };
return ( return (

View File

@@ -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>
);
};

View 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;
};

View File

@@ -2,38 +2,45 @@ import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api'; import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers'; import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants'; import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileContent = (selectedFile) => { export const useFileContent = (selectedFile) => {
const { currentWorkspace } = useWorkspace();
const [content, setContent] = useState(DEFAULT_FILE.content); const [content, setContent] = useState(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const loadFileContent = useCallback(async (filePath) => { const loadFileContent = useCallback(
try { async (filePath) => {
let newContent; if (!currentWorkspace) return;
if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content; try {
} else if (!isImageFile(filePath)) { let newContent;
newContent = await fetchFileContent(filePath); if (filePath === DEFAULT_FILE.path) {
} else { newContent = DEFAULT_FILE.content;
newContent = ''; // Set empty content for image files } else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.id, filePath);
} else {
newContent = ''; // Set empty content for image files
}
setContent(newContent);
setOriginalContent(newContent);
setHasUnsavedChanges(false);
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
} }
setContent(newContent); },
setOriginalContent(newContent); [currentWorkspace]
setHasUnsavedChanges(false); );
} catch (err) {
console.error('Error loading file content:', err);
setContent(''); // Set empty content on error
setOriginalContent('');
setHasUnsavedChanges(false);
}
}, []);
useEffect(() => { useEffect(() => {
if (selectedFile) { if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile); loadFileContent(selectedFile);
} }
}, [selectedFile, loadFileContent]); }, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback( const handleContentChange = useCallback(
(newContent) => { (newContent) => {

View File

@@ -1,12 +1,16 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { fetchFileList } from '../services/api'; import { fetchFileList } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useFileList = () => { export const useFileList = () => {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const loadFileList = useCallback(async () => { const loadFileList = useCallback(async () => {
if (!currentWorkspace || workspaceLoading) return;
try { try {
const fileList = await fetchFileList(); const fileList = await fetchFileList(currentWorkspace.id);
if (Array.isArray(fileList)) { if (Array.isArray(fileList)) {
setFiles(fileList); setFiles(fileList);
} else { } else {
@@ -14,8 +18,9 @@ export const useFileList = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to load file list:', error); console.error('Failed to load file list:', error);
setFiles([]);
} }
}, []); }, [currentWorkspace, workspaceLoading]);
return { files, loadFileList }; return { files, loadFileList };
}; };

View File

@@ -1,21 +1,35 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { lookupFileByName } from '../services/api'; import { lookupFileByName } from '../services/api';
import { DEFAULT_FILE } from '../utils/constants'; import { DEFAULT_FILE } from '../utils/constants';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile';
export const useFileNavigation = () => { export const useFileNavigation = () => {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState(true); const [isNewFile, setIsNewFile] = useState(true);
const { currentWorkspace } = useWorkspace();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback((filePath) => { const handleFileSelect = useCallback(
setSelectedFile(filePath); async (filePath) => {
setIsNewFile(filePath === DEFAULT_FILE.path); const newPath = filePath || DEFAULT_FILE.path;
}, []); setSelectedFile(newPath);
setIsNewFile(!filePath);
if (filePath) {
await saveLastOpenedFile(filePath);
}
},
[saveLastOpenedFile]
);
const handleLinkClick = useCallback( const handleLinkClick = useCallback(
async (filename) => { async (filename) => {
if (!currentWorkspace) return;
try { try {
const filePaths = await lookupFileByName(filename); const filePaths = await lookupFileByName(currentWorkspace.id, filename);
if (filePaths.length >= 1) { if (filePaths.length >= 1) {
handleFileSelect(filePaths[0]); handleFileSelect(filePaths[0]);
} else { } 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 }; return { handleLinkClick, selectedFile, isNewFile, handleFileSelect };
}; };

View File

@@ -1,67 +1,106 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api'; import { saveFileContent, deleteFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useGitOperations } from './useGitOperations';
export const useFileOperations = () => { export const useFileOperations = () => {
const handleSave = useCallback(async (filePath, content) => { const { currentWorkspace, settings } = useWorkspace();
try { const { handleCommitAndPush } = useGitOperations();
await saveFileContent(filePath, content);
notifications.show({
title: 'Success',
message: 'File saved successfully',
color: 'green',
});
return true;
} catch (error) {
console.error('Error saving file:', error);
notifications.show({
title: 'Error',
message: 'Failed to save file',
color: 'red',
});
return false;
}
}, []);
const handleDelete = useCallback(async (filePath) => { const autoCommit = useCallback(
try { async (filePath, action) => {
await deleteFile(filePath); if (settings.gitAutoCommit && settings.gitEnabled) {
notifications.show({ let commitMessage = settings.gitCommitMsgTemplate
title: 'Success', .replace('${filename}', filePath)
message: 'File deleted successfully', .replace('${action}', action);
color: 'green',
});
return true;
} catch (error) {
console.error('Error deleting file:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete file',
color: 'red',
});
return false;
}
}, []);
const handleCreate = useCallback(async (fileName, initialContent = '') => { commitMessage =
try { commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1);
await saveFileContent(fileName, initialContent);
notifications.show({ await handleCommitAndPush(commitMessage);
title: 'Success', }
message: 'File created successfully', },
color: 'green', [settings]
}); );
return true;
} catch (error) { const handleSave = useCallback(
console.error('Error creating new file:', error); async (filePath, content) => {
notifications.show({ if (!currentWorkspace) return false;
title: 'Error',
message: 'Failed to create new file', try {
color: 'red', await saveFileContent(currentWorkspace.id, filePath, content);
}); notifications.show({
return false; title: 'Success',
} message: 'File saved successfully',
}, []); color: 'green',
});
autoCommit(filePath, 'update');
return true;
} catch (error) {
console.error('Error saving file:', error);
notifications.show({
title: 'Error',
message: 'Failed to save file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleDelete = useCallback(
async (filePath) => {
if (!currentWorkspace) return false;
try {
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);
notifications.show({
title: 'Error',
message: 'Failed to delete file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
const handleCreate = useCallback(
async (fileName, initialContent = '') => {
if (!currentWorkspace) return false;
try {
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);
notifications.show({
title: 'Error',
message: 'Failed to create new file',
color: 'red',
});
return false;
}
},
[currentWorkspace, autoCommit]
);
return { handleSave, handleDelete, handleCreate }; return { handleSave, handleDelete, handleCreate };
}; };

View File

@@ -1,12 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api'; 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 () => { const handlePull = useCallback(async () => {
if (!gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;
try { try {
await pullChanges(); await pullChanges(currentWorkspace.id);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully pulled latest changes', message: 'Successfully pulled latest changes',
@@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => {
}); });
return false; return false;
} }
}, [gitEnabled]); }, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback( const handleCommitAndPush = useCallback(
async (message) => { async (message) => {
if (!gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;
try { try {
await commitAndPush(message); await commitAndPush(currentWorkspace.id, message);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully committed and pushed changes', message: 'Successfully committed and pushed changes',
@@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => {
return false; return false;
} }
}, },
[gitEnabled] [currentWorkspace, settings.gitEnabled]
); );
return { handlePull, handleCommitAndPush }; return { handlePull, handleCommitAndPush };

View 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,
};
};

View File

@@ -1,12 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NovaMD</title> <title>NovaMD</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./index.jsx"></script>
</body> </body>
</html> </html>

View File

@@ -16,76 +16,163 @@ const apiCall = async (url, options = {}) => {
} }
}; };
export const fetchFileList = async () => { export const fetchLastWorkspaceId = async () => {
const response = await apiCall(`${API_BASE_URL}/files`); const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`);
return response.json(); return response.json();
}; };
export const fetchFileContent = async (filePath) => { export const fetchFileList = async (workspaceId) => {
const response = await apiCall(`${API_BASE_URL}/files/${filePath}`);
return response.text();
};
export const saveFileContent = async (filePath, content) => {
const response = await apiCall(`${API_BASE_URL}/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}`, {
method: 'DELETE',
});
return response.text();
};
export const fetchUserSettings = async (userId) => {
const response = await apiCall(`${API_BASE_URL}/settings?userId=${userId}`);
return response.json();
};
export const saveUserSettings = async (settings) => {
const response = await apiCall(`${API_BASE_URL}/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
return response.json();
};
export const pullChanges = async () => {
const response = await apiCall(`${API_BASE_URL}/git/pull`, {
method: 'POST',
});
return response.json();
};
export const commitAndPush = async (message) => {
const response = await apiCall(`${API_BASE_URL}/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 lookupFileByName = async (filename) => {
const response = await apiCall( const response = await apiCall(
`${API_BASE_URL}/files/lookup?filename=${encodeURIComponent(filename)}` `${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 (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 (workspaceId, filePath) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`,
{
method: 'DELETE',
}
);
return response.text();
};
export const getWorkspace = async (workspaceId) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}`
);
return response.json();
};
// 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(workspaceData),
}
);
return response.json();
};
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 (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 = (workspaceId, filePath) => {
return `${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/${filePath}`;
};
export const lookupFileByName = async (workspaceId, filename) => {
const response = await apiCall(
`${API_BASE_URL}/users/1/workspaces/${workspaceId}/files/lookup?filename=${encodeURIComponent(
filename
)}`
); );
const data = await response.json(); const data = await response.json();
return data.paths; 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();
};

View File

@@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [
'.svg', '.svg',
]; ];
export const DEFAULT_SETTINGS = { // Renamed from DEFAULT_SETTINGS to be more specific
export const DEFAULT_WORKSPACE_SETTINGS = {
theme: THEMES.LIGHT, theme: THEMES.LIGHT,
autoSave: false, autoSave: false,
gitEnabled: false, gitEnabled: false,
@@ -34,7 +35,13 @@ export const DEFAULT_SETTINGS = {
gitUser: '', gitUser: '',
gitToken: '', gitToken: '',
gitAutoCommit: false, 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 = { export const DEFAULT_FILE = {

136
frontend/vite.config.js Normal file
View 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',
],
},
}));

View File

@@ -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,
},
};
};