diff --git a/.vscode/settings.json b/.vscode/settings.json index e0a5ace..4d7334d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,7 +18,8 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" - } + }, + "editor.defaultFormatter": "golang.go" }, "gopls": { "usePlaceholders": true, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fbdfb43..82595b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -12,6 +13,7 @@ import ( "novamd/internal/api" "novamd/internal/db" "novamd/internal/filesystem" + "novamd/internal/user" ) func main() { @@ -36,16 +38,15 @@ func main() { workdir = "./data" } - settings, err := database.GetSettings(1) // Assuming user ID 1 for now - if err != nil { - log.Print("Settings not found, using default settings") - } - fs := filesystem.New(workdir, &settings) + fs := filesystem.New(workdir) - if settings.Settings.GitEnabled { - if err := fs.InitializeGitRepo(); err != nil { - log.Fatal(err) - } + // User service + userService := user.NewUserService(database, fs) + + // Admin user + _, err = userService.SetupAdminUser() + if err != nil { + log.Fatal(err) } // Set up router @@ -64,21 +65,27 @@ func main() { 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 - } + r.Get( + "/*", + func(w http.ResponseWriter, r *http.Request) { + requestedPath := r.URL.Path - _, err = os.Stat(validatedPath) - if os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) - return - } - http.StripPrefix("/", fileServer).ServeHTTP(w, r) - }) + fullPath := filepath.Join(staticPath, requestedPath) + cleanPath := filepath.Clean(fullPath) + + if !strings.HasPrefix(cleanPath, staticPath) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + _, err = os.Stat(cleanPath) + if os.IsNotExist(err) { + http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) + return + } + http.StripPrefix("/", fileServer).ServeHTTP(w, r) + }, + ) // Start server port := os.Getenv("NOVAMD_PORT") @@ -87,4 +94,4 @@ func main() { } log.Printf("Server starting on port %s", port) log.Fatal(http.ListenAndServe(":"+port, r)) -} \ No newline at end of file +} diff --git a/backend/go.mod b/backend/go.mod index 9fb7aeb..3cea35d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 github.com/mattn/go-sqlite3 v1.14.23 + golang.org/x/crypto v0.21.0 ) require ( @@ -29,7 +30,6 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect diff --git a/backend/internal/api/file_handlers.go b/backend/internal/api/file_handlers.go new file mode 100644 index 0000000..1a6e943 --- /dev/null +++ b/backend/internal/api/file_handlers.go @@ -0,0 +1,170 @@ +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) { + userID, _, err := getUserAndWorkspaceIDs(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, map[string]string{"lastOpenedFile": user.LastOpenedFilePath}) + } +} + +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 that the file path is valid within the workspace + _, err = fs.ValidatePath(userID, workspaceID, requestBody.FilePath) + if err != nil { + http.Error(w, "Invalid file path", http.StatusBadRequest) + return + } + + if err := db.UpdateLastOpenedFile(userID, 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"}) + } +} diff --git a/backend/internal/api/git_handlers.go b/backend/internal/api/git_handlers.go new file mode 100644 index 0000000..7eaa146 --- /dev/null +++ b/backend/internal/api/git_handlers.go @@ -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"}) + } +} diff --git a/backend/internal/api/handler_utils.go b/backend/internal/api/handler_utils.go new file mode 100644 index 0000000..75c50cd --- /dev/null +++ b/backend/internal/api/handler_utils.go @@ -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) + } +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go deleted file mode 100644 index 717094e..0000000 --- a/backend/internal/api/handlers.go +++ /dev/null @@ -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) - } - } -} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 761bab7..af0b1f0 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -9,20 +9,42 @@ import ( func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { r.Route("/", func(r chi.Router) { - r.Route("/settings", func(r chi.Router) { - r.Get("/", GetSettings(db)) - r.Post("/", UpdateSettings(db, fs)) - }) - r.Route("/files", func(r chi.Router) { - r.Get("/", ListFiles(fs)) - r.Get("/*", GetFileContent(fs)) - r.Post("/*", SaveFile(fs)) - r.Delete("/*", DeleteFile(fs)) - r.Get("/lookup", LookupFileByName(fs)) - }) - r.Route("/git", func(r chi.Router) { - r.Post("/commit", StageCommitAndPush(fs)) - r.Post("/pull", PullChanges(fs)) + // User routes + r.Route("/users/{userId}", func(r chi.Router) { + r.Get("/", GetUser(db)) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", ListWorkspaces(db)) + r.Post("/", CreateWorkspace(db)) + r.Get("/last", GetLastWorkspace(db)) + r.Put("/last", UpdateLastWorkspace(db)) + + r.Route("/{workspaceId}", func(r chi.Router) { + r.Get("/", GetWorkspace(db)) + r.Put("/", UpdateWorkspace(db, fs)) + r.Delete("/", DeleteWorkspace(db)) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", ListFiles(fs)) + r.Get("/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)) + }) + }) + }) }) }) } diff --git a/backend/internal/api/user_handlers.go b/backend/internal/api/user_handlers.go new file mode 100644 index 0000000..28cd9fe --- /dev/null +++ b/backend/internal/api/user_handlers.go @@ -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) + } +} diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go new file mode 100644 index 0000000..c1c23c6 --- /dev/null +++ b/backend/internal/api/workspace_handlers.go @@ -0,0 +1,242 @@ +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) 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 + } + + 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"}) + } +} diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index 1973da6..4eb9402 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -13,10 +13,37 @@ type Migration struct { var migrations = []Migration{ { Version: 1, - SQL: `CREATE TABLE IF NOT EXISTS settings ( - user_id INTEGER PRIMARY KEY, - settings JSON NOT NULL - )`, + SQL: ` + -- Create users table + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_workspace_id INTEGER, + last_opened_file_path TEXT + ); + + -- 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, + -- 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) + ); + `, }, } diff --git a/backend/internal/db/settings.go b/backend/internal/db/settings.go deleted file mode 100644 index f0da94a..0000000 --- a/backend/internal/db/settings.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/db/users.go b/backend/internal/db/users.go new file mode 100644 index 0000000..4a0251d --- /dev/null +++ b/backend/internal/db/users.go @@ -0,0 +1,168 @@ +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 + u.id, u.email, u.display_name, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.id = ?`, id). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt, + &user.LastWorkspaceID, &user.LastOpenedFilePath, &user.LastWorkspaceID) + if err != nil { + return nil, err + } + return user, nil +} + +func (db *DB) GetUserByEmail(email string) (*models.User, error) { + user := &models.User{} + var lastOpenedFilePath sql.NullString + err := db.QueryRow(` + SELECT + u.id, u.email, u.display_name, u.password_hash, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.email = ?`, email). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, + &user.LastWorkspaceID, &lastOpenedFilePath, &user.LastWorkspaceID) + if err != nil { + return nil, err + } + if lastOpenedFilePath.Valid { + user.LastOpenedFilePath = lastOpenedFilePath.String + } else { + user.LastOpenedFilePath = "" + } + return user, nil +} + +func (db *DB) UpdateUser(user *models.User) error { + _, err := db.Exec(` + UPDATE users + SET email = ?, display_name = ?, role = ?, last_workspace_id = ?, last_opened_file_path = ? + WHERE id = ?`, + user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.LastOpenedFilePath, 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) UpdateLastOpenedFile(userID int, filePath string) error { + _, err := db.Exec("UPDATE users SET last_opened_file_path = ? WHERE id = ?", filePath, 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 +} diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go new file mode 100644 index 0000000..344e4e2 --- /dev/null +++ b/backend/internal/db/workspaces.go @@ -0,0 +1,162 @@ +package db + +import ( + "database/sql" + "novamd/internal/models" +) + +func (db *DB) CreateWorkspace(workspace *models.Workspace) error { + // Set default settings if not provided + if workspace.Theme == "" { + workspace.GetDefaultSettings() + } + + 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, 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) GetWorkspaceByID(id int) (*models.Workspace, error) { + workspace := &models.Workspace{} + 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, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + return workspace, nil +} + +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{} + err := rows.Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} + +func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { + _, 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, + workspace.GitToken, + workspace.GitAutoCommit, + workspace.GitCommitMsgTemplate, + workspace.ID, + workspace.UserID, + ) + return err +} + +// 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 +} diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index b589ea8..6e01536 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -4,17 +4,14 @@ import ( "errors" "fmt" "novamd/internal/gitutils" - "novamd/internal/models" "os" "path/filepath" - "sort" "strings" ) type FileSystem struct { RootDir string - GitRepo *gitutils.GitRepo - Settings *models.Settings + GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo } type FileNode struct { @@ -24,67 +21,59 @@ type FileNode struct { Children []FileNode `json:"children,omitempty"` } -func New(rootDir string, settings *models.Settings) *FileSystem { - fs := &FileSystem{ +func New(rootDir string) *FileSystem { + return &FileSystem{ RootDir: rootDir, - Settings: settings, + GitRepos: make(map[int]map[int]*gitutils.GitRepo), + } +} + +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) + } + // Optionally, create a welcome file in the new workspace + // welcomeFilePath := filepath.Join(workspacePath, "Welcome.md") + // welcomeContent := []byte("# Welcome to Your Main Workspace\n\nThis is your default workspace in NovaMD. You can start creating and editing files right away!") + // err = os.WriteFile(welcomeFilePath, welcomeContent, 0644) + // if err != nil { + // return fmt.Errorf("failed to create welcome file: %w", err) + // } + + return nil +} + +func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.RemoveAll(workspacePath) + if err != nil { + return fmt.Errorf("failed to delete workspace directory: %w", err) } - if settings.Settings.GitEnabled { - fs.GitRepo = gitutils.New( - settings.Settings.GitURL, - settings.Settings.GitUser, - settings.Settings.GitToken, - rootDir, - ) - } - - return fs + return nil } -func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error { - fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir) - return fs.InitializeGitRepo() -} - -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) +func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + fullPath := filepath.Join(workspacePath, path) cleanPath := filepath.Clean(fullPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) { - return "", fmt.Errorf("invalid path: outside of root directory") - } - - relPath, err := filepath.Rel(rootDir, cleanPath) - if err != nil { - return "", err - } - - if strings.HasPrefix(relPath, "..") { - return "", fmt.Errorf("invalid path: outside of root directory") + if !strings.HasPrefix(cleanPath, workspacePath) { + return "", fmt.Errorf("invalid path: outside of workspace") } return cleanPath, nil } -func (fs *FileSystem) validatePath(path string) (string, error) { - return ValidatePath(fs.RootDir, path) -} - -func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) { - return fs.walkDirectory(fs.RootDir, "") +func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + return fs.walkDirectory(workspacePath, "") } func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { @@ -93,67 +82,46 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { return nil, err } - var folders []FileNode - var files []FileNode - + nodes := make([]FileNode, 0) for _, entry := range entries { name := entry.Name() path := filepath.Join(prefix, name) fullPath := filepath.Join(dir, name) + node := FileNode{ + ID: path, + Name: name, + Path: path, + } + if entry.IsDir() { children, err := fs.walkDirectory(fullPath, path) if err != nil { return nil, err } - folders = append(folders, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - Children: children, - }) - } else { - files = append(files, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - }) + node.Children = children } + + nodes = append(nodes, node) } - // Sort folders and files alphabetically - sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) - sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name }) - - // Combine folders and files, with folders first - return append(folders, files...), nil + return nodes, nil } - -func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { +func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) { var foundPaths []string - var searchPattern string + workspacePath := fs.GetWorkspacePath(userID, workspaceID) - // If no extension is provided, assume .md - if !strings.Contains(filenameOrPath, ".") { - searchPattern = filenameOrPath + ".md" - } else { - searchPattern = filenameOrPath - } - - err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { - relPath, err := filepath.Rel(fs.RootDir, path) + relPath, err := filepath.Rel(workspacePath, path) if err != nil { return err } - - // Check if the file matches the search pattern - if strings.HasSuffix(relPath, searchPattern) || - strings.EqualFold(info.Name(), searchPattern) { + if strings.EqualFold(info.Name(), filename) { foundPaths = append(foundPaths, relPath) } } @@ -171,16 +139,16 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { return foundPaths, nil } -func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return nil, err } return os.ReadFile(fullPath) } -func (fs *FileSystem) SaveFile(filePath string, content []byte) error { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } @@ -193,30 +161,64 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error { return os.WriteFile(fullPath, content, 0644) } -func (fs *FileSystem) DeleteFile(filePath string) error { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } return os.Remove(fullPath) } -func (fs *FileSystem) StageCommitAndPush(message string) error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") +func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error { + dir := fs.GetWorkspacePath(userID, workspaceID) + return os.MkdirAll(dir, 0755) +} + +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 fs.GitRepo.Push() + return repo.Push() } -func (fs *FileSystem) Pull() error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") +func (fs *FileSystem) Pull(userID, workspaceID int) error { + repo, ok := fs.getGitRepo(userID, workspaceID) + if !ok { + return errors.New("git settings not configured for this workspace") } - return fs.GitRepo.Pull() + return repo.Pull() +} + +func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) { + userRepos, ok := fs.GitRepos[userID] + if !ok { + return nil, false + } + repo, ok := userRepos[workspaceID] + return repo, ok } diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go deleted file mode 100644 index 602bd45..0000000 --- a/backend/internal/models/settings.go +++ /dev/null @@ -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() -} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..8881601 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,32 @@ +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"` + LastOpenedFilePath string `json:"lastOpenedFilePath"` +} + +func (u *User) Validate() error { + return validate.Struct(u) +} diff --git a/backend/internal/models/workspace.go b/backend/internal/models/workspace.go new file mode 100644 index 0000000..28f1bc5 --- /dev/null +++ b/backend/internal/models/workspace.go @@ -0,0 +1,37 @@ +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"` + + // 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}" +} diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go new file mode 100644 index 0000000..b725256 --- /dev/null +++ b/backend/internal/user/user.go @@ -0,0 +1,120 @@ +package user + +import ( + "fmt" + "log" + "os" + + "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() (*models.User, error) { + // Get admin email and password from environment variables + adminEmail := os.Getenv("NOVAMD_ADMIN_EMAIL") + adminPassword := os.Getenv("NOVAMD_ADMIN_PASSWORD") + if adminEmail == "" || adminPassword == "" { + return nil, fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD environment variables must be set") + } + + // Check if admin user exists + adminUser, err := s.DB.GetUserByEmail(adminEmail) + if adminUser != nil { + return adminUser, nil // Admin user already exists + } + + // 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) +} diff --git a/frontend/src/App.js b/frontend/src/App.js index 5b3a52b..2ba651a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; import { ModalsProvider } from '@mantine/modals'; import Layout from './components/Layout'; -import { SettingsProvider, useSettings } from './contexts/SettingsContext'; +import { WorkspaceProvider } from './contexts/WorkspaceContext'; import { ModalProvider } from './contexts/ModalContext'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './App.scss'; function AppContent() { - const { loading } = useSettings(); - - if (loading) { - return
Loading...
; - } - return ; } @@ -26,11 +20,11 @@ function App() { - + - + diff --git a/frontend/src/components/Editor.js b/frontend/src/components/Editor.js index 1491f8e..902bb9b 100644 --- a/frontend/src/components/Editor.js +++ b/frontend/src/components/Editor.js @@ -5,10 +5,10 @@ import { EditorView, keymap } from '@codemirror/view'; import { markdown } from '@codemirror/lang-markdown'; import { defaultKeymap } from '@codemirror/commands'; import { oneDark } from '@codemirror/theme-one-dark'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { - const { settings } = useSettings(); + const { colorScheme } = useWorkspace(); const editorRef = useRef(); const viewRef = useRef(); @@ -27,12 +27,12 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { overflow: 'auto', }, '.cm-gutters': { - backgroundColor: settings.theme === 'dark' ? '#1e1e1e' : '#f5f5f5', - color: settings.theme === 'dark' ? '#858585' : '#999', + backgroundColor: colorScheme === 'dark' ? '#1e1e1e' : '#f5f5f5', + color: colorScheme === 'dark' ? '#858585' : '#999', border: 'none', }, '.cm-activeLineGutter': { - backgroundColor: settings.theme === 'dark' ? '#2c313a' : '#e8e8e8', + backgroundColor: colorScheme === 'dark' ? '#2c313a' : '#e8e8e8', }, }); @@ -56,7 +56,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { } }), theme, - settings.theme === 'dark' ? oneDark : [], + colorScheme === 'dark' ? oneDark : [], ], }); @@ -70,7 +70,7 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { return () => { view.destroy(); }; - }, [settings.theme, handleContentChange]); + }, [colorScheme, handleContentChange]); useEffect(() => { if (viewRef.current && content !== viewRef.current.state.doc.toString()) { diff --git a/frontend/src/components/FileActions.js b/frontend/src/components/FileActions.js index 5984a89..b387022 100644 --- a/frontend/src/components/FileActions.js +++ b/frontend/src/components/FileActions.js @@ -6,11 +6,11 @@ import { IconGitPullRequest, IconGitCommit, } from '@tabler/icons-react'; -import { useSettings } from '../contexts/SettingsContext'; import { useModalContext } from '../contexts/ModalContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const FileActions = ({ handlePullChanges, selectedFile }) => { - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { setNewFileModalVisible, setDeleteFileModalVisible, diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index f95b52a..b9f894a 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,24 +1,17 @@ import React from 'react'; -import { Group, Text, ActionIcon, Avatar } from '@mantine/core'; -import { IconSettings } from '@tabler/icons-react'; +import { Group, Text, Avatar } from '@mantine/core'; +import WorkspaceSwitcher from './WorkspaceSwitcher'; import Settings from './Settings'; -import { useModalContext } from '../contexts/ModalContext'; const Header = () => { - const { setSettingsModalVisible } = useModalContext(); - - const openSettings = () => setSettingsModalVisible(true); - return ( NovaMD + - - - diff --git a/frontend/src/components/Layout.js b/frontend/src/components/Layout.js index 97df9e8..6f2c5ed 100644 --- a/frontend/src/components/Layout.js +++ b/frontend/src/components/Layout.js @@ -1,16 +1,30 @@ import React from 'react'; -import { AppShell, Container } from '@mantine/core'; +import { AppShell, Container, Loader, Center } from '@mantine/core'; import Header from './Header'; import Sidebar from './Sidebar'; import MainContent from './MainContent'; import { useFileNavigation } from '../hooks/useFileNavigation'; import { useFileList } from '../hooks/useFileList'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Layout = () => { + const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { selectedFile, handleFileSelect, handleLinkClick } = useFileNavigation(); const { files, loadFileList } = useFileList(); + if (workspaceLoading) { + return ( +
+ +
+ ); + } + + if (!currentWorkspace) { + return
No workspace found. Please create a workspace.
; + } + return ( diff --git a/frontend/src/components/MainContent.js b/frontend/src/components/MainContent.js index 177f538..637490f 100644 --- a/frontend/src/components/MainContent.js +++ b/frontend/src/components/MainContent.js @@ -10,7 +10,7 @@ import CommitMessageModal from './modals/CommitMessageModal'; import { useFileContent } from '../hooks/useFileContent'; import { useFileOperations } from '../hooks/useFileOperations'; import { useGitOperations } from '../hooks/useGitOperations'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const MainContent = ({ selectedFile, @@ -19,7 +19,7 @@ const MainContent = ({ loadFileList, }) => { const [activeTab, setActiveTab] = useState('source'); - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { content, hasUnsavedChanges, diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js index b0e10ce..a4ee4aa 100644 --- a/frontend/src/components/Settings.js +++ b/frontend/src/components/Settings.js @@ -1,11 +1,21 @@ import React, { useReducer, useEffect, useCallback, useRef } from 'react'; -import { Modal, Badge, Button, Group, Title } from '@mantine/core'; +import { + Modal, + Badge, + Button, + Group, + Title, + Stack, + Accordion, +} from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { useSettings } from '../contexts/SettingsContext'; +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: {}, @@ -38,19 +48,19 @@ function settingsReducer(state, action) { initialSettings: state.localSettings, hasUnsavedChanges: false, }; - case 'RESET': - return { - ...state, - localSettings: state.initialSettings, - hasUnsavedChanges: false, - }; default: return state; } } +const AccordionControl = ({ children }) => ( + + {children} + +); + const Settings = () => { - const { settings, updateSettings, colorScheme } = useSettings(); + const { currentWorkspace, updateSettings } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const [state, dispatch] = useReducer(settingsReducer, initialState); const isInitialMount = useRef(true); @@ -58,16 +68,20 @@ const Settings = () => { 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 }); } - }, [settings]); - - useEffect(() => { - dispatch({ - type: 'UPDATE_LOCAL_SETTINGS', - payload: { theme: colorScheme }, - }); - }, [colorScheme]); + }, [currentWorkspace]); const handleInputChange = useCallback((key, value) => { dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); @@ -75,6 +89,14 @@ const Settings = () => { 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({ @@ -92,11 +114,8 @@ const Settings = () => { }; const handleClose = useCallback(() => { - if (state.hasUnsavedChanges) { - dispatch({ type: 'RESET' }); - } setSettingsModalVisible(false); - }, [state.hasUnsavedChanges, setSettingsModalVisible]); + }, [setSettingsModalVisible]); return ( { centered size="lg" > - {state.hasUnsavedChanges && ( - - Unsaved Changes - - )} - handleInputChange('theme', newTheme)} - /> - handleInputChange('autoSave', value)} - /> - - - - - + + {state.hasUnsavedChanges && ( + + Unsaved Changes + + )} + + ({ + 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)', + }, + }, + })} + > + + General + + + + + + + Appearance + + + handleInputChange('theme', newTheme) + } + /> + + + + + Editor + + + handleInputChange('autoSave', value) + } + /> + + + + + Git Integration + + + + + + + Danger Zone + + + + + + + + + + + ); }; diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 79271f7..04619c7 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -3,10 +3,10 @@ import { Box } from '@mantine/core'; import FileActions from './FileActions'; import FileTree from './FileTree'; import { useGitOperations } from '../hooks/useGitOperations'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { - const { settings } = useSettings(); + const { settings } = useWorkspace(); const { handlePull } = useGitOperations(settings.gitEnabled); useEffect(() => { diff --git a/frontend/src/components/WorkspaceSwitcher.js b/frontend/src/components/WorkspaceSwitcher.js new file mode 100644 index 0000000..e9b79a0 --- /dev/null +++ b/frontend/src/components/WorkspaceSwitcher.js @@ -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 ( + <> + + + { + setPopoverOpened((o) => !o); + if (!popoverOpened) { + loadWorkspaces(); + } + }} + > + + +
+ + {currentWorkspace?.name || 'No workspace'} + +
+
+
+
+ + + + + Workspaces + + + + + + + + + + {loading ? ( +
+ +
+ ) : ( + workspaces.map((workspace) => { + const isSelected = workspace.id === currentWorkspace?.id; + return ( + + + { + switchWorkspace(workspace.id); + setPopoverOpened(false); + }} + > + + + {workspace.name} + + + {new Date( + workspace.createdAt + ).toLocaleDateString()} + + + + {isSelected && ( + + { + e.stopPropagation(); + setSettingsModalVisible(true); + setPopoverOpened(false); + }} + > + + + + )} + + + ); + }) + )} +
+
+
+
+ + + ); +}; + +export default WorkspaceSwitcher; diff --git a/frontend/src/components/modals/CreateWorkspaceModal.js b/frontend/src/components/modals/CreateWorkspaceModal.js new file mode 100644 index 0000000..265eccf --- /dev/null +++ b/frontend/src/components/modals/CreateWorkspaceModal.js @@ -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 ( + setCreateWorkspaceModalVisible(false)} + title="Create New Workspace" + centered + size="sm" + > + + setName(event.currentTarget.value)} + mb="md" + w="100%" + disabled={loading} + /> + + + + + + + ); +}; + +export default CreateWorkspaceModal; diff --git a/frontend/src/components/modals/DeleteWorkspaceModal.js b/frontend/src/components/modals/DeleteWorkspaceModal.js new file mode 100644 index 0000000..8effeeb --- /dev/null +++ b/frontend/src/components/modals/DeleteWorkspaceModal.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { Modal, Text, Button, Group, Stack } from '@mantine/core'; + +const DeleteWorkspaceModal = ({ + opened, + onClose, + onConfirm, + workspaceName, +}) => ( + + + + Are you sure you want to delete workspace "{workspaceName}"? This action + cannot be undone and all files in this workspace will be permanently + deleted. + + + + + + + +); + +export default DeleteWorkspaceModal; diff --git a/frontend/src/components/settings/AppearanceSettings.js b/frontend/src/components/settings/AppearanceSettings.js index f29f253..0c811ba 100644 --- a/frontend/src/components/settings/AppearanceSettings.js +++ b/frontend/src/components/settings/AppearanceSettings.js @@ -1,20 +1,18 @@ import React from 'react'; import { Text, Switch, Group, Box, Title } from '@mantine/core'; -import { useSettings } from '../../contexts/SettingsContext'; +import { useWorkspace } from '../../contexts/WorkspaceContext'; -const AppearanceSettings = ({ onThemeChange }) => { - const { colorScheme, toggleColorScheme } = useSettings(); +const AppearanceSettings = ({ themeSettings, onThemeChange }) => { + const { colorScheme, updateColorScheme } = useWorkspace(); const handleThemeChange = () => { - toggleColorScheme(); - onThemeChange(colorScheme === 'dark' ? 'light' : 'dark'); + const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; + updateColorScheme(newTheme); + onThemeChange(newTheme); }; return ( - - Appearance - Dark Mode diff --git a/frontend/src/components/settings/DangerZoneSettings.js b/frontend/src/components/settings/DangerZoneSettings.js new file mode 100644 index 0000000..4052a92 --- /dev/null +++ b/frontend/src/components/settings/DangerZoneSettings.js @@ -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 ( + + + + setDeleteModalOpened(false)} + onConfirm={handleDelete} + workspaceName={currentWorkspace?.name} + /> + + ); +}; + +export default DangerZoneSettings; diff --git a/frontend/src/components/settings/EditorSettings.js b/frontend/src/components/settings/EditorSettings.js index 08b1db0..8449544 100644 --- a/frontend/src/components/settings/EditorSettings.js +++ b/frontend/src/components/settings/EditorSettings.js @@ -4,9 +4,6 @@ import { Text, Switch, Tooltip, Group, Box, Title } from '@mantine/core'; const EditorSettings = ({ autoSave, onAutoSaveChange }) => { return ( - - Editor - Auto Save diff --git a/frontend/src/components/settings/GeneralSettings.js b/frontend/src/components/settings/GeneralSettings.js new file mode 100644 index 0000000..0ada3e9 --- /dev/null +++ b/frontend/src/components/settings/GeneralSettings.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { Title, Box, TextInput, Text, Grid } from '@mantine/core'; + +const GeneralSettings = ({ name, onInputChange }) => { + return ( + + + + Workspace Name + + + + onInputChange('name', event.currentTarget.value) + } + placeholder="Enter workspace name" + required + /> + + + + ); +}; + +export default GeneralSettings; diff --git a/frontend/src/components/settings/GitSettings.js b/frontend/src/components/settings/GitSettings.js index 1144018..4884ca5 100644 --- a/frontend/src/components/settings/GitSettings.js +++ b/frontend/src/components/settings/GitSettings.js @@ -21,7 +21,6 @@ const GitSettings = ({ }) => { return ( - Git Integration Enable Git diff --git a/frontend/src/contexts/ModalContext.js b/frontend/src/contexts/ModalContext.js index 697bdc7..4865e6a 100644 --- a/frontend/src/contexts/ModalContext.js +++ b/frontend/src/contexts/ModalContext.js @@ -8,6 +8,10 @@ export const ModalProvider = ({ children }) => { const [commitMessageModalVisible, setCommitMessageModalVisible] = useState(false); const [settingsModalVisible, setSettingsModalVisible] = useState(false); + const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] = + useState(false); + const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] = + useState(false); const value = { newFileModalVisible, @@ -18,6 +22,10 @@ export const ModalProvider = ({ children }) => { setCommitMessageModalVisible, settingsModalVisible, setSettingsModalVisible, + switchWorkspaceModalVisible, + setSwitchWorkspaceModalVisible, + createWorkspaceModalVisible, + setCreateWorkspaceModalVisible, }; return ( diff --git a/frontend/src/contexts/SettingsContext.js b/frontend/src/contexts/SettingsContext.js deleted file mode 100644 index 0a8df8d..0000000 --- a/frontend/src/contexts/SettingsContext.js +++ /dev/null @@ -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 ( - - {children} - - ); -}; diff --git a/frontend/src/contexts/WorkspaceContext.js b/frontend/src/contexts/WorkspaceContext.js new file mode 100644 index 0000000..f7804e2 --- /dev/null +++ b/frontend/src/contexts/WorkspaceContext.js @@ -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 ( + + {children} + + ); +}; + +export const useWorkspace = () => { + const context = useContext(WorkspaceContext); + if (context === undefined) { + throw new Error('useWorkspace must be used within a WorkspaceProvider'); + } + return context; +}; diff --git a/frontend/src/hooks/useFileContent.js b/frontend/src/hooks/useFileContent.js index ba12b22..cb304d0 100644 --- a/frontend/src/hooks/useFileContent.js +++ b/frontend/src/hooks/useFileContent.js @@ -2,38 +2,45 @@ import { useState, useCallback, useEffect } from 'react'; import { fetchFileContent } from '../services/api'; import { isImageFile } from '../utils/fileHelpers'; import { DEFAULT_FILE } from '../utils/constants'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileContent = (selectedFile) => { + const { currentWorkspace } = useWorkspace(); const [content, setContent] = useState(DEFAULT_FILE.content); const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const loadFileContent = useCallback(async (filePath) => { - try { - let newContent; - if (filePath === DEFAULT_FILE.path) { - newContent = DEFAULT_FILE.content; - } else if (!isImageFile(filePath)) { - newContent = await fetchFileContent(filePath); - } else { - newContent = ''; // Set empty content for image files + const loadFileContent = useCallback( + async (filePath) => { + if (!currentWorkspace) return; + + try { + let newContent; + if (filePath === DEFAULT_FILE.path) { + newContent = DEFAULT_FILE.content; + } else if (!isImageFile(filePath)) { + newContent = await fetchFileContent(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); - setHasUnsavedChanges(false); - } catch (err) { - console.error('Error loading file content:', err); - setContent(''); // Set empty content on error - setOriginalContent(''); - setHasUnsavedChanges(false); - } - }, []); + }, + [currentWorkspace] + ); useEffect(() => { - if (selectedFile) { + if (selectedFile && currentWorkspace) { loadFileContent(selectedFile); } - }, [selectedFile, loadFileContent]); + }, [selectedFile, currentWorkspace, loadFileContent]); const handleContentChange = useCallback( (newContent) => { diff --git a/frontend/src/hooks/useFileList.js b/frontend/src/hooks/useFileList.js index 2e8abed..ef72c47 100644 --- a/frontend/src/hooks/useFileList.js +++ b/frontend/src/hooks/useFileList.js @@ -1,12 +1,16 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { fetchFileList } from '../services/api'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileList = () => { const [files, setFiles] = useState([]); + const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const loadFileList = useCallback(async () => { + if (!currentWorkspace || workspaceLoading) return; + try { - const fileList = await fetchFileList(); + const fileList = await fetchFileList(currentWorkspace.id); if (Array.isArray(fileList)) { setFiles(fileList); } else { @@ -14,8 +18,9 @@ export const useFileList = () => { } } catch (error) { console.error('Failed to load file list:', error); + setFiles([]); } - }, []); + }, [currentWorkspace]); return { files, loadFileList }; }; diff --git a/frontend/src/hooks/useFileNavigation.js b/frontend/src/hooks/useFileNavigation.js index 497f1e4..23ce637 100644 --- a/frontend/src/hooks/useFileNavigation.js +++ b/frontend/src/hooks/useFileNavigation.js @@ -2,10 +2,12 @@ import { useState, useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { lookupFileByName } from '../services/api'; import { DEFAULT_FILE } from '../utils/constants'; +import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileNavigation = () => { const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); const [isNewFile, setIsNewFile] = useState(true); + const { currentWorkspace } = useWorkspace(); const handleFileSelect = useCallback((filePath) => { setSelectedFile(filePath); @@ -14,8 +16,10 @@ export const useFileNavigation = () => { const handleLinkClick = useCallback( async (filename) => { + if (!currentWorkspace) return; + try { - const filePaths = await lookupFileByName(filename); + const filePaths = await lookupFileByName(currentWorkspace.id, filename); if (filePaths.length >= 1) { handleFileSelect(filePaths[0]); } else { @@ -34,7 +38,7 @@ export const useFileNavigation = () => { }); } }, - [handleFileSelect] + [currentWorkspace] ); return { handleLinkClick, selectedFile, isNewFile, handleFileSelect }; diff --git a/frontend/src/hooks/useFileOperations.js b/frontend/src/hooks/useFileOperations.js index a76b5b4..0110755 100644 --- a/frontend/src/hooks/useFileOperations.js +++ b/frontend/src/hooks/useFileOperations.js @@ -1,12 +1,12 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { saveFileContent, deleteFile } from '../services/api'; -import { useSettings } from '../contexts/SettingsContext'; +import { useWorkspace } from '../contexts/WorkspaceContext'; import { useGitOperations } from './useGitOperations'; export const useFileOperations = () => { - const { settings } = useSettings(); - const { handleCommitAndPush } = useGitOperations(settings.gitEnabled); + const { currentWorkspace, settings } = useWorkspace(); + const { handleCommitAndPush } = useGitOperations(); const autoCommit = useCallback( async (filePath, action) => { @@ -15,20 +15,21 @@ export const useFileOperations = () => { .replace('${filename}', filePath) .replace('${action}', action); - // Capitalize the first letter of the commit message commitMessage = commitMessage.charAt(0).toUpperCase() + commitMessage.slice(1); await handleCommitAndPush(commitMessage); } }, - [settings, handleCommitAndPush] + [settings] ); const handleSave = useCallback( async (filePath, content) => { + if (!currentWorkspace) return false; + try { - await saveFileContent(filePath, content); + await saveFileContent(currentWorkspace.id, filePath, content); notifications.show({ title: 'Success', message: 'File saved successfully', @@ -46,13 +47,15 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); const handleDelete = useCallback( async (filePath) => { + if (!currentWorkspace) return false; + try { - await deleteFile(filePath); + await deleteFile(currentWorkspace.id, filePath); notifications.show({ title: 'Success', message: 'File deleted successfully', @@ -70,13 +73,15 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); const handleCreate = useCallback( async (fileName, initialContent = '') => { + if (!currentWorkspace) return false; + try { - await saveFileContent(fileName, initialContent); + await saveFileContent(currentWorkspace.id, fileName, initialContent); notifications.show({ title: 'Success', message: 'File created successfully', @@ -94,7 +99,7 @@ export const useFileOperations = () => { return false; } }, - [autoCommit] + [currentWorkspace, autoCommit] ); return { handleSave, handleDelete, handleCreate }; diff --git a/frontend/src/hooks/useGitOperations.js b/frontend/src/hooks/useGitOperations.js index 63f2424..40e1669 100644 --- a/frontend/src/hooks/useGitOperations.js +++ b/frontend/src/hooks/useGitOperations.js @@ -1,12 +1,16 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { pullChanges, commitAndPush } from '../services/api'; +import { useWorkspace } from '../contexts/WorkspaceContext'; + +export const useGitOperations = () => { + const { currentWorkspace, settings } = useWorkspace(); -export const useGitOperations = (gitEnabled) => { const handlePull = useCallback(async () => { - if (!gitEnabled) return false; + if (!currentWorkspace || !settings.gitEnabled) return false; + try { - await pullChanges(); + await pullChanges(currentWorkspace.id); notifications.show({ title: 'Success', message: 'Successfully pulled latest changes', @@ -22,13 +26,14 @@ export const useGitOperations = (gitEnabled) => { }); return false; } - }, [gitEnabled]); + }, [currentWorkspace, settings.gitEnabled]); const handleCommitAndPush = useCallback( async (message) => { - if (!gitEnabled) return false; + if (!currentWorkspace || !settings.gitEnabled) return false; + try { - await commitAndPush(message); + await commitAndPush(currentWorkspace.id, message); notifications.show({ title: 'Success', message: 'Successfully committed and pushed changes', @@ -45,7 +50,7 @@ export const useGitOperations = (gitEnabled) => { return false; } }, - [gitEnabled] + [currentWorkspace, settings.gitEnabled] ); return { handlePull, handleCommitAndPush }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 8b57ad5..6fbed6c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -16,76 +16,163 @@ const apiCall = async (url, options = {}) => { } }; -export const fetchFileList = async () => { - const response = await apiCall(`${API_BASE_URL}/files`); +export const fetchLastWorkspaceId = async () => { + const response = await apiCall(`${API_BASE_URL}/users/1/workspaces/last`); return response.json(); }; -export const fetchFileContent = async (filePath) => { - const response = await apiCall(`${API_BASE_URL}/files/${filePath}`); - 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) => { +export const fetchFileList = async (workspaceId) => { 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(); 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(); +}; diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index c4971b0..4ce4101 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -26,7 +26,8 @@ export const IMAGE_EXTENSIONS = [ '.svg', ]; -export const DEFAULT_SETTINGS = { +// Renamed from DEFAULT_SETTINGS to be more specific +export const DEFAULT_WORKSPACE_SETTINGS = { theme: THEMES.LIGHT, autoSave: false, gitEnabled: false, @@ -37,6 +38,12 @@ export const DEFAULT_SETTINGS = { gitCommitMsgTemplate: '${action} ${filename}', }; +// Template for creating new workspaces +export const DEFAULT_WORKSPACE = { + name: '', + ...DEFAULT_WORKSPACE_SETTINGS, +}; + export const DEFAULT_FILE = { name: 'New File.md', path: 'New File.md',