6 Commits

Author SHA1 Message Date
domrichardson ead8219f3b new frontend
Build and Push App Image / build-and-push (push) Successful in 3m38s
2026-06-17 12:08:20 +01:00
domrichardson b690b00016 feat: updated task list index
Build and Push App Image / build-and-push (push) Successful in 1m30s
2026-04-17 14:49:40 +01:00
domrichardson 503d2415e6 feat: associated task status with task list not space
Build and Push App Image / build-and-push (push) Successful in 1m52s
2026-04-01 14:29:15 +01:00
domrichardson 74d8899eec feat: Updates to dashboard and delete confirmations
Build and Push App Image / build-and-push (push) Successful in 34s
2026-04-01 13:40:18 +01:00
domrichardson 295e03feb4 fix: removed hardcoded api url
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-30 10:58:36 +01:00
domrichardson b09137eca5 feat: Added the ability to delete task lists
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-30 10:14:07 +01:00
74 changed files with 13399 additions and 1914 deletions
-2
View File
@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
FRONTEND_URL="http://localhost" FRONTEND_URL="http://localhost"
VITE_API_BASE_URL="http://localhost"
# Default Admin # Default Admin
DEFAULT_ADMIN_EMAIL=admin@notely.local DEFAULT_ADMIN_EMAIL=admin@notely.local
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin
-2
View File
@@ -46,8 +46,6 @@ jobs:
context: . context: .
file: ./devops/docker/Dockerfile file: ./devops/docker/Dockerfile
push: true push: true
build-args: |
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}
+4
View File
@@ -43,3 +43,7 @@ secret*
*.a *.a
*.so *.so
.go/ .go/
frontend_new/out
backend/public
frontend_new/.next
+1 -4
View File
@@ -21,7 +21,6 @@ Required or commonly used:
- `JWT_SECRET` - `JWT_SECRET`
- `ENCRYPTION_KEY` - `ENCRYPTION_KEY`
- `FRONTEND_URL` - `FRONTEND_URL`
- `VITE_API_BASE_URL`
- `DEFAULT_ADMIN_EMAIL` - `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD` - `DEFAULT_ADMIN_PASSWORD`
@@ -41,7 +40,6 @@ Optional backend runtime values that Docker Compose will also pass through if pr
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin` - MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
- Backend port: `8080` - Backend port: `8080`
- Public frontend URL: `http://localhost` - Public frontend URL: `http://localhost`
- Browser API base URL for container builds: `http://localhost`
## 2. `backend/.env` ## 2. `backend/.env`
@@ -107,13 +105,12 @@ cp .env.example .env
### Frontend Variables In `frontend/.env.example` ### Frontend Variables In `frontend/.env.example`
- `VITE_API_BASE_URL`
- `VITE_ENV` - `VITE_ENV`
- `VITE_ENABLE_ANALYTICS` - `VITE_ENABLE_ANALYTICS`
### Variables Currently Relevant To The Frontend App ### Variables Currently Relevant To The Frontend App
- `VITE_API_BASE_URL`: used by the API client - API requests are sent to the current browser origin (same-origin runtime behavior)
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them. The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
+1 -1
View File
@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
Check: Check:
- backend is running on port `8080` - backend is running on port `8080`
- frontend `VITE_API_BASE_URL` - frontend and API are reachable through the same host/origin
- Vite proxy settings in `frontend/vite.config.js` - Vite proxy settings in `frontend/vite.config.js`
### OAuth callback redirects to the wrong URL ### OAuth callback redirects to the wrong URL
+114 -39
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -288,12 +289,12 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET") api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints // Task status endpoints (scoped to task list)
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped) // File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET") api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
@@ -358,39 +359,9 @@ func main() {
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT") admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE") admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
// Serve static files (frontend) for all other routes // Serve static files (NextJS frontend) for all other routes.
// This must be after all API route handlers to allow API routes to take precedence // Must come after all API route handlers.
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.PathPrefix("/").HandlerFunc(serveNextJS)
// List of static file extensions to serve directly
staticExts := map[string]bool{
".js": true, ".css": true, ".svg": true, ".png": true,
".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
}
filePath := "./public" + r.URL.Path
if r.URL.Path == "/" {
filePath = "./public/index.html"
}
// Check if it's a static file (has an extension in staticExts)
isStatic := false
for ext := range staticExts {
if len(r.URL.Path) > len(ext) {
if r.URL.Path[len(r.URL.Path)-len(ext):] == ext {
isStatic = true
break
}
}
}
// If it doesn't look like a static file, serve index.html (SPA routing)
if !isStatic {
filePath = "./public/index.html"
}
http.ServeFile(w, r, filePath)
})
// Start server // Start server
server := &http.Server{ server := &http.Server{
@@ -529,3 +500,107 @@ func ensureDefaultAdminUser(
log.Printf("default admin user synchronized from environment: %s", adminEmail) log.Printf("default admin user synchronized from environment: %s", adminEmail)
return nil return nil
} }
// serveNextJS serves the NextJS static export from ./public.
// It handles /_next/ assets directly, tries {path}/index.html for page routes,
// and falls back to dynamic route pattern matching (NextJS [param] folders).
func serveNextJS(w http.ResponseWriter, r *http.Request) {
const publicDir = "./public"
urlPath := r.URL.Path
// ── /_next/ and other static assets ─────────────────────────────────────
if strings.HasPrefix(urlPath, "/_next/") {
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
return
}
staticExts := []string{
".js", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico",
".woff", ".woff2", ".ttf", ".eot", ".json", ".map", ".txt", ".xml",
}
for _, ext := range staticExts {
if strings.HasSuffix(urlPath, ext) {
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
return
}
}
// ── HTML page resolution ─────────────────────────────────────────────────
// NextJS static export with trailingSlash:true produces {path}/index.html
cleanPath := strings.TrimRight(urlPath, "/")
if cleanPath == "" {
cleanPath = "/"
}
// 1. Try exact {path}/index.html
candidate := filepath.Join(publicDir, filepath.FromSlash(cleanPath), "index.html")
if _, err := os.Stat(candidate); err == nil {
http.ServeFile(w, r, candidate)
return
}
// 2. Try {path}.html
candidate = filepath.Join(publicDir, filepath.FromSlash(cleanPath)+".html")
if _, err := os.Stat(candidate); err == nil {
http.ServeFile(w, r, candidate)
return
}
// 3. Walk path segments and try NextJS dynamic [param] folders
segments := strings.Split(strings.Trim(cleanPath, "/"), "/")
if result := findNextJSPage(publicDir, segments); result != "" {
http.ServeFile(w, r, result)
return
}
// 4. Fallback: root index.html
http.ServeFile(w, r, filepath.Join(publicDir, "index.html"))
}
// findNextJSPage recursively searches the public directory for an HTML file
// that matches the given URL segments, replacing unknown segments with any
// NextJS dynamic directory ([param] named folders).
func findNextJSPage(publicDir string, segments []string) string {
return searchNextJSSegments(publicDir, segments, 0, publicDir)
}
func searchNextJSSegments(publicDir string, segments []string, idx int, currentDir string) string {
if idx == len(segments) {
candidate := filepath.Join(currentDir, "index.html")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return ""
}
seg := segments[idx]
// Try the exact segment directory
exactDir := filepath.Join(currentDir, seg)
if result := searchNextJSSegments(publicDir, segments, idx+1, exactDir); result != "" {
return result
}
// Try NextJS dynamic segment directories.
// Matches both [param] (source convention) and __param__ (static export convention).
entries, err := os.ReadDir(currentDir)
if err != nil {
return ""
}
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() {
continue
}
isBracket := len(name) > 2 && name[0] == '[' && name[len(name)-1] == ']'
isPlaceholder := len(name) >= 4 && strings.HasPrefix(name, "__") && strings.HasSuffix(name, "__")
if isBracket || isPlaceholder {
dynamicDir := filepath.Join(currentDir, name)
if result := searchNextJSSegments(publicDir, segments, idx+1, dynamicDir); result != "" {
return result
}
}
}
return ""
}
+2 -2
View File
@@ -531,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
// TaskStatusDTO represents a task status in API responses. // TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct { type TaskStatusDTO struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"space_id"` TaskListID string `json:"task_list_id"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Order int `json:"order"` Order int `json:"order"`
@@ -621,7 +621,7 @@ func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{ return &TaskStatusDTO{
ID: status.ID.Hex(), ID: status.ID.Hex(),
SpaceID: status.SpaceID.Hex(), TaskListID: status.TaskListID.Hex(),
Name: status.Name, Name: status.Name,
Color: status.Color, Color: status.Color,
Order: status.Order, Order: status.Order,
@@ -46,8 +46,8 @@ func NewTaskService(
} }
} }
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -66,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
for idx, status := range defaults { for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{ if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: status.name, Name: status.name,
Color: status.color, Color: status.color,
Order: idx, Order: idx,
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
return nil return nil
} }
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) { func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
return status, nil return status, nil
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
return depth, nil return depth, nil
} }
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) { func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID { if currentStatusID == requestedStatusID {
return true, nil return true, nil
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -205,10 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID) parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil { if err != nil {
return nil, errors.New("invalid parent task") return nil, errors.New("invalid parent task")
@@ -226,6 +222,10 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,7 +252,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil { if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
return nil, validateErr return nil, validateErr
} }
statusID = parsedStatusID statusID = parsedStatusID
@@ -299,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
status, err := s.validateStatus(ctx, spaceID, task.StatusID) status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -331,9 +331,6 @@ func (s *TaskService) ListTasks(
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
filters := map[string]any{} filters := map[string]any{}
if taskListID != nil && strings.TrimSpace(*taskListID) != "" { if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
@@ -403,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil { if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found") return nil, errors.New("note not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID}) tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Collect statuses per task list
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
byID, ok := statusCache[taskListID]
if !ok {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil {
return nil
}
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, st := range statuses {
byID[st.ID] = st
}
statusCache[taskListID] = byID
}
return byID[statusID]
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks)) result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
status := statusByID[task.StatusID] status := getStatus(task.TaskListID, task.StatusID)
if status == nil { if status == nil {
continue continue
} }
@@ -509,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid status") return nil, errors.New("invalid status")
} }
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil { if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
return nil, err return nil, err
} }
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID) adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -582,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -812,25 +819,28 @@ func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, u
return errors.New("task list not found") return errors.New("task list not found")
} }
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID}) if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
if err != nil {
return err return err
} }
if len(tasks) > 0 {
return errors.New("cannot delete task list with tasks") if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
return err
} }
return s.taskListRepo.DeleteTaskList(ctx, taskListID) return s.taskListRepo.DeleteTaskList(ctx, taskListID)
} }
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil { if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -841,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
return result, nil return result, nil
} }
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -852,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
status := &entities.TaskStatus{ status := &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color), Color: strings.TrimSpace(req.Color),
Order: len(statuses), Order: len(statuses),
@@ -872,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -885,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
} }
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("task status not found") return nil, errors.New("task status not found")
} }
@@ -900,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error { func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err return err
} }
@@ -912,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return errors.New("insufficient permissions") return errors.New("insufficient permissions")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -932,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return err return err
} }
return s.normalizeStatusOrder(ctx, spaceID) return s.normalizeStatusOrder(ctx, taskListID)
} }
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -946,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -969,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
status := statusByID[statusID] status := statusByID[statusID]
if status == nil { if status == nil {
return nil, errors.New("status id does not belong to this space") return nil, errors.New("status id does not belong to this task list")
} }
if _, exists := seen[statusID]; exists { if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids") return nil, errors.New("duplicate status id in ordered_status_ids")
@@ -1000,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
} }
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1012,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
return result, nil return result, nil
} }
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
+2 -2
View File
@@ -25,10 +25,10 @@ type Task struct {
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskStatus defines the ordered linear status progression for a space. // TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct { type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"` ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"` TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"` Name string `bson:"name"`
Color string `bson:"color,omitempty"` Color string `bson:"color,omitempty"`
Order int `bson:"order"` Order int `bson:"order"`
@@ -225,6 +225,7 @@ type TaskRepository interface {
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) error UpdateTask(ctx context.Context, task *entities.Task) error
DeleteTask(ctx context.Context, id bson.ObjectID) error DeleteTask(ctx context.Context, id bson.ObjectID) error
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
} }
@@ -244,7 +245,8 @@ type TaskListRepository interface {
type TaskStatusRepository interface { type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
} }
@@ -95,6 +95,11 @@ func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error
return err return err
} }
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err return err
@@ -191,8 +196,7 @@ func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, space
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error { func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}},
}) })
return err return err
} }
@@ -227,8 +231,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
return &status, nil return &status, nil
} }
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) { func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}})) cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,14 +256,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
return err return err
} }
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error { func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
}) })
@@ -380,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID) statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -397,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.CreateTaskStatusRequest var req dto.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -404,7 +414,7 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req) status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -421,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
@@ -433,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req) status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -449,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
return return
} }
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil { if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -468,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.ReorderTaskStatusesRequest var req dto.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -475,7 +500,7 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
return return
} }
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs) statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
+4 -7
View File
@@ -1,15 +1,12 @@
# Frontend build stage # Frontend build stage
FROM node:25-alpine AS frontend-builder FROM node:25-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend_new
ARG VITE_API_BASE_URL COPY frontend_new/package*.json ./
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY frontend/package*.json ./
RUN npm install RUN npm install
COPY frontend/ . COPY frontend_new/ .
RUN npm run build RUN npm run build
# Backend build stage # Backend build stage
@@ -35,7 +32,7 @@ RUN apk --no-cache add ca-certificates
WORKDIR /root/ WORKDIR /root/
COPY --from=backend-builder /app/server . COPY --from=backend-builder /app/server .
COPY --from=frontend-builder /frontend/dist ./public COPY --from=frontend-builder /frontend_new/out ./public
EXPOSE 8080 EXPOSE 8080
-2
View File
@@ -36,8 +36,6 @@ services:
build: build:
context: . context: .
dockerfile: ./devops/docker/Dockerfile dockerfile: ./devops/docker/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: notely-app container_name: notely-app
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
-3
View File
@@ -1,8 +1,5 @@
# Frontend Environment Example # Frontend Environment Example
# API Base URL (Backend server)
VITE_API_BASE_URL=http://localhost:8080
# Environment # Environment
VITE_ENV=development VITE_ENV=development
+4 -1443
View File
File diff suppressed because it is too large Load Diff
@@ -66,24 +66,6 @@
max-height: 600px; max-height: 600px;
} }
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface)5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
.task-mention-panel { .task-mention-panel {
margin-top: 0.45rem; margin-top: 0.45rem;
border: 1px solid #dbe4f0; border: 1px solid #dbe4f0;
@@ -225,19 +207,6 @@
background-color: var(--color-surface); background-color: var(--color-surface);
} }
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel { :root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558; border-color: #3a4558;
background: #1f2733; background: #1f2733;
@@ -296,5 +265,3 @@
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%); background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%); color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
} }
@@ -289,24 +289,6 @@
align-items: center; align-items: center;
} }
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface) 5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.task-filters { .task-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -0,0 +1,32 @@
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
margin-bottom: 0;
}
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
+11 -13
View File
@@ -62,17 +62,17 @@
</div> </div>
<div v-if="mode === 'edit'" class="col-12"> <div v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2"> <DangerZonePanel
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2"> class="mt-4"
<div> title-id="danger-zone-title"
<div class="fw-semibold text-danger">Danger Zone</div> title="Danger Zone"
<div class="small text-muted">Permanently delete this provider configuration.</div> description="Permanently delete this provider configuration. This action cannot be undone."
</div> >
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)"> <button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button> </button>
</div> </DangerZonePanel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -179,6 +180,3 @@ const handleSubmit = () => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
+99 -16
View File
@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -85,24 +85,39 @@
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div> <div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -133,6 +148,20 @@ const error = ref("");
const success = ref(""); const success = ref("");
const newMember = ref({ user_id: "" }); const newMember = ref({ user_id: "" });
const deleting = ref(false); const deleting = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-"); const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
@@ -208,9 +237,20 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -236,10 +276,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -247,13 +292,51 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
@@ -0,0 +1,62 @@
<template>
<teleport to="body">
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
<i class="mdi mdi-alert-outline text-danger" aria-hidden="true"></i>
<span>{{ title }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
{{ busy ? busyLabel : confirmLabel }}
</button>
</div>
</div>
</div>
</div>
<div v-if="visible" class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "Confirm Deletion",
},
message: {
type: String,
default: "Are you sure you want to continue?",
},
confirmLabel: {
type: String,
default: "Delete",
},
cancelLabel: {
type: String,
default: "Cancel",
},
busyLabel: {
type: String,
default: "Deleting...",
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "confirm"]);
</script>
@@ -0,0 +1,24 @@
<template>
<section class="danger-zone" :aria-labelledby="titleId">
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
<p class="danger-zone-copy mb-3">{{ description }}</p>
<slot></slot>
</section>
</template>
<script setup>
defineProps({
titleId: {
type: String,
required: true,
},
title: {
type: String,
default: "Danger Zone",
},
description: {
type: String,
default: "This action is permanent and cannot be undone.",
},
});
</script>
@@ -0,0 +1,317 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Task List</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<!-- Task List Details -->
<div class="mb-4">
<label class="form-label" for="editTaskListName">Name</label>
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
</select>
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
</div>
<hr />
<!-- Status Progression -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<hr />
<!-- Danger Zone -->
<DangerZonePanel
v-if="canDeleteTaskList"
title-id="edit-task-list-danger-zone"
title="Danger Zone"
description="Delete this task list, all associated tasks, and statuses permanently."
>
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Task List
</button>
</DangerZonePanel>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
<!-- Status Create/Edit Sub-Modal -->
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="editStatusName">Status Name</label>
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
<div class="status-color-row">
<input id="editStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<DangerZonePanel
v-if="statusMode === 'edit'"
class="mt-4"
title-id="edit-status-danger-zone"
title="Danger Zone"
description="Deleting this status is permanent and cannot be undone."
>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</DangerZonePanel>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show" style="z-index: 1055"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
taskList: {
type: Object,
required: true,
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteTaskList: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
const listForm = ref({ name: "", category_id: null });
watch(
() => props.taskList,
(tl) => {
listForm.value = {
name: tl?.name || "",
category_id: tl?.category_id || null,
};
},
{ immediate: true },
);
const saveListDetails = () => {
const name = listForm.value.name?.trim();
if (!name) {
return;
}
emit("update-task-list", {
name,
category_id: listForm.value.category_id || null,
});
};
// Status drag-and-drop reorder
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const onStatusDragStart = (id) => {
draggedStatusId.value = id;
};
const onStatusDragOver = (id) => {
dragOverStatusId.value = id;
};
const onStatusDragLeave = (id) => {
if (dragOverStatusId.value === id) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((s) => s.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
// Status create/edit modal
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const statusForm = ref({ name: "", color: "#7c8596" });
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = { name: status.name || "", color: status.color || "#7c8596" };
showStatusModal.value = true;
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else if (editingStatusId.value) {
emit("rename-status", { id: editingStatusId.value, name, color });
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script>
<style scoped>
.status-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
cursor: grab;
}
.status-item.is-drag-over {
border-color: var(--bs-primary);
background: rgba(var(--bs-primary-rgb), 0.08);
}
.drag-handle {
cursor: grab;
opacity: 0.5;
font-size: 1.1rem;
line-height: 1;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-name {
flex: 1;
font-size: 0.9rem;
}
.status-actions {
margin-left: auto;
}
.status-color-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.status-color-row .form-control-color {
width: 40px;
padding: 0.25rem;
flex-shrink: 0;
}
</style>
+42 -4
View File
@@ -78,7 +78,7 @@
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i> <i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span> <span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span> <span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)"> <button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i> <i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -87,11 +87,14 @@
<!-- Hidden file input --> <!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" /> <input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div> </div>
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
spaceId: { spaceId: {
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
const newFolderName = ref(""); const newFolderName = ref("");
const fileInputRef = ref(null); const fileInputRef = ref(null);
const newFolderInputRef = ref(null); const newFolderInputRef = ref(null);
const showDeleteConfirmModal = ref(false);
const pendingDeleteItem = ref(null);
const deletingItem = ref(false);
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
if (!currentPrefix.value) return []; if (!currentPrefix.value) return [];
@@ -215,9 +221,37 @@ const createFolder = async () => {
} }
}; };
const deleteItem = async (obj) => { const requestDeleteItem = (obj) => {
const label = displayName(obj); if (!obj) {
if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return; return;
}
pendingDeleteItem.value = obj;
showDeleteConfirmModal.value = true;
};
const closeDeleteConfirmModal = () => {
if (deletingItem.value) {
return;
}
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
};
const deleteConfirmMessage = computed(() => {
const obj = pendingDeleteItem.value;
const label = obj ? displayName(obj) : "this item";
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
});
const confirmDeleteItem = async () => {
const obj = pendingDeleteItem.value;
if (!obj) {
return;
}
deletingItem.value = true;
error.value = ""; error.value = "";
try { try {
if (obj.is_folder) { if (obj.is_folder) {
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } }); await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
} }
await loadFiles(); await loadFiles();
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
} catch (e) { } catch (e) {
error.value = e.response?.data || "Delete failed"; error.value = e.response?.data || "Delete failed";
} finally {
deletingItem.value = false;
} }
}; };
+25 -7
View File
@@ -125,16 +125,22 @@
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" /> <input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
</div> </div>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title"> <DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3> <button class="btn btn-danger" type="button" @click="requestDelete">
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
<button class="btn btn-danger" type="button" @click="confirmDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note Delete Note
</button> </button>
</section> </DangerZonePanel>
</div> </div>
</div> </div>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
@close="showDeleteConfirmModal = false"
@confirm="confirmDelete"
/>
</template> </template>
<script setup> <script setup>
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore"; import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue"; import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
const showTaskPicker = ref(false); const showTaskPicker = ref(false);
const taskPickerQuery = ref(""); const taskPickerQuery = ref("");
const taskPickerLoading = ref(false); const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value); const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value); const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
@@ -374,13 +383,22 @@ const autoSave = () => {
detectTaskMention(); detectTaskMention();
}; };
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
};
const confirmDelete = () => { const confirmDelete = () => {
if (!props.canDelete) { if (!props.canDelete) {
return; return;
} }
if (confirm("Are you sure you want to delete this note?")) { if (!editingNote.value?.id) {
emit("delete", editingNote.value.id); return;
} }
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
}; };
/** Insert markdown snippet at the textarea cursor position. */ /** Insert markdown snippet at the textarea cursor position. */
+99 -13
View File
@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document"> <div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -76,13 +76,17 @@
<template v-if="canDeleteSpace"> <template v-if="canDeleteSpace">
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</template> </template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div> <div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
@@ -93,12 +97,23 @@
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -130,6 +145,20 @@ const success = ref("");
const memberForm = ref({ user_id: "" }); const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view")); const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage")); const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
watch( watch(
() => props.space, () => props.space,
@@ -224,13 +253,24 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
if (!canManageMembers.value) { if (!canManageMembers.value) {
return; return;
} }
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]); Promise.all([loadMembers(), loadUserOptions()]);
} }
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
+6 -183
View File
@@ -5,6 +5,7 @@
<h4 class="mb-0">Tasks</h4> <h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p> <p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div> </div>
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
</div> </div>
<div class="task-filters"> <div class="task-filters">
@@ -23,36 +24,6 @@
</select> </select>
</div> </div>
<div class="status-lane">
<div class="lane-header">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<div class="task-status-groups"> <div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div> <div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
@@ -184,42 +155,6 @@
</div> </div>
</section> </section>
</div> </div>
<teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="taskStatusName">Status Name</label>
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
<div class="status-color-row">
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
</teleport>
</section> </section>
</template> </template>
@@ -235,23 +170,18 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
selectedTaskList: {
type: Object,
default: null,
},
}); });
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]); const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
const filterStatus = ref(""); const filterStatus = ref("");
const filterParent = ref(""); const filterParent = ref("");
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const expandedTaskIds = ref({}); const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref(""); const openStatusMenuTaskId = ref("");
const statusForm = ref({
name: "",
color: "#7c8596",
});
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2)); const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => { const tasksById = computed(() => {
@@ -363,113 +293,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick); document.removeEventListener("click", onDocumentClick);
}); });
const onStatusDragStart = (statusId) => {
draggedStatusId.value = statusId;
};
const onStatusDragOver = (statusId) => {
dragOverStatusId.value = statusId;
};
const onStatusDragLeave = (statusId) => {
if (dragOverStatusId.value === statusId) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetStatusId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((item) => item.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetStatusId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetStatusId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
};
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = {
name: status.name || "",
color: status.color || "#7c8596",
};
showStatusModal.value = true;
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else {
if (!editingStatusId.value) {
return;
}
emit("rename-status", {
id: editingStatusId.value,
name,
color,
});
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style> <style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
@@ -0,0 +1,145 @@
<template>
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
<CreateCategoryModal
v-if="showCreateCategoryModal"
:category="editingCategory"
:parent-options="categoryParentOptions"
:parent-id="categoryModalParentId"
@close="emit('close-create-category')"
@submit="emit('submit-category', $event)"
/>
<CreateNoteModal
v-if="showCreateNoteModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-note')"
@create="emit('create-note', $event)"
/>
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-task-list')"
@create="emit('create-task-list', $event)"
/>
<SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace"
@close="emit('close-space-settings')"
@saved="emit('saved-space', $event)"
@deleted="emit('deleted-space', $event)"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:parent-task-options="taskParentOptions"
:subtasks="taskDetailSubtasks"
@close="emit('close-task-modal')"
@save-task="emit('save-task', $event)"
@delete-task="emit('delete-task', $event)"
@transition="emit('transition-task', $event)"
@create-subtask="emit('create-subtask', $event)"
@open-task="emit('open-task', $event)"
/>
</template>
<script setup>
import CreateSpaceModal from "../CreateSpaceModal.vue";
import CreateCategoryModal from "../CreateCategoryModal.vue";
import CreateNoteModal from "../CreateNoteModal.vue";
import CreateTaskListModal from "../CreateTaskListModal.vue";
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
import TaskDetailModal from "../TaskDetailModal.vue";
defineProps({
showCreateSpaceModal: {
type: Boolean,
default: false,
},
showCreateCategoryModal: {
type: Boolean,
default: false,
},
editingCategory: {
type: Object,
default: null,
},
categoryParentOptions: {
type: Array,
default: () => [],
},
categoryModalParentId: {
type: [String, Number, null],
default: null,
},
showCreateNoteModal: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
selectedCategoryId: {
type: [String, Number, null],
default: null,
},
showCreateTaskListModal: {
type: Boolean,
default: false,
},
showSpaceSettingsModal: {
type: Boolean,
default: false,
},
currentSpace: {
type: Object,
default: null,
},
canManageSpaceSettings: {
type: Boolean,
default: false,
},
showTaskModal: {
type: Boolean,
default: false,
},
taskModalDraft: {
type: Object,
default: null,
},
taskStatuses: {
type: Array,
default: () => [],
},
taskParentOptions: {
type: Array,
default: () => [],
},
taskDetailSubtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"close-create-space",
"create-space",
"close-create-category",
"submit-category",
"close-create-note",
"create-note",
"close-create-task-list",
"create-task-list",
"close-space-settings",
"saved-space",
"deleted-space",
"close-task-modal",
"save-task",
"delete-task",
"transition-task",
"create-subtask",
"open-task",
]);
</script>
@@ -0,0 +1,160 @@
<template>
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:selected-task-list="selectedTaskList"
@select-task="emit('select-task', $event)"
@filter-change="emit('filter-change', $event)"
@update-task-status="emit('update-task-status', $event)"
@edit-task-list="emit('edit-task-list')"
/>
<SearchResultsPage
v-else-if="isSearchRoute"
:items="searchItems"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@page-change="emit('page-change', $event)"
/>
<NoteEditor
v-else-if="selectedNote && isEditingNote"
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpaceId"
@save="emit('save-note', $event)"
@delete="emit('delete-note', $event)"
@cancel="emit('cancel-edit-note')"
@open-linked-task="emit('open-linked-task', $event)"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpaceId"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="emit('open-linked-task', $event)"
/>
<WorkspaceList
v-else
:items="displayedItems"
:can-load-more="canLoadMoreMainNotes"
:is-loading-more="isLoadingMoreMainNotes"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@load-more="emit('load-more')"
/>
</div>
</template>
<script setup>
import TaskBoard from "../TaskBoard.vue";
import SearchResultsPage from "../SearchResultsPage.vue";
import NoteEditor from "../NoteEditor.vue";
import NoteViewer from "../NoteViewer.vue";
import WorkspaceList from "../WorkspaceList.vue";
defineProps({
activeView: {
type: String,
required: true,
},
tasks: {
type: Array,
default: () => [],
},
taskStatuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
canDeleteTasks: {
type: Boolean,
default: false,
},
isSearchRoute: {
type: Boolean,
default: false,
},
searchItems: {
type: Array,
default: () => [],
},
searchQuery: {
type: String,
default: "",
},
searchPage: {
type: Number,
default: 1,
},
searchPageSize: {
type: Number,
default: 12,
},
noteViewMode: {
type: String,
default: "grid",
},
selectedNote: {
type: Object,
default: null,
},
isEditingNote: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteNotes: {
type: Boolean,
default: false,
},
currentSpaceId: {
type: String,
default: "",
},
linkedTasksForSelectedNote: {
type: Array,
default: () => [],
},
displayedItems: {
type: Array,
default: () => [],
},
canLoadMoreMainNotes: {
type: Boolean,
default: false,
},
isLoadingMoreMainNotes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"select-task",
"filter-change",
"update-task-status",
"edit-task-list",
"select-note",
"select-task-list",
"page-change",
"save-note",
"delete-note",
"cancel-edit-note",
"open-linked-task",
"load-more",
]);
</script>
+1
View File
@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css"; import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App); const app = createApp(App);
+129 -7
View File
@@ -73,7 +73,7 @@
<div class="user-row-actions"> <div class="user-row-actions">
<div class="d-flex gap-2 user-actions-stack"> <div class="d-flex gap-2 user-actions-stack">
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button> <button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button> <button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +286,16 @@
:deleting="deletingProviderModal" :deleting="deletingProviderModal"
@close="closeProviderModal" @close="closeProviderModal"
@submit="submitProviderModal" @submit="submitProviderModal"
@delete="deleteProviderFromModal" @delete="requestDeleteProvider"
/>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/> />
</template> </template>
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
import AdminGroupModal from "../components/AdminGroupModal.vue"; import AdminGroupModal from "../components/AdminGroupModal.vue";
import AdminUserModal from "../components/AdminUserModal.vue"; import AdminUserModal from "../components/AdminUserModal.vue";
import AdminProviderModal from "../components/AdminProviderModal.vue"; import AdminProviderModal from "../components/AdminProviderModal.vue";
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
const router = useRouter(); const router = useRouter();
const activeTab = ref("users"); const activeTab = ref("users");
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
const selectedProvider = ref(null); const selectedProvider = ref(null);
const submittingProviderModal = ref(false); const submittingProviderModal = ref(false);
const deletingProviderModal = ref(false); const deletingProviderModal = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const loadingFeatureFlags = ref(false); const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false); const savingFeatureFlags = ref(false);
@@ -365,6 +381,47 @@ const clearMessages = () => {
successMessage.value = ""; successMessage.value = "";
}; };
const deleteConfirmTitle = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
return "Delete User";
}
if (deleteConfirmIntent.value.type === "group") {
return "Delete Group";
}
if (deleteConfirmIntent.value.type === "provider") {
return "Delete Identity Provider";
}
return "Confirm Deletion";
});
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
const username = deleteConfirmIntent.value.payload?.username || "this user";
return `Delete user "${username}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "group") {
const name = deleteConfirmIntent.value.payload?.name || "this group";
return `Delete group "${name}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "provider") {
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
return `Delete identity provider "${name}"? This action cannot be undone.`;
}
return "Are you sure you want to continue?";
});
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const formatDate = (iso) => { const formatDate = (iso) => {
if (!iso) return ""; if (!iso) return "";
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
} }
}; };
const requestDeleteUser = (user) => {
if (!user?.id) {
return;
}
deleteConfirmIntent.value = {
type: "user",
payload: user,
};
showDeleteConfirmModal.value = true;
};
const deleteUser = async (user) => { const deleteUser = async (user) => {
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) { if (!user?.id) {
return; return;
} }
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
if (group.is_system) { if (group.is_system) {
return; return;
} }
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) { if (!group?.id) {
return; return;
} }
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
await Promise.all([loadGroups(), loadUsers()]); await Promise.all([loadGroups(), loadUsers()]);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete group."; error.value = e.response?.data || "Failed to delete group.";
throw e;
} }
}; };
const requestDeleteGroup = (group) => {
if (!group?.id || group.is_system) {
return;
}
deleteConfirmIntent.value = {
type: "group",
payload: group,
};
showDeleteConfirmModal.value = true;
};
const loadSpaces = async () => { const loadSpaces = async () => {
loadingSpaces.value = true; loadingSpaces.value = true;
clearMessages(); clearMessages();
@@ -631,12 +713,21 @@ const loadProviders = async () => {
} }
}; };
const deleteProviderFromModal = async (provider) => { const requestDeleteProvider = (provider) => {
if (!provider?.id) { if (!provider?.id) {
return; return;
} }
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) { closeProviderModal();
deleteConfirmIntent.value = {
type: "provider",
payload: { ...provider },
};
showDeleteConfirmModal.value = true;
};
const deleteProviderFromModal = async (provider) => {
if (!provider?.id) {
return; return;
} }
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
closeProviderModal(); closeProviderModal();
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete provider."; error.value = e.response?.data || "Failed to delete provider.";
throw e;
} finally { } finally {
deletingProviderModal.value = false; deletingProviderModal.value = false;
} }
}; };
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type || !payload) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "user") {
await deleteUser(payload);
} else if (type === "group") {
await deleteGroup(payload);
} else if (type === "provider") {
await deleteProviderFromModal(payload);
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
const loadFeatureFlags = async () => { const loadFeatureFlags = async () => {
loadingFeatureFlags.value = true; loadingFeatureFlags.value = true;
clearMessages(); clearMessages();
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
<template>
<div class="home-page">
<router-view />
</div>
</template>
<script setup></script>
+14 -2
View File
@@ -18,13 +18,25 @@ const routes = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/n/:noteId?",
name: "DashboardNote",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/t/:taskListId",
name: "DashboardTaskList",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "/search", path: "/search",
name: "Search", name: "Search",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
+3 -1
View File
@@ -1,8 +1,10 @@
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : "";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", baseURL: runtimeOrigin,
withCredentials: true, withCredentials: true,
}); });
+15 -15
View File
@@ -19,7 +19,7 @@ export const useSpaceStore = defineStore("space", () => {
const noteLinkedTasks = ref([]); const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => { const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -212,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = []; searchResults.value = [];
}; };
const fetchTaskStatuses = async (spaceId) => { const fetchTaskStatuses = async (spaceId, taskListId) => {
if (!spaceId) { if (!spaceId || !taskListId) {
taskStatuses.value = []; taskStatuses.value = [];
return []; return [];
} }
try { try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`); const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
return taskStatuses.value; return taskStatuses.value;
} catch (error) { } catch (error) {
@@ -261,25 +261,25 @@ export const useSpaceStore = defineStore("space", () => {
await fetchTaskLists(spaceId); await fetchTaskLists(spaceId);
}; };
const createTaskStatus = async (spaceId, payload) => { const createTaskStatus = async (spaceId, taskListId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
return response.data; return response.data;
}; };
const updateTaskStatus = async (spaceId, statusId, payload) => { const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload); const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
return response.data; return response.data;
}; };
const deleteTaskStatus = async (spaceId, statusId) => { const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`); await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
}; };
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => { const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, { const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
ordered_status_ids: orderedStatusIds, ordered_status_ids: orderedStatusIds,
}); });
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
distDir: "out",
images: {
unoptimized: true,
},
};
export default nextConfig;
+2281
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "noteapp-frontend-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@tiptap/extension-link": "^3.24.0",
"@tiptap/extension-mention": "^3.24.0",
"@tiptap/extension-placeholder": "^3.24.0",
"@tiptap/extension-table": "^3.24.0",
"@tiptap/extension-table-cell": "^3.24.0",
"@tiptap/extension-table-header": "^3.24.0",
"@tiptap/extension-table-row": "^3.24.0",
"@tiptap/extension-task-item": "^3.24.0",
"@tiptap/extension-task-list": "^3.24.0",
"@tiptap/pm": "^3.24.0",
"@tiptap/react": "^3.24.0",
"@tiptap/starter-kit": "^3.24.0",
"@tiptap/suggestion": "^3.24.0",
"axios": "^1.7.0",
"bootstrap": "^5.3.0",
"dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"next": "16.2.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tippy.js": "^6.3.7",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0"
}
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import Navbar from "@/components/Navbar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const hasPermission = useAuthStore((s) => s.hasPermission);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
ensureInitialized().then(() => {
const state = useAuthStore.getState();
if (!state.user) {
router.replace("/login");
} else if (!state.hasPermission("admin.access") && !state.hasPermission("*")) {
router.replace("/dashboard");
} else {
setAuthChecked(true);
}
});
}, []);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
return (
<div className="app-container">
<nav>
<Navbar />
</nav>
<div className="app-main d-flex flex-column" style={{ overflow: "hidden" }}>
{children}
</div>
</div>
);
}
+887
View File
@@ -0,0 +1,887 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import apiClient from "@/lib/apiClient";
import AdminUserModal from "@/components/AdminUserModal";
import AdminGroupModal from "@/components/AdminGroupModal";
import AdminSpaceModal from "@/components/AdminSpaceModal";
import AdminProviderModal from "@/components/AdminProviderModal";
import ConfirmActionModal from "@/components/ConfirmActionModal";
type AdminTab = "users" | "groups" | "spaces" | "providers" | "featureFlags";
interface AdminUser {
id: string;
username: string;
email: string;
is_active: boolean;
created_at: string;
group_ids?: string[];
}
interface AdminGroup {
id: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
}
interface AdminSpace {
id: string;
name: string;
description: string;
icon?: string;
is_public: boolean;
}
interface AuthProvider {
id: string;
name: string;
type?: string;
client_id?: string;
authorization_url?: string;
token_url?: string;
userinfo_url?: string;
id_token_claim?: string;
scopes?: string[];
is_active: boolean;
}
interface FeatureFlags {
registration_enabled: boolean;
provider_login_enabled: boolean;
public_sharing_enabled: boolean;
file_explorer_enabled: boolean;
s3_endpoint: string;
s3_bucket: string;
s3_region: string;
s3_access_key: string;
s3_secret_key: string;
s3_secret_key_set: boolean;
}
const TABS: Array<{ id: AdminTab; label: string }> = [
{ id: "users", label: "Users" },
{ id: "groups", label: "Permission Groups" },
{ id: "spaces", label: "Spaces" },
{ id: "providers", label: "Identity Providers" },
{ id: "featureFlags", label: "Feature Flags" },
];
const defaultFlags = (): FeatureFlags => ({
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
s3_endpoint: "",
s3_bucket: "",
s3_region: "",
s3_access_key: "",
s3_secret_key: "",
s3_secret_key_set: false,
});
export default function AdminPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<AdminTab>("users");
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
// Users
const [users, setUsers] = useState<AdminUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [submittingUser, setSubmittingUser] = useState(false);
// Groups
const [groups, setGroups] = useState<AdminGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
const [showGroupModal, setShowGroupModal] = useState(false);
const [groupModalMode, setGroupModalMode] = useState<"create" | "edit">("create");
const [selectedGroup, setSelectedGroup] = useState<AdminGroup | null>(null);
const [submittingGroup, setSubmittingGroup] = useState(false);
// Spaces
const [spaces, setSpaces] = useState<AdminSpace[]>([]);
const [loadingSpaces, setLoadingSpaces] = useState(false);
const [showSpaceModal, setShowSpaceModal] = useState(false);
const [selectedSpace, setSelectedSpace] = useState<AdminSpace | null>(null);
// Providers
const [providers, setProviders] = useState<AuthProvider[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
const [showProviderModal, setShowProviderModal] = useState(false);
const [providerModalMode, setProviderModalMode] = useState<"create" | "edit">("create");
const [selectedProvider, setSelectedProvider] = useState<AuthProvider | null>(null);
const [submittingProvider, setSubmittingProvider] = useState(false);
// Delete confirm
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmBusy, setConfirmBusy] = useState(false);
const [confirmIntent, setConfirmIntent] = useState<{
type: "user" | "group" | "provider";
payload: AdminUser | AdminGroup | AuthProvider | null;
}>({ type: "user", payload: null });
// Feature flags
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>(defaultFlags());
const [loadingFeatureFlags, setLoadingFeatureFlags] = useState(false);
const [savingFlags, setSavingFlags] = useState(false);
const flash = (msg: string) => {
setSuccessMessage(msg);
setTimeout(() => setSuccessMessage(""), 3500);
};
const clearError = () => setError("");
// ── Loaders ──────────────────────────────────────────────────────────────────
const loadUsers = useCallback(async () => {
setLoadingUsers(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/users");
setUsers(Array.isArray(res.data) ? res.data : res.data?.users || []);
} catch {
setError("Failed to load users.");
} finally {
setLoadingUsers(false);
}
}, []);
const loadGroups = useCallback(async () => {
setLoadingGroups(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/groups");
setGroups(Array.isArray(res.data) ? res.data : res.data?.groups || []);
} catch {
setError("Failed to load groups.");
} finally {
setLoadingGroups(false);
}
}, []);
const loadSpaces = useCallback(async () => {
setLoadingSpaces(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/spaces");
setSpaces(Array.isArray(res.data) ? res.data : res.data?.spaces || []);
} catch {
setError("Failed to load spaces.");
} finally {
setLoadingSpaces(false);
}
}, []);
const loadProviders = useCallback(async () => {
setLoadingProviders(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/auth/providers");
setProviders(res.data?.providers || []);
} catch {
setError("Failed to load providers.");
} finally {
setLoadingProviders(false);
}
}, []);
const loadFeatureFlagsData = useCallback(async () => {
setLoadingFeatureFlags(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/feature-flags");
setFeatureFlags({
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
});
} catch {
setError("Failed to load feature flags.");
} finally {
setLoadingFeatureFlags(false);
}
}, []);
useEffect(() => {
loadUsers();
loadGroups();
loadSpaces();
loadProviders();
loadFeatureFlagsData();
}, [loadUsers, loadGroups, loadSpaces, loadProviders, loadFeatureFlagsData]);
const selectTab = (tab: AdminTab) => {
setActiveTab(tab);
setShowMobileSidebar(false);
setError("");
};
// ── User CRUD ─────────────────────────────────────────────────────────────────
const openEditUser = (u: AdminUser) => {
setSelectedUser({ ...u });
setShowUserModal(true);
};
const submitEditUser = async ({ group_ids }: { group_ids: string[] }) => {
if (!selectedUser) return;
setSubmittingUser(true);
clearError();
try {
const res = await apiClient.put(`/api/v1/admin/users/${selectedUser.id}/groups`, { group_ids });
setUsers((prev) => prev.map((u) => (u.id === selectedUser.id ? { ...u, ...res.data } : u)));
flash("User updated.");
setShowUserModal(false);
setSelectedUser(null);
} catch {
setError("Failed to update user groups.");
} finally {
setSubmittingUser(false);
}
};
const requestDeleteUser = (u: AdminUser) => {
setConfirmIntent({ type: "user", payload: u });
setConfirmVisible(true);
};
// ── Group CRUD ────────────────────────────────────────────────────────────────
const openCreateGroup = () => {
setGroupModalMode("create");
setSelectedGroup(null);
setShowGroupModal(true);
};
const openEditGroup = (g: AdminGroup) => {
setGroupModalMode("edit");
setSelectedGroup({ ...g });
setShowGroupModal(true);
};
const submitGroupModal = async (data: { name: string; description: string; permissions: string[] }) => {
setSubmittingGroup(true);
clearError();
try {
if (groupModalMode === "create") {
await apiClient.post("/api/v1/admin/groups", data);
flash("Group created.");
} else {
await apiClient.put(`/api/v1/admin/groups/${selectedGroup!.id}`, data);
flash("Group updated.");
}
setShowGroupModal(false);
setSelectedGroup(null);
await Promise.all([loadGroups(), loadUsers()]);
} catch {
setError(`Failed to ${groupModalMode === "create" ? "create" : "update"} group.`);
} finally {
setSubmittingGroup(false);
}
};
const requestDeleteGroup = (g: AdminGroup) => {
if (g.is_system) return;
setConfirmIntent({ type: "group", payload: g });
setConfirmVisible(true);
};
// ── Space CRUD ────────────────────────────────────────────────────────────────
const openEditSpace = (s: AdminSpace) => {
setSelectedSpace({ ...s });
setShowSpaceModal(true);
};
const onSpaceSaved = (updated: AdminSpace) => {
setSpaces((prev) => prev.map((s) => (s.id === updated.id ? { ...s, ...updated } : s)));
setSelectedSpace(updated);
flash("Space updated.");
};
const onSpaceDeleted = (deleted: AdminSpace) => {
setSpaces((prev) => prev.filter((s) => s.id !== deleted.id));
setShowSpaceModal(false);
setSelectedSpace(null);
flash(`Space "${deleted.name}" deleted.`);
};
// ── Provider CRUD ─────────────────────────────────────────────────────────────
const openCreateProvider = () => {
setProviderModalMode("create");
setSelectedProvider(null);
setShowProviderModal(true);
};
const openEditProvider = (p: AuthProvider) => {
setProviderModalMode("edit");
setSelectedProvider({ ...p });
setShowProviderModal(true);
};
const submitProviderModal = async (formData: object) => {
setSubmittingProvider(true);
clearError();
try {
if (providerModalMode === "create") {
await apiClient.post("/api/v1/admin/auth/providers", formData);
flash("Provider added.");
} else {
await apiClient.put(`/api/v1/admin/auth/providers/${selectedProvider!.id}`, formData);
flash("Provider updated.");
}
setShowProviderModal(false);
setSelectedProvider(null);
await loadProviders();
} catch {
setError(`Failed to ${providerModalMode === "create" ? "add" : "update"} provider.`);
} finally {
setSubmittingProvider(false);
}
};
const requestDeleteProvider = (p: AuthProvider) => {
setShowProviderModal(false);
setConfirmIntent({ type: "provider", payload: { ...p } });
setConfirmVisible(true);
};
// ── Confirm delete ────────────────────────────────────────────────────────────
const confirmDelete = async () => {
if (confirmBusy) return;
setConfirmBusy(true);
clearError();
try {
const { type, payload } = confirmIntent;
if (type === "user") {
const u = payload as AdminUser;
await apiClient.delete(`/api/v1/admin/users/${u.id}`);
setUsers((prev) => prev.filter((x) => x.id !== u.id));
flash(`User "${u.username}" deleted.`);
} else if (type === "group") {
const g = payload as AdminGroup;
await apiClient.delete(`/api/v1/admin/groups/${g.id}`);
flash(`Group "${g.name}" deleted.`);
await Promise.all([loadGroups(), loadUsers()]);
} else if (type === "provider") {
const p = payload as AuthProvider;
await apiClient.delete(`/api/v1/admin/auth/providers/${p.id}`);
setProviders((prev) => prev.filter((x) => x.id !== p.id));
flash(`Provider "${p.name}" deleted.`);
}
setConfirmVisible(false);
setConfirmIntent({ type: "user", payload: null });
} catch {
setError("Delete failed.");
} finally {
setConfirmBusy(false);
}
};
const closeConfirm = () => {
if (confirmBusy) return;
setConfirmVisible(false);
};
// ── Feature Flags ─────────────────────────────────────────────────────────────
const saveFeatureFlags = async () => {
setSavingFlags(true);
clearError();
try {
const res = await apiClient.put("/api/v1/admin/feature-flags", {
registration_enabled: featureFlags.registration_enabled,
provider_login_enabled: featureFlags.provider_login_enabled,
public_sharing_enabled: featureFlags.public_sharing_enabled,
file_explorer_enabled: featureFlags.file_explorer_enabled,
s3_endpoint: featureFlags.s3_endpoint,
s3_bucket: featureFlags.s3_bucket,
s3_region: featureFlags.s3_region,
s3_access_key: featureFlags.s3_access_key,
s3_secret_key: featureFlags.s3_secret_key,
});
setFeatureFlags({
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
});
flash("Feature flags saved.");
} catch {
setError("Failed to save feature flags.");
} finally {
setSavingFlags(false);
}
};
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatDate = (d: string) => (d ? new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "—");
const getUserGroupSummary = (u: AdminUser) => {
const ids = u.group_ids || [];
if (!ids.length) return "No groups";
const names = ids.map((id) => groups.find((g) => g.id === id)?.name).filter(Boolean);
return names.length ? names.join(", ") : "No groups";
};
const confirmTitle = confirmIntent.type === "user" ? "Delete User" : confirmIntent.type === "group" ? "Delete Group" : "Delete Identity Provider";
const confirmMessage =
confirmIntent.type === "user"
? `Delete user "${(confirmIntent.payload as AdminUser)?.username}"? This cannot be undone.`
: confirmIntent.type === "group"
? `Delete group "${(confirmIntent.payload as AdminGroup)?.name}"? This cannot be undone.`
: `Delete identity provider "${(confirmIntent.payload as AuthProvider)?.name}"? This cannot be undone.`;
// ── Render ────────────────────────────────────────────────────────────────────
return (
<div className="admin-page">
<div className="admin-topbar d-flex justify-content-between align-items-center">
<button className="btn btn-outline-secondary d-md-none" type="button" aria-label="Open admin navigation" onClick={() => setShowMobileSidebar(true)}>
<i className="mdi mdi-menu" aria-hidden="true" />
</button>
<div className="d-flex align-items-start gap-2">
<div>
<h2 className="mb-1">Admin Panel</h2>
<p className="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
</div>
</div>
<button className="btn btn-outline-secondary" onClick={() => router.push("/dashboard")}>
Back to Notes
</button>
</div>
{error && <div className="alert alert-danger mx-3 mt-2">{error}</div>}
{successMessage && <div className="alert alert-success mx-3 mt-2">{successMessage}</div>}
<div className="admin-shell">
{showMobileSidebar && <div className="admin-sidebar-backdrop" onClick={() => setShowMobileSidebar(false)} />}
<aside className={`admin-sidebar${showMobileSidebar ? " open" : ""}`}>
<div className="admin-sidebar-inner">
<div className="d-flex justify-content-between align-items-center px-2 py-1 d-md-none">
<h6 className="mb-0">Admin Sections</h6>
<button type="button" className="btn-close" aria-label="Close" onClick={() => setShowMobileSidebar(false)} />
</div>
<nav className="nav nav-pills flex-column gap-1 admin-nav mt-2">
{TABS.map((tab) => (
<button key={tab.id} className={`nav-link text-start${activeTab === tab.id ? " active" : ""}`} onClick={() => selectTab(tab.id)}>
{tab.label}
</button>
))}
</nav>
</div>
</aside>
<main className="admin-content">
{/* ── Users ── */}
{activeTab === "users" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">All Users</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingUsers} onClick={loadUsers}>
Refresh
</button>
</div>
{loadingUsers ? (
<div className="text-muted small">Loading users</div>
) : users.length === 0 ? (
<div className="border rounded p-3 text-muted">No users found.</div>
) : (
<div className="list-group users-list">
{users.map((u) => (
<div key={u.id} className="list-group-item user-row">
<div className="user-row-main">
<div className="user-name-line">
<span className="fw-semibold user-name">{u.username}</span>
<span className={`badge ${u.is_active ? "text-bg-success" : "text-bg-secondary"}`}>{u.is_active ? "Active" : "Inactive"}</span>
</div>
<div className="user-meta-grid">
<div className="user-meta-item">
<div className="user-meta-label">Email</div>
<div className="user-meta-value">{u.email}</div>
</div>
<div className="user-meta-item">
<div className="user-meta-label">Joined</div>
<div className="user-meta-value">{formatDate(u.created_at)}</div>
</div>
<div className="user-meta-item">
<div className="user-meta-label">Groups</div>
<div className="user-meta-value">{getUserGroupSummary(u)}</div>
</div>
</div>
</div>
<div className="user-row-actions">
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditUser(u)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => requestDeleteUser(u)}>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Groups ── */}
{activeTab === "groups" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Permission Groups</h5>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-secondary" disabled={loadingGroups} onClick={loadGroups}>
Refresh
</button>
<button className="btn btn-sm btn-primary" onClick={openCreateGroup}>
Create Group
</button>
</div>
</div>
{loadingGroups ? (
<div className="text-muted small">Loading groups</div>
) : groups.length === 0 ? (
<div className="border rounded p-3 text-muted">No groups created yet.</div>
) : (
<div className="list-group">
{groups.map((g) => (
<div key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold d-flex align-items-center gap-2">
<span>{g.name}</span>
{g.is_system && <span className="badge text-bg-dark">System</span>}
</div>
<div className="small text-muted">{g.description || "No description"}</div>
<div className="small text-muted">
{(g.permissions || []).length} permission{(g.permissions || []).length === 1 ? "" : "s"}
</div>
</div>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditGroup(g)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" disabled={g.is_system} onClick={() => requestDeleteGroup(g)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Spaces ── */}
{activeTab === "spaces" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">All Spaces</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingSpaces} onClick={loadSpaces}>
Refresh
</button>
</div>
{loadingSpaces ? (
<div className="text-muted small">Loading spaces</div>
) : spaces.length === 0 ? (
<div className="border rounded p-3 text-muted">No spaces found.</div>
) : (
<div className="list-group">
{spaces.map((s) => (
<div key={s.id} className="list-group-item d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold">{s.name}</div>
<div className="small text-muted">{s.description || "No description"}</div>
</div>
<div className="d-flex align-items-center gap-2">
<span className={`badge ${s.is_public ? "text-bg-success" : "text-bg-secondary"}`}>{s.is_public ? "Public" : "Private"}</span>
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditSpace(s)}>
Edit Space
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Providers ── */}
{activeTab === "providers" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Identity Providers</h5>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-secondary" disabled={loadingProviders} onClick={loadProviders}>
Refresh
</button>
<button className="btn btn-sm btn-primary" onClick={openCreateProvider}>
<i className="mdi mdi-plus me-1" aria-hidden="true" />
Add Provider
</button>
</div>
</div>
{loadingProviders ? (
<div className="text-muted small">Loading providers</div>
) : providers.length === 0 ? (
<div className="border rounded p-3 text-muted">No providers configured yet.</div>
) : (
<div className="list-group">
{providers.map((p) => (
<div key={p.id} className="list-group-item d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
<i className={`mdi ${p.is_active ? "mdi-check-circle text-success" : "mdi-close-circle text-secondary"}`} aria-hidden="true" />
<span className="fw-semibold">{p.name}</span>
</div>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditProvider(p)}>
Edit
</button>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Feature Flags ── */}
{activeTab === "featureFlags" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Application Feature Flags</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingFeatureFlags} onClick={loadFeatureFlagsData}>
Refresh
</button>
</div>
{loadingFeatureFlags ? (
<div className="text-muted small">Loading feature flags</div>
) : (
<div className="d-grid gap-3">
<FlagItem
id="flag-registration"
title="Enable User Registration"
description="Controls whether new users can sign up from the register page."
checked={featureFlags.registration_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, registration_enabled: v }))}
/>
<FlagItem
id="flag-provider-login"
title="Enable Provider Login"
description="Controls OAuth/OIDC sign-in buttons and provider login endpoints."
checked={featureFlags.provider_login_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, provider_login_enabled: v }))}
/>
<FlagItem
id="flag-public-sharing"
title="Enable Public Sharing"
description="Reserved for public content controls and future sharing gates."
checked={featureFlags.public_sharing_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, public_sharing_enabled: v }))}
/>
{/* File Explorer with S3 config */}
<div className="feature-flag-item border rounded p-3">
<div className={`d-flex justify-content-between align-items-center${featureFlags.file_explorer_enabled ? " mb-3" : ""}`}>
<div>
<div className="fw-semibold">Enable File Explorer</div>
<div className="small text-muted">Allow users to browse and insert files from an S3 bucket directly into notes.</div>
</div>
<div className="form-check form-switch m-0">
<input
id="flag-file-explorer"
className="form-check-input"
type="checkbox"
checked={featureFlags.file_explorer_enabled}
onChange={(e) => setFeatureFlags((f) => ({ ...f, file_explorer_enabled: e.target.checked }))}
/>
</div>
</div>
{featureFlags.file_explorer_enabled && (
<div className="row g-2 mt-1">
<div className="col-md-6">
<label className="form-label small mb-1">S3 Endpoint URL</label>
<input
type="url"
className="form-control form-control-sm"
placeholder="https://s3.amazonaws.com or custom endpoint"
value={featureFlags.s3_endpoint}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_endpoint: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label small mb-1">Bucket Name</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="my-bucket"
value={featureFlags.s3_bucket}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_bucket: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Region</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="us-east-1"
value={featureFlags.s3_region}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_region: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Access Key</label>
<input
type="text"
className="form-control form-control-sm"
autoComplete="off"
value={featureFlags.s3_access_key}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_access_key: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Secret Key</label>
<input
type="password"
className="form-control form-control-sm"
placeholder={featureFlags.s3_secret_key_set ? "Leave blank to keep current secret" : "Enter secret key"}
autoComplete="new-password"
value={featureFlags.s3_secret_key}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_secret_key: e.target.value }))}
/>
{featureFlags.s3_secret_key_set && !featureFlags.s3_secret_key && (
<div className="small text-success mt-1">
<i className="mdi mdi-check-circle-outline" aria-hidden="true" /> Secret key is set
</div>
)}
</div>
</div>
)}
</div>
<div className="d-flex justify-content-end">
<button className="btn btn-primary" onClick={saveFeatureFlags} disabled={savingFlags}>
{savingFlags ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Saving
</>
) : (
"Save Changes"
)}
</button>
</div>
</div>
)}
</div>
</section>
)}
</main>
</div>
{/* Modals */}
{showUserModal && selectedUser && (
<AdminUserModal
user={selectedUser}
groups={groups}
submitting={submittingUser}
onClose={() => {
setShowUserModal(false);
setSelectedUser(null);
}}
onSubmit={submitEditUser}
/>
)}
{showGroupModal && (
<AdminGroupModal
mode={groupModalMode}
group={selectedGroup}
submitting={submittingGroup}
onClose={() => {
setShowGroupModal(false);
setSelectedGroup(null);
}}
onSubmit={submitGroupModal}
/>
)}
{showSpaceModal && selectedSpace && (
<AdminSpaceModal
space={selectedSpace}
users={users}
onClose={() => {
setShowSpaceModal(false);
setSelectedSpace(null);
}}
onSaved={onSpaceSaved}
onDeleted={onSpaceDeleted}
/>
)}
{showProviderModal && (
<AdminProviderModal
mode={providerModalMode}
provider={selectedProvider}
submitting={submittingProvider}
onClose={() => {
setShowProviderModal(false);
setSelectedProvider(null);
}}
onSubmit={submitProviderModal}
onDelete={requestDeleteProvider}
/>
)}
<ConfirmActionModal visible={confirmVisible} title={confirmTitle} message={confirmMessage} busy={confirmBusy} onClose={closeConfirm} onConfirm={confirmDelete} />
</div>
);
}
function FlagItem({ id, title, description, checked, onChange }: { id: string; title: string; description: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold">{title}</div>
<div className="small text-muted">{description}</div>
</div>
<div className="form-check form-switch m-0">
<input id={id} className="form-check-input" type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { Space, useSpaceStore } from "@/stores/spaceStore";
import Navbar from "@/components/Navbar";
import Sidebar from "@/components/Sidebar";
import SpaceSettingsModal from "@/components/SpaceSettingsModal";
import apiClient from "@/lib/apiClient";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const fetchSpaces = useSpaceStore((s) => s.fetchSpaces);
const currentSpace = useSpaceStore((s) => s.currentSpace!);
const [authChecked, setAuthChecked] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const navbarRef = useRef<HTMLElement>(null);
const [navbarHeight, setNavbarHeight] = useState(56);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
const [showSpaceSettingsModal, setShowSpaceSettingsModal] = useState(false);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
fetchSpaces();
}
});
}, []);
useEffect(() => {
const el = navbarRef.current;
if (!el) return;
setNavbarHeight(el.offsetHeight);
const ro = new ResizeObserver(() => setNavbarHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, [authChecked]);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
function handleCreateCategory(name: string) {
apiClient.post(`/api/v1/spaces/${currentSpace?.id}/categories`, { name }).then(() => {
useSpaceStore.getState().fetchCategories(currentSpace?.id || "");
});
}
function handleSpaceSaved(_updatedSpace: Space) {
useSpaceStore.getState().fetchSpaces();
setShowSpaceSettingsModal(false);
}
function handleSpaceDeleted() {
useSpaceStore.getState().fetchSpaces();
setShowSpaceSettingsModal(false);
}
return (
<>
<div className="app-container">
<nav ref={navbarRef}>
<Navbar onToggleSidebar={() => setShowSidebar((v) => !v)} showSidebarToggle />
</nav>
<div className="app-main d-flex">
<Sidebar
open={showSidebar}
onClose={() => setShowSidebar(false)}
navbarHeight={navbarHeight}
onOpenCreateCategory={() => setShowCreateCategoryModal(true)}
onOpenSpaceSettings={() => setShowSpaceSettingsModal(true)}
/>
<main className="main-content flex-grow-1">{children}</main>
</div>
</div>
{showCreateCategoryModal && (
<CreateCategoryModal
onClose={() => setShowCreateCategoryModal(false)}
onSave={(name) => {
handleCreateCategory(name);
setShowCreateCategoryModal(false);
}}
/>
)}
{showSpaceSettingsModal && currentSpace && (
<SpaceSettingsModal
space={currentSpace}
onClose={() => setShowSpaceSettingsModal(false)}
onSaved={handleSpaceSaved}
onDeleted={handleSpaceDeleted}
/>
)}
</>
);
}
function CreateCategoryModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
const [categoryName, setCategoryName] = useState("");
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") onSave(categoryName);
}
return (
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Create Category</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
<div className="mb-3">
<label htmlFor="categoryName" className="form-label">
Category Name
</label>
<input
type="text"
className="form-control"
id="categoryName"
placeholder="Enter category name"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="button" className="btn btn-primary" onClick={() => onSave(categoryName)} disabled={!categoryName.trim()}>
Create
</button>
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type TaskList, type Category } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const notes = useSpaceStore((s) => s.notes);
const taskLists = useSpaceStore((s) => s.taskLists);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const notesLoading = useSpaceStore((s) => s.notesLoading);
const notesHasMore = useSpaceStore((s) => s.notesHasMore);
const fetchNotes = useSpaceStore((s) => s.fetchNotes);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const hasPermission = useAuthStore((s) => s.hasPermission);
const [searchQuery, setSearchQuery] = useState(searchParams?.get("search") ?? "");
const [searchResults, setSearchResults] = useState<Note[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const canCreateNotes = hasPermission("*") || hasSpacePermission(currentSpace, "notes.create");
const canCreateTasklists = hasPermission("*") || hasSpacePermission(currentSpace, "tasklists.create");
const canCreateSpaces = hasPermission("*") || hasPermission("spaces.create");
useEffect(() => {
const q = searchParams?.get("search");
if (q && currentSpace) {
setSearchQuery(q);
doSearch(q, currentSpace.id);
} else {
setSearchQuery("");
setSearchResults([]);
}
}, [searchParams, currentSpace]);
async function doSearch(q: string, spaceId: string) {
if (!q.trim()) return;
setSearchLoading(true);
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/search`, {
params: { q, limit: 50 },
});
setSearchResults(res.data || []);
} catch {
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}
const isSearch = !!(searchQuery && searchParams?.get("search"));
// Group notes and task lists by category id
const notesByCategory: Record<string, Note[]> = {};
const taskListsByCategory: Record<string, TaskList[]> = {};
const uncategorizedNotes: Note[] = [];
const uncategorizedTaskLists: TaskList[] = [];
for (const note of notes) {
if (note.category_id) {
(notesByCategory[note.category_id] ??= []).push(note);
} else {
uncategorizedNotes.push(note);
}
}
for (const tl of taskLists) {
if (tl.category_id) {
(taskListsByCategory[tl.category_id] ??= []).push(tl);
} else {
uncategorizedTaskLists.push(tl);
}
}
// Flatten category tree for lookup
function flattenTree(cats: Category[]): Category[] {
const result: Category[] = [];
function walk(list: Category[]) {
for (const c of list) {
result.push(c);
const children = c.subcategories ?? c.children ?? [];
if (children.length) walk(children);
}
}
walk(cats);
return result;
}
const flatCategories = flattenTree(categoryTree);
// ── No spaces ──────────────────────────────────────────────────────────────
if (spaces.length === 0) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center py-5">
<i className="mdi mdi-folder-outline" style={{ fontSize: "5rem", color: "#6c757d", display: "block", marginBottom: "1rem" }} />
<h2 className="text-muted mb-3">No Spaces Yet</h2>
<p className="text-muted mb-4">Create a space to start organising your notes.</p>
{canCreateSpaces && (
<button className="btn btn-primary">
<i className="mdi mdi-plus-circle-outline me-2" />
Create Your First Space
</button>
)}
</div>
</div>
);
}
// ── No space selected ──────────────────────────────────────────────────────
if (!currentSpace) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center py-5">
<i className="mdi mdi-arrow-up-left" style={{ fontSize: "3rem", color: "#6c757d", display: "block", marginBottom: "1rem" }} />
<h4 className="text-muted">Select a space to get started</h4>
</div>
</div>
);
}
return (
<>
{/* Toolbar */}
<div className="toolbar p-3 border-bottom">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0 breadcrumb-title">{currentSpace.name}</h5>
</div>
</div>
</div>
{/* Content */}
<div className="content overflow-auto flex-grow-1">
{/* Search results */}
{isSearch ? (
<div className="p-3">
<div className="mb-3 text-muted small border-bottom pb-2">
Search results for <strong>&quot;{searchQuery}&quot;</strong>
{!searchLoading && `${searchResults.length} found`}
</div>
{searchLoading ? (
<div className="d-flex align-items-center justify-content-center p-5">
<div className="spinner-border text-secondary" role="status" />
</div>
) : searchResults.length === 0 ? (
<p className="text-muted">No results found.</p>
) : (
<div className="notes-grid">
{searchResults.map((note) => (
<NoteCard
key={note.id}
note={note}
onClick={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${note.id}`)}
/>
))}
</div>
)}
</div>
) : notesLoading && notes.length === 0 ? (
<div className="d-flex align-items-center justify-content-center p-5">
<div className="spinner-border text-secondary" role="status" />
</div>
) : (
<div className="p-3">
{/* Category sections */}
{flatCategories.map((cat) => {
const catNotes = notesByCategory[cat.id] ?? [];
const catTaskLists = taskListsByCategory[cat.id] ?? [];
return (
<CategorySection
key={cat.id}
category={cat}
notes={catNotes}
taskLists={catTaskLists}
canCreateNotes={canCreateNotes}
canCreateTasklists={canCreateTasklists}
onNoteClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${id}`)}
onTaskListClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/${id}`)}
onNewNote={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/new?categoryId=${cat.id}`)}
onNewTaskList={() => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/new?categoryId=${cat.id}`)}
/>
);
})}
{/* Uncategorized section — only show if there are items */}
{(uncategorizedNotes.length > 0 || uncategorizedTaskLists.length > 0) && (
<CategorySection
category={null}
notes={uncategorizedNotes}
taskLists={uncategorizedTaskLists}
canCreateNotes={canCreateNotes}
canCreateTasklists={canCreateTasklists}
onNoteClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${id}`)}
onTaskListClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/${id}`)}
onNewNote={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/new`)}
onNewTaskList={() => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/new`)}
/>
)}
{/* Empty space */}
{flatCategories.length === 0 && uncategorizedNotes.length === 0 && uncategorizedTaskLists.length === 0 && (
<div className="text-center py-5">
<i className="mdi mdi-note-outline" style={{ fontSize: "3rem", color: "#6c757d", display: "block" }} />
<p className="text-muted mt-2">No content yet. Create a category in the sidebar to get started.</p>
</div>
)}
{notesHasMore && (
<div className="text-center p-3">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => fetchNotes(currentSpace.id, { reset: false })}
disabled={notesLoading}
>
{notesLoading ? "Loading…" : "Load more notes"}
</button>
</div>
)}
</div>
)}
</div>
</>
);
}
function CategorySection({
category,
notes,
taskLists,
canCreateNotes,
canCreateTasklists,
onNoteClick,
onTaskListClick,
onNewNote,
onNewTaskList,
}: {
category: Category | null;
notes: Note[];
taskLists: TaskList[];
canCreateNotes: boolean;
canCreateTasklists: boolean;
onNoteClick: (id: string) => void;
onTaskListClick: (id: string) => void;
onNewNote: () => void;
onNewTaskList: () => void;
}) {
return (
<div className="category-section mb-4">
<div className="category-section-header d-flex align-items-center gap-2 mb-2 pb-1 border-bottom">
<i className="mdi mdi-folder-outline text-muted" style={{ fontSize: "1rem" }} />
<span className="fw-semibold text-muted" style={{ fontSize: "0.85rem", letterSpacing: "0.04em", textTransform: "uppercase" }}>
{category?.name ?? "Uncategorized"}
</span>
</div>
<div className="category-items-row">
{notes.map((note) => (
<NoteCard key={note.id} note={note} onClick={() => onNoteClick(note.id)} />
))}
{taskLists.map((tl) => (
<TaskListCard key={tl.id} taskList={tl} onClick={() => onTaskListClick(tl.id)} />
))}
{canCreateNotes && (
<CreateCard icon="mdi-note-plus-outline" label="New Note" onClick={onNewNote} />
)}
{canCreateTasklists && (
<CreateCard icon="mdi-format-list-checkbox" label="New Task List" onClick={onNewTaskList} />
)}
</div>
</div>
);
}
function NoteCard({ note, onClick }: { note: Note; onClick: () => void }) {
return (
<div className="content-card note-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon">
<i className="mdi mdi-note-text-outline" />
</div>
<div className="content-card-title">
{note.is_password_protected && <i className="mdi mdi-lock-outline me-1" style={{ fontSize: "0.8rem" }} />}
{note.title || "Untitled"}
</div>
<div className="content-card-meta">{new Date(note.updated_at).toLocaleDateString()}</div>
</div>
);
}
function TaskListCard({ taskList, onClick }: { taskList: TaskList; onClick: () => void }) {
return (
<div className="content-card task-list-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon">
<i className="mdi mdi-format-list-checkbox" />
</div>
<div className="content-card-title">{taskList.name}</div>
{taskList.description && <div className="content-card-meta">{taskList.description.slice(0, 60)}</div>}
</div>
);
}
function CreateCard({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<div className="content-card create-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon create-icon">
<i className={`mdi ${icon}`} />
</div>
<div className="content-card-title">{label}</div>
</div>
);
}
@@ -0,0 +1,572 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type Category, type TaskList } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
import RichTextEditor from "@/components/RichTextEditor";
/** Read real URL params from window.location useParams() returns static
* placeholder values in a Next.js static export. */
function getNoteParams(): { spaceId: string; noteId: string } {
if (typeof window === "undefined") return { spaceId: "", noteId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/notes\/([^/]+)/);
return { spaceId: m?.[1] ?? "", noteId: m?.[2] ?? "" };
}
type PasswordMode = "keep" | "set" | "remove";
export default function NotePage() {
const { spaceId, noteId } = getNoteParams();
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const user = useAuthStore((s) => s.user);
const hasPermission = useAuthStore((s) => s.hasPermission);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const fetchCategories = useSpaceStore((s) => s.fetchCategories);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const selectSpace = useSpaceStore((s) => s.selectSpace);
const [authChecked, setAuthChecked] = useState(false);
const [note, setNote] = useState<Note | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [isEditing, setIsEditing] = useState(false);
// Password-lock state
const [unlocked, setUnlocked] = useState(false);
const [unlockPassword, setUnlockPassword] = useState("");
const [unlockError, setUnlockError] = useState("");
const [unlocking, setUnlocking] = useState(false);
// Editor state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState<string>("");
const [isPinned, setIsPinned] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [passwordMode, setPasswordMode] = useState<PasswordMode>("keep");
const [newPassword, setNewPassword] = useState("");
const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "dirty">("saved");
// Task lists for @TaskList mentions
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flat list of categories for dropdown
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
// Ensure space is selected
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
} else {
fetchCategories(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
loadNote();
}, [authChecked]);
async function loadNote() {
setLoading(true);
setError("");
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
const n: Note = res.data;
setNote(n);
setUnlocked(!n.is_password_protected);
if (!n.is_password_protected) populateEditor(n);
loadTaskLists();
} catch (e: unknown) {
const err = e as { response?: { status: number } };
if (err?.response?.status === 403) {
setError("Access denied.");
} else {
setError("Failed to load note.");
}
} finally {
setLoading(false);
}
}
async function unlockNote() {
if (!unlockPassword.trim()) {
setUnlockError("Password is required.");
return;
}
setUnlocking(true);
setUnlockError("");
try {
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/notes/${noteId}/unlock`, {
password: unlockPassword,
});
const n: Note = res.data;
setNote(n);
populateEditor(n);
setUnlocked(true);
setUnlockPassword("");
} catch {
setUnlockError("Incorrect password.");
} finally {
setUnlocking(false);
}
}
async function loadTaskLists() {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
setTaskLists(Array.isArray(res.data) ? res.data : []);
} catch {
// ignore
}
}
async function fetchTasksForList(taskListId: string) {
const [tasksRes, statusRes] = await Promise.all([
apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params: { taskListId } }),
apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`),
]);
const tasks = Array.isArray(tasksRes.data) ? tasksRes.data : [];
const statuses = Array.isArray(statusRes.data) ? statusRes.data : [];
const statusMap = new Map(statuses.map((s: { id: string; name: string; color: string }) => [s.id, s]));
return tasks
.filter((t: { parent_task_id: string | null }) => !t.parent_task_id)
.map((t: { id: string; title: string; status_id: string }) => {
const status = statusMap.get(t.status_id) as { name: string; color: string } | undefined;
return {
id: t.id,
title: t.title,
statusColor: status?.color ?? "#7c8596",
statusName: status?.name ?? "",
};
});
}
function populateEditor(n: Note) {
setTitle(n.title);
setDescription(n.description ?? "");
setContent(n.content ?? "");
setTags((n.tags ?? []).join(", "));
setCategoryId(n.category_id ?? "");
setIsPinned(n.is_pinned);
setIsFavorite(n.is_favorite);
setIsPublic(n.is_public);
setPasswordMode("keep");
setNewPassword("");
setSaveStatus("saved");
}
function startEditing() {
if (note) populateEditor(note);
setIsEditing(true);
}
function cancelEditing() {
setIsEditing(false);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
}
// Auto-save with 3s debounce
const scheduleSave = useCallback(() => {
setSaveStatus("dirty");
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
performSave();
}, 3000);
}, [title, description, content, tags, categoryId, isPinned, isFavorite, isPublic, passwordMode, newPassword]);
async function performSave() {
setSaveStatus("saving");
try {
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const catId = categoryId || null;
const body: Record<string, unknown> = {
title,
description,
content,
tags: tagList,
category_id: catId,
is_pinned: isPinned,
is_favorite: isFavorite,
is_public: isPublic,
};
if (passwordMode === "set" && newPassword) {
body.note_password = newPassword;
} else if (passwordMode === "remove") {
body.note_password = "";
}
const res = await apiClient.put(`/api/v1/spaces/${spaceId}/notes/${noteId}`, body);
setNote(res.data);
setSaveStatus("saved");
} catch {
setSaveStatus("dirty");
}
}
async function deleteNote() {
if (!confirm("Delete this note? This cannot be undone.")) return;
try {
await apiClient.delete(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
router.push(`/dashboard`);
} catch {
alert("Failed to delete note.");
}
}
const canEdit = hasPermission("*") || hasSpacePermission(currentSpace, "notes.edit");
const canDelete = hasPermission("*") || hasSpacePermission(currentSpace, "notes.delete");
if (!authChecked || loading) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<div className="alert alert-danger">{error}</div>
<button className="btn btn-secondary" onClick={() => router.push("/dashboard")}>
Dashboard
</button>
</div>
);
}
if (!note) return null;
const flatCategories = flattenCategories(categoryTree);
// --- EDITOR MODE ---
if (isEditing) {
return (
<div className="note-editor-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
<button className="btn btn-sm btn-primary" onClick={performSave} disabled={saveStatus === "saving"}>
<i className="mdi mdi-content-save me-1" />
Save
</button>
<span className={`badge ms-2 ${saveStatus === "saved" ? "bg-success" : saveStatus === "saving" ? "bg-secondary" : "bg-warning text-dark"}`}>
{saveStatus === "saved" ? "Saved" : saveStatus === "saving" ? "Saving…" : "Unsaved"}
</span>
</div>
{/* Title */}
<input
className="form-control form-control-lg mb-2 note-title-input"
placeholder="Note title…"
value={title}
onChange={(e) => {
setTitle(e.target.value);
scheduleSave();
}}
maxLength={255}
/>
{/* Description */}
<textarea
className="form-control mb-2"
placeholder="Short description…"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
scheduleSave();
}}
maxLength={500}
/>
{/* WYSIWYG content editor */}
<RichTextEditor
key={noteId}
content={content}
onChange={(html) => {
setContent(html);
scheduleSave();
}}
placeholder="Write your note here…"
taskLists={taskLists}
spaceId={spaceId}
minHeight={450}
onFetchTasksForList={fetchTasksForList}
/>
{/* Metadata row */}
<div className="row g-3 mt-2">
<div className="col-md-4">
<label className="form-label small">Tags (comma-separated)</label>
<input
className="form-control form-control-sm"
placeholder="tag1, tag2…"
value={tags}
onChange={(e) => {
setTags(e.target.value);
scheduleSave();
}}
/>
</div>
<div className="col-md-4">
<label className="form-label small">Category</label>
<select
className="form-select form-select-sm"
value={categoryId}
onChange={(e) => {
setCategoryId(e.target.value);
scheduleSave();
}}
>
<option value="">Uncategorised</option>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label small">Password protection</label>
<select className="form-select form-select-sm" value={passwordMode} onChange={(e) => setPasswordMode(e.target.value as PasswordMode)}>
<option value="keep">Keep current</option>
<option value="set">Set new password</option>
<option value="remove">Remove password</option>
</select>
{passwordMode === "set" && (
<input
className="form-control form-control-sm mt-1"
type="password"
placeholder="New password (min 4 chars)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
/>
)}
</div>
</div>
<div className="d-flex gap-3 mt-2 ms-1">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPinned"
checked={isPinned}
onChange={(e) => {
setIsPinned(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isPinned">
Pinned
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isFavorite"
checked={isFavorite}
onChange={(e) => {
setIsFavorite(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isFavorite">
Favourite
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => {
setIsPublic(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isPublic">
Public
</label>
</div>
</div>
{/* Danger zone */}
{canDelete && (
<div className="border border-danger rounded p-3 mt-4">
<h6 className="text-danger mb-2">Danger Zone</h6>
<button className="btn btn-sm btn-danger" onClick={deleteNote}>
<i className="mdi mdi-delete-outline me-1" />
Delete Note
</button>
</div>
)}
</div>
);
}
// --- VIEWER MODE ---
const categoryLabel = flatCategories.find((c) => c.id === note.category_id)?.name;
// Password gate — show unlock form, hide all content and Edit button
if (note.is_password_protected && !unlocked) {
return (
<div className="note-viewer-container p-3">
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
</div>
<div className="d-flex justify-content-center mt-5">
<div className="card shadow-sm" style={{ maxWidth: 400, width: "100%" }}>
<div className="card-body p-4 text-center">
<i className="mdi mdi-lock-outline" style={{ fontSize: "3rem", color: "var(--color-primary)" }} />
<h5 className="mt-3 mb-1">{note.title}</h5>
<p className="text-muted small mb-4">This note is password protected. Enter the password to view it.</p>
<input
type="password"
className={`form-control mb-2${unlockError ? " is-invalid" : ""}`}
placeholder="Enter password…"
value={unlockPassword}
onChange={(e) => { setUnlockPassword(e.target.value); setUnlockError(""); }}
onKeyDown={(e) => e.key === "Enter" && unlockNote()}
autoFocus
/>
{unlockError && <div className="invalid-feedback d-block mb-2">{unlockError}</div>}
<button className="btn btn-primary w-100" onClick={unlockNote} disabled={unlocking}>
{unlocking ? "Unlocking…" : "Unlock"}
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="note-viewer-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3 flex-wrap">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
{canEdit && (
<button className="btn btn-sm btn-outline-primary" onClick={startEditing}>
<i className="mdi mdi-pencil-outline me-1" />
Edit
</button>
)}
</div>
{/* Note header */}
<div className="d-flex gap-2 align-items-center mb-2 flex-wrap">
<h2 className="note-viewer-title mb-1 flex-grow-1">{note.title}</h2>
<div className="ms-auto d-flex gap-2 align-items-center flex-wrap">
{note.is_pinned && (
<span className="badge bg-secondary">
<i className="mdi mdi-pin me-1" />
Pinned
</span>
)}
{note.is_favorite && (
<span className="badge bg-warning text-dark">
<i className="mdi mdi-star me-1" />
Favourite
</span>
)}
{note.is_public && (
<span className="badge bg-info text-dark">
<i className="mdi mdi-earth me-1" />
Public
</span>
)}
{!note.is_public && (
<span className="badge bg-secondary">
<i className="mdi mdi-lock-outline me-1" />
Private
</span>
)}
{note.is_password_protected && (
<span className="badge bg-warning text-dark">
<i className="mdi mdi-shield-key-outline me-1" />
Password
</span>
)}
</div>
</div>
{note.description && <p className="text-muted mb-2">{note.description}</p>}
<div className="d-flex gap-2 align-items-center mb-2 flex-wrap">
{(note.tags ?? []).map((tag) => (
<span key={tag} className="badge bg-secondary">
{tag}
</span>
))}
{categoryLabel && (
<span className="badge bg-light text-dark border">
<i className="mdi mdi-folder-outline me-1" />
{categoryLabel}
</span>
)}
<span className="text-muted small ms-auto">Updated {new Date(note.updated_at).toLocaleString()}</span>
</div>
<hr />
{/* WYSIWYG content (read-only) */}
<RichTextEditor
key={noteId}
content={note.content ?? ""}
readOnly
taskLists={taskLists}
spaceId={spaceId}
onNavigate={(path) => router.push(path)}
onFetchTasksForList={fetchTasksForList}
/>
</div>
);
}
@@ -0,0 +1,9 @@
import NotePageClient from "./NotePageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__", noteId: "__note__" }];
}
export default function NotePage() {
return <NotePageClient />;
}
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type Category, type TaskList } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
import RichTextEditor from "@/components/RichTextEditor";
/** Read real URL params from window.location useParams() returns static
* placeholder values in a Next.js static export. */
function getNoteParams(): { spaceId: string; noteId: string } {
if (typeof window === "undefined") return { spaceId: "", noteId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/notes\/([^/]+)/);
return { spaceId: m?.[1] ?? "", noteId: m?.[2] ?? "" };
}
type PasswordMode = "keep" | "set" | "remove";
export default function NewNotePage() {
const { spaceId, noteId } = getNoteParams();
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const user = useAuthStore((s) => s.user);
const hasPermission = useAuthStore((s) => s.hasPermission);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const fetchCategories = useSpaceStore((s) => s.fetchCategories);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const selectSpace = useSpaceStore((s) => s.selectSpace);
const [authChecked, setAuthChecked] = useState(false);
const [note, setNote] = useState<Note | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [isEditing, setIsEditing] = useState(false);
// Editor state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState<string>("");
const [isPinned, setIsPinned] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [passwordMode, setPasswordMode] = useState<PasswordMode>("keep");
const [newPassword, setNewPassword] = useState("");
const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "dirty">("saved");
// Task lists for @TaskList mentions
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flat list of categories for dropdown — computed before any hooks that depend on it
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
const flatCategories = flattenCategories(categoryTree);
// Set default category once when categories first load (must be before early returns)
useEffect(() => {
if (flatCategories.length > 0 && !categoryId) {
setCategoryId(flatCategories[0].id);
}
}, [flatCategories.length]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
// Ensure space is selected
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
} else {
fetchCategories(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
loadTaskLists();
}, [authChecked]);
async function loadTaskLists() {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
setTaskLists(Array.isArray(res.data) ? res.data : []);
setLoading(false);
} catch {
// ignore
}
}
async function fetchTasksForList(taskListId: string) {
const [tasksRes, statusRes] = await Promise.all([
apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params: { taskListId } }),
apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`),
]);
const tasks = Array.isArray(tasksRes.data) ? tasksRes.data : [];
const statuses = Array.isArray(statusRes.data) ? statusRes.data : [];
const statusMap = new Map(statuses.map((s: { id: string; name: string; color: string }) => [s.id, s]));
return tasks
.filter((t: { parent_task_id: string | null }) => !t.parent_task_id)
.map((t: { id: string; title: string; status_id: string }) => {
const status = statusMap.get(t.status_id) as { name: string; color: string } | undefined;
return {
id: t.id,
title: t.title,
statusColor: status?.color ?? "#7c8596",
statusName: status?.name ?? "",
};
});
}
function cancelEditing() {
router.push(`/dashboard/spaces/${spaceId}/notes`);
}
if (!authChecked || loading) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<div className="alert alert-danger">New Note Error: {error}</div>
<button className="btn btn-secondary" onClick={() => router.push("/dashboard")}>
Dashboard
</button>
</div>
);
}
if (flatCategories.length === 0) {
return (
<div className="p-4">
<div className="alert alert-warning">Please create a category before creating notes.</div>
</div>
);
}
async function performSave() {
setSaveStatus("saving");
try {
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const catId = categoryId || null;
const body: Record<string, unknown> = {
title,
description,
content,
tags: tagList,
category_id: catId,
is_pinned: isPinned,
is_favorite: isFavorite,
is_public: isPublic,
};
if (passwordMode === "set" && newPassword) {
body.note_password = newPassword;
} else if (passwordMode === "remove") {
body.note_password = "";
}
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/notes`, body);
setNote(res.data);
setSaveStatus("saved");
window.location.href = `/dashboard/spaces/${spaceId}/notes/${res.data.id}`;
} catch {
setSaveStatus("dirty");
}
}
return (
<div className="note-editor-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
<button className="btn btn-sm btn-primary" onClick={performSave} disabled={saveStatus === "saving"}>
<i className="mdi mdi-content-save me-1" />
Save
</button>
<span className={`badge ms-2 ${saveStatus === "saved" ? "bg-success" : saveStatus === "saving" ? "bg-secondary" : "bg-warning text-dark"}`}>
{saveStatus === "saved" ? "Saved" : saveStatus === "saving" ? "Saving…" : "Unsaved"}
</span>
</div>
{/* Title */}
<input
className="form-control form-control-lg mb-2 note-title-input"
placeholder="Note title…"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
maxLength={255}
/>
{/* Description */}
<textarea
className="form-control mb-2"
placeholder="Short description…"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
maxLength={500}
/>
{/* WYSIWYG content editor */}
<RichTextEditor
key={noteId}
content={content}
onChange={(html) => {
setContent(html);
}}
placeholder="Write your note here…"
taskLists={taskLists}
spaceId={spaceId}
minHeight={450}
onFetchTasksForList={fetchTasksForList}
/>
{/* Metadata row */}
<div className="row g-3 mt-2">
<div className="col-md-4">
<label className="form-label small">Tags (comma-separated)</label>
<input
className="form-control form-control-sm"
placeholder="tag1, tag2…"
value={tags}
onChange={(e) => {
setTags(e.target.value);
}}
/>
</div>
<div className="col-md-4">
<label className="form-label small">Category</label>
<select
className="form-select form-select-sm"
value={categoryId}
onChange={(e) => {
setCategoryId(e.target.value);
}}
>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label small">Password protection</label>
<select className="form-select form-select-sm" value={passwordMode} onChange={(e) => setPasswordMode(e.target.value as PasswordMode)}>
<option value="keep">Keep current</option>
<option value="set">Set new password</option>
<option value="remove">Remove password</option>
</select>
{passwordMode === "set" && (
<input
className="form-control form-control-sm mt-1"
type="password"
placeholder="New password (min 4 chars)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
/>
)}
</div>
</div>
<div className="d-flex gap-3 mt-2 ms-1">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPinned"
checked={isPinned}
onChange={(e) => {
setIsPinned(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isPinned">
Pinned
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isFavorite"
checked={isFavorite}
onChange={(e) => {
setIsFavorite(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isFavorite">
Favourite
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => {
setIsPublic(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isPublic">
Public
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import NewNotePageClient from "./NewNotePageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__" }];
}
export default function NewNotePage() {
return <NewNotePageClient />;
}
@@ -0,0 +1,9 @@
import TaskListPageClient from "./TaskListPageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__", taskListId: "__tasklist__" }];
}
export default function TaskListPage() {
return <TaskListPageClient />;
}
@@ -0,0 +1,132 @@
"use client";
import apiClient from "@/lib/apiClient";
import { useAuthStore } from "@/stores/authStore";
import { Category, useSpaceStore } from "@/stores/spaceStore";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
function getTaskListParams(): { spaceId: string; taskListId: string } {
if (typeof window === "undefined") return { spaceId: "", taskListId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/tasklists\/([^/]+)/);
return { spaceId: m?.[1] ?? "", taskListId: m?.[2] ?? "" };
}
export default function NewTaskListPageClient() {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const [authChecked, setAuthChecked] = useState(false);
const [tasklistName, setTasklistName] = useState("");
const [tasklistDescription, setTasklistDescription] = useState("");
const [tasklistCategory, setTasklistCategory] = useState("");
const { spaceId } = getTaskListParams();
const selectSpace = useSpaceStore((s) => s.selectSpace);
const categories = useSpaceStore((s) => s.categoryTree);
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
async function HandleSubmit(e: React.SubmitEvent) {
e.preventDefault();
const { spaceId } = getTaskListParams();
try {
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, {
name: tasklistName,
description: tasklistDescription,
category_id: tasklistCategory,
});
router.push(`/dashboard/spaces/${spaceId}/tasklists/${res.data.id}`);
} catch (error) {
console.error("Error creating task list:", error);
}
}
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
setTasklistCategory(categories[0]?.id ?? "");
}, [authChecked, categories]);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
const flatCategories = flattenCategories(categories);
if (flatCategories.length == 0) {
return (
<div className="p-4">
<div className="alert alert-warning">Please create a category before creating notes.</div>
</div>
);
}
return (
<div className="p-3">
<h1 className="h4">Create New Task List</h1>
<div className="card mb-3">
<div className="card-body">
<form onSubmit={HandleSubmit}>
<div className="mb-3">
<label htmlFor="taskListName" className="form-label">
Task List Name
</label>
<input type="text" className="form-control" id="taskListName" value={tasklistName} onChange={(e) => setTasklistName(e.target.value)} />
</div>
<div className="mb-3">
<label htmlFor="taskListDescription" className="form-label">
Description
</label>
<textarea className="form-control" id="taskListDescription" rows={3} value={tasklistDescription} onChange={(e) => setTasklistDescription(e.target.value)} />
</div>
<div className="mb-3">
<label htmlFor="taskListCategory" className="form-label">
Category
</label>
<select className="form-select" id="taskListCategory" value={tasklistCategory} onChange={(e) => setTasklistCategory(e.target.value)}>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import NewTaskListPageClient from "./NewTaskListPageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__" }];
}
export default function TaskListPage() {
return <NewTaskListPageClient />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css";
import "../styles/globals.css";
export const metadata: Metadata = {
title: "Notely",
description: "Note taking application",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
import apiClient from "@/lib/apiClient";
export default function LoginPage() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const loadFeatureFlags = useSettingsStore((s) => s.loadFeatureFlags);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; name: string }>>([]);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [providerLoginEnabled, setProviderLoginEnabled] = useState(true);
const handled = useRef(false);
useEffect(() => {
// apply saved theme
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
const init = async () => {
const flags = await loadFeatureFlags();
setRegistrationEnabled(!!flags.registration_enabled);
setProviderLoginEnabled(!!flags.provider_login_enabled);
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
return;
}
// Handle OAuth callback
if (!handled.current) {
handled.current = true;
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
if (status === "oauth_error") {
setError(params.get("message") || "Provider sign-in failed.");
return;
}
if (status === "oauth_success") {
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
} else {
setError("Provider sign-in returned an incomplete session.");
}
return;
}
}
// Load OAuth providers
if (flags.provider_login_enabled) {
try {
const res = await apiClient.get("/api/v1/auth/providers");
setProviders(res.data?.providers || []);
} catch {
setProviders([]);
}
}
// Show query message
const msg = new URLSearchParams(window.location.search).get("message");
if (msg) setError(msg);
};
init();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
router.replace("/dashboard");
} catch (err: unknown) {
setError(String(err));
} finally {
setLoading(false);
}
};
const startProviderLogin = (providerId: string) => {
window.location.href = `${window.location.origin}/api/v1/auth/providers/${providerId}/start`;
};
return (
<div className="login-page">
<div className="auth-container">
<div className="login-card">
<div className="brand-block">
<div className="brand-mark">
<i className="mdi mdi-note-text-outline" aria-hidden="true" />
</div>
<h1 className="brand-title">Notely</h1>
</div>
<h2 className="auth-title">Login</h2>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input id="email" type="email" className="form-control" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input id="password" type="password" className="form-control" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary w-100 auth-submit" disabled={loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Logging in
</>
) : (
"Login"
)}
</button>
</form>
{providerLoginEnabled && providers.length > 0 && (
<div className="mt-4">
<div className="oauth-divider">
<span>or continue with</span>
</div>
<div className="d-grid gap-2 mt-3">
{providers.map((provider) => (
<button key={provider.id} type="button" className="btn btn-outline-secondary auth-provider-btn" onClick={() => startProviderLogin(provider.id)}>
Sign in with {provider.name}
</button>
))}
</div>
</div>
)}
{registrationEnabled && (
<p className="text-center mt-4 mb-0 auth-switch-link">
Don&apos;t have an account? <Link href="/register">Register here</Link>
</p>
)}
</div>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
export default function RootPage() {
const router = useRouter();
const initialized = useAuthStore((s) => s.initialized);
const user = useAuthStore((s) => s.user);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
useEffect(() => {
ensureInitialized().then(() => {
if (useAuthStore.getState().user) {
router.replace("/dashboard");
} else {
router.replace("/login");
}
});
}, []);
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
export default function RegisterPage() {
const router = useRouter();
const register = useAuthStore((s) => s.register);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const loadFeatureFlags = useSettingsStore((s) => s.loadFeatureFlags);
const [form, setForm] = useState({
email: "",
username: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
const init = async () => {
const flags = await loadFeatureFlags();
setRegistrationEnabled(!!flags.registration_enabled);
if (!flags.registration_enabled) return;
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
}
};
init();
}, []);
const update = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!registrationEnabled) {
setError("Registration is currently disabled.");
return;
}
if (form.password !== form.confirmPassword) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
await register(form.email, form.username, form.password, form.firstName, form.lastName);
router.replace("/dashboard");
} catch (err: unknown) {
setError(String(err));
} finally {
setLoading(false);
}
};
return (
<div className="register-page">
<div className="register-container">
<div className="register-card">
<div className="brand-block">
<div className="brand-mark">
<i className="mdi mdi-note-text-outline" aria-hidden="true" />
</div>
<h1 className="brand-title">Notely</h1>
</div>
<h2 className="auth-title">Register</h2>
{!registrationEnabled && (
<div className="alert alert-warning">
Registration is currently disabled by an administrator.{" "}
<Link href="/login" className="alert-link">
Go to login
</Link>
</div>
)}
<form onSubmit={handleSubmit} className={!registrationEnabled ? "opacity-50" : ""}>
<div className="row mb-3">
<div className="col-12 col-md-6 mb-3 mb-md-0">
<label htmlFor="firstName" className="form-label">
First Name
</label>
<input id="firstName" type="text" className="form-control" value={form.firstName} onChange={update("firstName")} disabled={!registrationEnabled} />
</div>
<div className="col-12 col-md-6">
<label htmlFor="lastName" className="form-label">
Last Name
</label>
<input id="lastName" type="text" className="form-control" value={form.lastName} onChange={update("lastName")} disabled={!registrationEnabled} />
</div>
</div>
<div className="mb-3">
<label htmlFor="username" className="form-label">
Username
</label>
<input id="username" type="text" className="form-control" value={form.username} onChange={update("username")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input id="email" type="email" className="form-control" value={form.email} onChange={update("email")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input id="password" type="password" className="form-control" value={form.password} onChange={update("password")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="confirmPassword" className="form-label">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
className="form-control"
value={form.confirmPassword}
onChange={update("confirmPassword")}
required
disabled={!registrationEnabled}
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary w-100 auth-submit" disabled={!registrationEnabled || loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Registering
</>
) : (
"Register"
)}
</button>
</form>
<p className="text-center mt-4 mb-0 auth-switch-link">
Already have an account? <Link href="/login">Login here</Link>
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
interface AdminGroup {
id: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
}
interface Props {
mode: "create" | "edit";
group: AdminGroup | null;
submitting: boolean;
onClose: () => void;
onSubmit: (data: { name: string; description: string; permissions: string[] }) => void;
}
export default function AdminGroupModal({ mode, group, submitting, onClose, onSubmit }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionsText, setPermissionsText] = useState("");
const isSystem = mode === "edit" && !!group?.is_system;
useEffect(() => {
if (mode === "edit" && group) {
setName(group.name || "");
setDescription(group.description || "");
setPermissionsText((group.permissions || []).join("\n"));
} else {
setName("");
setDescription("");
setPermissionsText("");
}
}, [mode, group]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const permissions = permissionsText
.split(/\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
onSubmit({ name, description, permissions });
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{mode === "create" ? "Create Group" : "Edit Group"}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Group name</label>
<input className="form-control" type="text" required disabled={isSystem} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Description</label>
<input className="form-control" type="text" disabled={isSystem} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div>
<label className="form-label">Permissions (one per line)</label>
<textarea
className="form-control permissions-textarea"
rows={10}
placeholder={"space.create\nspace.project_docs.category.create\nspace.project_docs.*"}
disabled={isSystem}
value={permissionsText}
onChange={(e) => setPermissionsText(e.target.value)}
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
{!isSystem && (
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes"}
</button>
)}
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useEffect, useState } from "react";
interface AuthProvider {
id: string;
name: string;
type?: string;
client_id?: string;
authorization_url?: string;
token_url?: string;
userinfo_url?: string;
id_token_claim?: string;
scopes?: string[];
is_active: boolean;
}
interface ProviderForm {
name: string;
type: string;
client_id: string;
client_secret: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
id_token_claim: string;
scopes: string;
is_active: boolean;
}
const defaultForm = (): ProviderForm => ({
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
interface Props {
mode: "create" | "edit";
provider: AuthProvider | null;
submitting: boolean;
onClose: () => void;
onSubmit: (data: Omit<ProviderForm, "scopes"> & { scopes: string[] }) => void;
onDelete: (provider: AuthProvider) => void;
}
export default function AdminProviderModal({ mode, provider, submitting, onClose, onSubmit, onDelete }: Props) {
const [form, setForm] = useState<ProviderForm>(defaultForm());
useEffect(() => {
if (mode === "edit" && provider) {
setForm({
name: provider.name || "",
type: provider.type || "oidc",
client_id: provider.client_id || "",
client_secret: "",
authorization_url: provider.authorization_url || "",
token_url: provider.token_url || "",
userinfo_url: provider.userinfo_url || "",
id_token_claim: provider.id_token_claim || "id_token",
scopes: (provider.scopes || []).join(", "),
is_active: provider.is_active ?? true,
});
} else {
setForm(defaultForm());
}
}, [mode, provider]);
const set = (field: keyof ProviderForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
...form,
scopes: form.scopes
.split(",")
.map((s) => s.trim())
.filter(Boolean),
});
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{mode === "create" ? "Add Identity Provider" : "Edit Identity Provider"}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">
Display Name <span className="text-danger">*</span>
</label>
<input type="text" className="form-control" required value={form.name} onChange={set("name")} />
</div>
<div className="col-md-6">
<label className="form-label">
Provider Type <span className="text-danger">*</span>
</label>
<select className="form-select" value={form.type} onChange={set("type")}>
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">
Client ID <span className="text-danger">*</span>
</label>
<input type="text" className="form-control" required value={form.client_id} onChange={set("client_id")} />
</div>
<div className="col-md-6">
<label className="form-label">
Client Secret {mode === "create" ? <span className="text-danger">*</span> : <span className="text-muted small">(leave blank to keep existing)</span>}
</label>
<input
type="password"
className="form-control"
required={mode === "create"}
autoComplete="new-password"
value={form.client_secret}
onChange={set("client_secret")}
/>
</div>
<div className="col-md-6">
<label className="form-label">
Authorization URL <span className="text-danger">*</span>
</label>
<input type="url" className="form-control" required value={form.authorization_url} onChange={set("authorization_url")} />
</div>
<div className="col-md-6">
<label className="form-label">
Token URL <span className="text-danger">*</span>
</label>
<input type="url" className="form-control" required value={form.token_url} onChange={set("token_url")} />
</div>
<div className="col-md-6">
<label className="form-label">UserInfo URL</label>
<input type="url" className="form-control" placeholder="Optional" value={form.userinfo_url} onChange={set("userinfo_url")} />
</div>
<div className="col-md-6">
<label className="form-label">ID Token Claim</label>
<input type="text" className="form-control" placeholder="id_token" value={form.id_token_claim} onChange={set("id_token_claim")} />
</div>
<div className="col-12">
<label className="form-label">Scopes</label>
<input type="text" className="form-control" placeholder="openid, profile, email" value={form.scopes} onChange={set("scopes")} />
<div className="form-text">Comma-separated list of OAuth scopes.</div>
</div>
<div className="col-12">
<div className="form-check">
<input
id="provider-active"
type="checkbox"
className="form-check-input"
checked={form.is_active}
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.checked }))}
/>
<label htmlFor="provider-active" className="form-check-label">
Provider is active
</label>
</div>
</div>
{mode === "edit" && provider && (
<div className="col-12">
<div className="border border-danger rounded p-3 mt-2">
<h6 className="text-danger mb-1">Danger Zone</h6>
<p className="small text-muted mb-2">Permanently delete this provider configuration. This action cannot be undone.</p>
<button className="btn btn-danger" type="button" disabled={submitting} onClick={() => onDelete(provider)}>
<i className="mdi mdi-delete-outline me-1" aria-hidden="true" />
Delete Provider
</button>
</div>
</div>
)}
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : mode === "create" ? "Add Provider" : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,294 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import apiClient from "@/lib/apiClient";
import ConfirmActionModal from "./ConfirmActionModal";
interface AdminSpace {
id: string;
name: string;
description: string;
icon?: string;
is_public: boolean;
}
interface AdminUser {
id: string;
username: string;
}
interface SpaceMember {
user_id: string;
username?: string;
joined_at?: string;
}
interface Props {
space: AdminSpace;
users: AdminUser[];
onClose: () => void;
onSaved: (updated: AdminSpace) => void;
onDeleted: (space: AdminSpace) => void;
}
export default function AdminSpaceModal({ space, users, onClose, onSaved, onDeleted }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [savingSpace, setSavingSpace] = useState(false);
const [members, setMembers] = useState<SpaceMember[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [addingMember, setAddingMember] = useState(false);
const [removingMemberId, setRemovingMemberId] = useState("");
const [newUserId, setNewUserId] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// confirm dialog state
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmBusy, setConfirmBusy] = useState(false);
const [confirmIntent, setConfirmIntent] = useState<{ type: "member" | "space"; payload: SpaceMember | AdminSpace | null }>({ type: "space", payload: null });
const clearMessages = () => {
setError("");
setSuccess("");
};
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
clearMessages();
try {
const res = await apiClient.get(`/api/v1/admin/spaces/${space.id}/members`);
setMembers(res.data?.members || []);
} catch {
setError("Failed to load members.");
} finally {
setLoadingMembers(false);
}
}, [space.id]);
useEffect(() => {
setName(space.name || "");
setDescription(space.description || "");
setIcon(space.icon || "");
setIsPublic(!!space.is_public);
loadMembers();
}, [space, loadMembers]);
const saveSpace = async () => {
setSavingSpace(true);
clearMessages();
try {
const res = await apiClient.put(`/api/v1/admin/spaces/${space.id}`, { name, description, icon, is_public: isPublic });
setSuccess("Space updated.");
onSaved(res.data);
} catch {
setError("Failed to update space.");
} finally {
setSavingSpace(false);
}
};
const addMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUserId) return;
setAddingMember(true);
clearMessages();
try {
await apiClient.post(`/api/v1/admin/spaces/${space.id}/members`, { user_id: newUserId });
setSuccess("Member added.");
setNewUserId("");
await loadMembers();
} catch {
setError("Failed to add member.");
} finally {
setAddingMember(false);
}
};
const requestRemoveMember = (member: SpaceMember) => {
setConfirmIntent({ type: "member", payload: member });
setConfirmVisible(true);
};
const requestDeleteSpace = () => {
setConfirmIntent({ type: "space", payload: space });
setConfirmVisible(true);
};
const confirmAction = async () => {
if (confirmBusy) return;
setConfirmBusy(true);
try {
if (confirmIntent.type === "member") {
const member = confirmIntent.payload as SpaceMember;
setRemovingMemberId(member.user_id);
await apiClient.delete(`/api/v1/admin/spaces/${space.id}/members/${member.user_id}`);
setSuccess("Member removed.");
await loadMembers();
setRemovingMemberId("");
} else {
await apiClient.delete(`/api/v1/admin/spaces/${space.id}`);
onDeleted(space);
}
setConfirmVisible(false);
} catch {
setError("Action failed.");
setRemovingMemberId("");
} finally {
setConfirmBusy(false);
}
};
const selectableUsers = users.filter((u) => !members.some((m) => m.user_id === u.id));
const formatDate = (iso?: string) => (iso ? new Date(iso).toLocaleDateString() : "—");
const confirmTitle = confirmIntent.type === "member" ? "Remove Member" : "Delete Space";
const confirmMessage =
confirmIntent.type === "member"
? `Remove member "${(confirmIntent.payload as SpaceMember)?.username || (confirmIntent.payload as SpaceMember)?.user_id}" from this space?`
: `Permanently delete space "${space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget && !confirmVisible) onClose();
}}
>
<div className="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Edit Space</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
{/* Space settings */}
<div className="row g-3 mb-4">
<div className="col-md-5">
<label className="form-label">Name</label>
<input type="text" className="form-control" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="col-md-5">
<label className="form-label">Description</label>
<input type="text" className="form-control" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Icon</label>
<input type="text" className="form-control" maxLength={20} value={icon} onChange={(e) => setIcon(e.target.value)} />
</div>
<div className="col-12 d-flex justify-content-between align-items-center">
<div className="form-check form-switch">
<input id="admin-space-public" className="form-check-input" type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<label htmlFor="admin-space-public" className="form-check-label">
Public space
</label>
</div>
<button className="btn btn-primary" disabled={savingSpace} onClick={saveSpace}>
{savingSpace ? "Saving..." : "Save Space"}
</button>
</div>
</div>
<hr />
{/* Members */}
<div className="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 className="mb-0">Members</h6>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingMembers} onClick={loadMembers}>
Refresh
</button>
</div>
<form className="row g-2 align-items-end mb-3" onSubmit={addMember}>
<div className="col-md-10">
<label className="form-label form-label-sm mb-1">Username</label>
<select className="form-select form-select-sm" required value={newUserId} onChange={(e) => setNewUserId(e.target.value)}>
<option value="">Select user</option>
{selectableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={addingMember}>
{addingMember ? "..." : "Add"}
</button>
</div>
</form>
{loadingMembers ? (
<div className="text-muted small">Loading members...</div>
) : members.length === 0 ? (
<div className="text-muted small">No members found.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.user_id}>
<td>{m.username || m.user_id}</td>
<td className="small text-muted">{formatDate(m.joined_at)}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-danger" disabled={removingMemberId === m.user_id} onClick={() => requestRemoveMember(m)}>
{removingMemberId === m.user_id ? "Removing..." : "Remove"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && <div className="alert alert-danger mt-3 mb-0">{error}</div>}
{success && <div className="alert alert-success mt-3 mb-0">{success}</div>}
<hr />
{/* Danger zone */}
<div className="border border-danger rounded p-3 mt-4">
<h6 className="text-danger mb-1">Danger Zone</h6>
<p className="small text-muted mb-2">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
<button className="btn btn-danger" type="button" onClick={requestDeleteSpace}>
<i className="mdi mdi-delete-outline me-1" aria-hidden="true" />
Delete Space
</button>
</div>
</div>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
<ConfirmActionModal
visible={confirmVisible}
title={confirmTitle}
message={confirmMessage}
busy={confirmBusy}
onClose={() => {
if (!confirmBusy) setConfirmVisible(false);
}}
onConfirm={confirmAction}
/>
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
interface Group {
id: string;
name: string;
}
interface AdminUser {
id: string;
username: string;
email: string;
is_active: boolean;
group_ids?: string[];
}
interface Props {
user: AdminUser | null;
groups: Group[];
submitting: boolean;
onClose: () => void;
onSubmit: (data: { group_ids: string[] }) => void;
}
export default function AdminUserModal({ user, groups, submitting, onClose, onSubmit }: Props) {
const [groupIds, setGroupIds] = useState<string[]>([]);
useEffect(() => {
setGroupIds([...(user?.group_ids || [])]);
}, [user]);
if (!user) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ group_ids: groupIds });
};
const toggleGroup = (id: string) => {
setGroupIds((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]));
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Edit User</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Username</label>
<input className="form-control" type="text" value={user.username} disabled readOnly />
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input className="form-control" type="text" value={user.email} disabled readOnly />
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<input className="form-control" type="text" value={user.is_active ? "Active" : "Inactive"} disabled readOnly />
</div>
<div>
<label className="form-label">Groups</label>
<select
className="form-select"
multiple
size={Math.max(4, groups.length)}
value={groupIds}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
setGroupIds(selected);
}}
>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<div className="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,66 @@
"use client";
interface Props {
visible: boolean;
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
busyLabel?: string;
busy?: boolean;
onClose: () => void;
onConfirm: () => void;
}
export default function ConfirmActionModal({
visible,
title = "Confirm",
message = "Are you sure you want to continue?",
confirmLabel = "Delete",
cancelLabel = "Cancel",
busyLabel = "Deleting...",
busy = false,
onClose,
onConfirm,
}: Props) {
if (!visible) return null;
return (
<>
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1060 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center gap-2 mb-0">
<i className="mdi mdi-alert-outline text-danger" aria-hidden="true" />
<span>{title}</span>
</h5>
<button type="button" className="btn-close" aria-label="Close" disabled={busy} onClick={onClose} />
</div>
<div className="modal-body">
<p className="text-muted mb-0">{message}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" disabled={busy} onClick={onClose}>
{cancelLabel}
</button>
<button type="button" className="btn btn-danger" disabled={busy} onClick={onConfirm}>
{busy ? busyLabel : confirmLabel}
</button>
</div>
</div>
</div>
</div>
<div className="modal-backdrop fade show" style={{ zIndex: 1055 }} />
</>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Space } from "@/stores/spaceStore";
interface NavbarProps {
onToggleSidebar?: () => void;
showSidebarToggle?: boolean;
}
export default function Navbar({ onToggleSidebar, showSidebarToggle }: NavbarProps) {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const isAdmin = useAuthStore((s) => s.hasPermission("admin.access") || s.hasPermission("*"));
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const selectSpace = useSpaceStore((s) => s.selectSpace);
const [showSpaceDropdown, setShowSpaceDropdown] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isDarkMode, setIsDarkMode] = useState(false);
const spaceDropdownRef = useRef<HTMLDivElement>(null);
const userDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const theme = document.documentElement.getAttribute("data-bs-theme");
setIsDarkMode(theme === "dark");
}, []);
useEffect(() => {
const theme = isDarkMode ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
localStorage.setItem("theme", theme);
}, [isDarkMode]);
// Close dropdowns on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (spaceDropdownRef.current && !spaceDropdownRef.current.contains(e.target as Node)) {
setShowSpaceDropdown(false);
}
if (userDropdownRef.current && !userDropdownRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleLogout = () => {
logout();
router.replace("/login");
};
const handleSpaceSelect = async (space: Space) => {
setShowSpaceDropdown(false);
await selectSpace(space.id);
router.push("/dashboard");
};
const performSearch = () => {
if (searchQuery.trim()) {
router.push(`/dashboard?search=${encodeURIComponent(searchQuery.trim())}`);
}
};
return (
<nav className="navbar navbar-dark bg-dark sticky-top">
<div className="container-fluid app-navbar">
<div className="navbar-left d-flex align-items-center gap-2">
{showSidebarToggle && currentSpace && (
<button className="btn btn-outline-light d-md-none nav-menu-toggle" type="button" aria-label="Toggle sidebar" onClick={onToggleSidebar}>
<i className="mdi mdi-menu" aria-hidden="true" />
</button>
)}
<span className="navbar-brand mb-0 h1 d-flex align-items-center gap-2 app-brand">
<i className="mdi mdi-notebook-outline" aria-hidden="true" />
<span>Notely</span>
</span>
</div>
<div className="navbar-controls d-flex align-items-center gap-3">
{/* Space Selector */}
{user && (
<div ref={spaceDropdownRef} className="dropdown nav-space-selector">
<button className="btn btn-outline-light dropdown-toggle" type="button" onClick={() => setShowSpaceDropdown((v) => !v)}>
{currentSpace ? currentSpace.name : "Select Space"}
</button>
<ul className={`dropdown-menu${showSpaceDropdown ? " show" : ""}`}>
{spaces.map((space) => (
<li key={space.id}>
<button className="dropdown-item" onClick={() => handleSpaceSelect(space)}>
{space.name}
</button>
</li>
))}
</ul>
</div>
)}
{/* Search */}
<div className="search-box nav-search">
<input
type="text"
className="form-control"
placeholder="Search notes &amp; task lists…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && performSearch()}
/>
</div>
{/* Theme Toggle */}
<button className="btn btn-outline-light" type="button" aria-label={isDarkMode ? "Switch to light mode" : "Switch to dark mode"} onClick={() => setIsDarkMode((v) => !v)}>
<i className={`mdi ${isDarkMode ? "mdi-weather-sunny" : "mdi-weather-night"}`} aria-hidden="true" />
</button>
{/* User Menu */}
{user && (
<div ref={userDropdownRef} className="dropdown nav-user-menu">
<button className="btn btn-outline-light dropdown-toggle" type="button" onClick={() => setShowUserMenu((v) => !v)}>
{user.username}
</button>
<ul className={`dropdown-menu dropdown-menu-end${showUserMenu ? " show" : ""}`}>
{isAdmin && (
<>
<li>
<button
className="dropdown-item"
onClick={() => {
setShowUserMenu(false);
router.push("/admin");
}}
>
Admin Panel
</button>
</li>
<li>
<hr className="dropdown-divider" />
</li>
</>
)}
<li>
<button className="dropdown-item" onClick={handleLogout}>
Logout
</button>
</li>
</ul>
</div>
)}
</div>
</div>
</nav>
);
}
@@ -0,0 +1,618 @@
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useEditor, EditorContent, ReactRenderer, mergeAttributes } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { Link } from "@tiptap/extension-link";
import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
import { Placeholder } from "@tiptap/extension-placeholder";
import { Mention } from "@tiptap/extension-mention";
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import "tippy.js/dist/tippy.css";
/* ─── Types ────────────────────────────────────────────────────────────── */
export interface TaskListItem {
id: string;
name: string;
}
export interface TaskPickerItem {
id: string;
title: string;
statusColor: string;
statusName: string;
}
interface RichTextEditorProps {
content: string;
onChange?: (html: string) => void;
readOnly?: boolean;
placeholder?: string;
taskLists?: TaskListItem[];
spaceId?: string;
onNavigate?: (path: string) => void;
onFetchTasksForList?: (taskListId: string) => Promise<TaskPickerItem[]>;
minHeight?: number;
}
/* ─── Extended Mention node with statusColor + taskId attrs ──────────── */
const TaskMention = Mention.extend({
addAttributes() {
return {
...this.parent?.(),
statusColor: {
default: null,
parseHTML: (el) => el.getAttribute("data-status-color"),
renderHTML: (attrs) => (attrs.statusColor ? { "data-status-color": attrs.statusColor } : {}),
},
taskId: {
default: null,
parseHTML: (el) => el.getAttribute("data-task-id"),
renderHTML: (attrs) => (attrs.taskId ? { "data-task-id": attrs.taskId } : {}),
},
};
},
renderHTML({ node, HTMLAttributes }) {
const color = node.attrs.statusColor ?? "#7c8596";
return [
"span",
mergeAttributes(
{
class: "tasklist-mention-node",
"data-type": "mention",
style: `--status-color:${color}`,
},
HTMLAttributes,
),
[
"i",
{
class: "mdi mdi-circle",
style: `color:${color}`,
"aria-hidden": "true",
},
],
node.attrs.label ?? "",
];
},
parseHTML() {
return [{ tag: 'span[data-type="mention"]' }];
},
});
/* ─── Two-stage mention dropdown ──────────────────────────────────────── */
interface MentionListHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}
type MentionListProps = SuggestionProps<TaskListItem> & {
fetchTasks: ((taskListId: string) => Promise<TaskPickerItem[]>) | undefined;
};
const MentionList = forwardRef<MentionListHandle, MentionListProps>(({ items, command, fetchTasks }, ref) => {
const [stage, setStage] = useState<"list" | "task">("list");
const [selectedList, setSelectedList] = useState<TaskListItem | null>(null);
const [tasks, setTasks] = useState<TaskPickerItem[]>([]);
const [loadingTasks, setLoadingTasks] = useState(false);
const [cursor, setCursor] = useState(0);
const stageRef = useRef(stage);
const tasksRef = useRef(tasks);
const itemsRef = useRef(items);
useEffect(() => {
stageRef.current = stage;
}, [stage]);
useEffect(() => {
tasksRef.current = tasks;
}, [tasks]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
async function pickList(list: TaskListItem) {
setSelectedList(list);
setCursor(0);
setTasks([]);
if (!fetchTasks) {
command({ id: list.id, label: list.name } as never);
return;
}
setStage("task");
setLoadingTasks(true);
try {
const fetched = await fetchTasks(list.id);
setTasks(fetched);
} finally {
setLoadingTasks(false);
}
}
function pickTask(task: TaskPickerItem) {
if (!selectedList) return;
command({
id: selectedList.id,
label: `${selectedList.name}${task.title}`,
statusColor: task.statusColor,
taskId: task.id,
} as never);
}
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
const cur = stageRef.current;
const list = cur === "list" ? itemsRef.current : tasksRef.current;
if (event.key === "ArrowUp") {
setCursor((s) => (s - 1 + Math.max(list.length, 1)) % Math.max(list.length, 1));
return true;
}
if (event.key === "ArrowDown") {
setCursor((s) => (s + 1) % Math.max(list.length, 1));
return true;
}
if (event.key === "Enter") {
if (cur === "list") {
const item = itemsRef.current[cursor];
if (item) {
pickList(item);
}
} else {
const task = tasksRef.current[cursor];
if (task) pickTask(task);
}
return true;
}
if ((event.key === "Backspace" || event.key === "Escape") && cur === "task") {
setStage("list");
setCursor(0);
return true;
}
return false;
},
}));
if (!items.length && stage === "list") return null;
return (
<div className="rte-mention-dropdown">
{stage === "list" ? (
<>
<div className="rte-mention-header">Task Lists</div>
{items.map((tl, i) => (
<button
key={tl.id}
className={`rte-mention-item${i === cursor ? " is-selected" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
pickList(tl);
}}
>
<i className="mdi mdi-format-list-checks me-2" />
{tl.name}
<i className="mdi mdi-chevron-right ms-auto opacity-50" />
</button>
))}
</>
) : (
<>
<div className="rte-mention-header d-flex align-items-center gap-1">
<button
className="rte-mention-back"
onMouseDown={(e) => {
e.preventDefault();
setStage("list");
setCursor(0);
}}
>
<i className="mdi mdi-arrow-left" />
</button>
<span>{selectedList?.name}</span>
</div>
{loadingTasks ? (
<div className="rte-mention-loading">
<span className="spinner-border spinner-border-sm me-2" />
Loading tasks
</div>
) : tasks.length === 0 ? (
<div className="rte-mention-empty">No tasks found</div>
) : (
tasks.map((task, i) => (
<button
key={task.id}
className={`rte-mention-item${i === cursor ? " is-selected" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
pickTask(task);
}}
>
<span className="rte-status-dot me-2" style={{ background: task.statusColor }} title={task.statusName} />
{task.title}
<span className="rte-mention-status-label ms-auto">{task.statusName}</span>
</button>
))
)}
</>
)}
</div>
);
});
MentionList.displayName = "MentionList";
/* ─── Build suggestion config ─────────────────────────────────────────── */
function buildSuggestion(
taskListsRef: React.MutableRefObject<TaskListItem[]>,
fetchTasksRef: React.MutableRefObject<((id: string) => Promise<TaskPickerItem[]>) | undefined>,
): Partial<SuggestionOptions<TaskListItem>> {
return {
char: "@",
allowSpaces: true,
items: ({ query }) => taskListsRef.current.filter((tl) => tl.name.toLowerCase().includes(query.toLowerCase())).slice(0, 10),
render: () => {
let component: ReactRenderer<MentionListHandle, MentionListProps>;
let popup: TippyInstance[];
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props: { ...props, fetchTasks: fetchTasksRef.current } as MentionListProps,
editor: props.editor,
});
if (!props.clientRect) return;
popup = tippy("body", {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props) => {
component?.updateProps({
...props,
fetchTasks: fetchTasksRef.current,
} as MentionListProps);
if (props.clientRect) {
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
},
onKeyDown: (props) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
popup?.[0]?.destroy();
component?.destroy();
},
};
},
};
}
/* ─── Toolbar button helper ────────────────────────────────────────────── */
function ToolBtn({ title, active, disabled, onClick, children }: { title: string; active?: boolean; disabled?: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
title={title}
className={`rte-tool-btn${active ? " is-active" : ""}`}
disabled={disabled}
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
>
{children}
</button>
);
}
/* ─── Link dialog ──────────────────────────────────────────────────────── */
function LinkDialog({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
const [url, setUrl] = useState("https://");
return (
<div className="rte-link-dialog">
<input
className="form-control form-control-sm"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://…"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") onConfirm(url);
if (e.key === "Escape") onCancel();
}}
/>
<button
className="btn btn-sm btn-primary ms-1"
onMouseDown={(e) => {
e.preventDefault();
onConfirm(url);
}}
>
OK
</button>
<button
className="btn btn-sm btn-outline-secondary ms-1"
onMouseDown={(e) => {
e.preventDefault();
onCancel();
}}
>
Cancel
</button>
</div>
);
}
/* ─── Main component ───────────────────────────────────────────────────── */
export default function RichTextEditor({
content,
onChange,
readOnly = false,
placeholder = "Write something…",
taskLists = [],
spaceId,
onNavigate,
onFetchTasksForList,
minHeight = 300,
}: RichTextEditorProps) {
const taskListsRef = useRef<TaskListItem[]>(taskLists);
const fetchTasksRef = useRef<((id: string) => Promise<TaskPickerItem[]>) | undefined>(onFetchTasksForList);
useEffect(() => {
taskListsRef.current = taskLists;
}, [taskLists]);
useEffect(() => {
fetchTasksRef.current = onFetchTasksForList;
}, [onFetchTasksForList]);
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [headingOpen, setHeadingOpen] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Link.configure({ openOnClick: false, HTMLAttributes: { rel: "noopener noreferrer" } }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: false }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({ placeholder }),
TaskMention.configure({
HTMLAttributes: { class: "tasklist-mention-node" },
suggestion: buildSuggestion(taskListsRef, fetchTasksRef),
}),
],
content: content || "",
editable: !readOnly,
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML());
},
editorProps: {
attributes: {
class: "rte-content",
style: `min-height:${minHeight}px`,
},
},
immediatelyRender: false,
});
// Sync content when parent changes it (e.g. loading a new note)
const prevContentRef = useRef(content);
useEffect(() => {
if (!editor) return;
// Only reset when content changed externally (not from user typing)
if (content !== prevContentRef.current && content !== editor.getHTML()) {
editor.commands.setContent(content || "", { emitUpdate: false });
}
prevContentRef.current = content;
}, [content, editor]);
// Update editable state
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
// Handle clicks on mention nodes in read-only mode for navigation
const handleEditorClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!readOnly || !spaceId || !onNavigate) return;
const target = (e.target as HTMLElement).closest("[data-type='mention']") as HTMLElement | null;
if (target?.dataset.id) {
e.preventDefault();
onNavigate(`/dashboard/spaces/${spaceId}/tasklists/${target.dataset.id}`);
}
},
[readOnly, spaceId, onNavigate],
);
if (!editor) return null;
// --- READ-ONLY ---
if (readOnly) {
return (
<div className="rte-wrapper rte-readonly" onClick={handleEditorClick}>
<EditorContent editor={editor} />
</div>
);
}
// --- EDITABLE ---
const isTable = editor.isActive("table");
return (
<div className="rte-wrapper">
{/* Toolbar */}
<div className="rte-toolbar">
{/* History */}
<ToolBtn title="Undo (Ctrl+Z)" disabled={!editor.can().undo()} onClick={() => editor.chain().focus().undo().run()}>
<i className="mdi mdi-undo" />
</ToolBtn>
<ToolBtn title="Redo (Ctrl+Y)" disabled={!editor.can().redo()} onClick={() => editor.chain().focus().redo().run()}>
<i className="mdi mdi-redo" />
</ToolBtn>
<div className="rte-toolbar-sep" />
{/* Heading dropdown */}
<div className="rte-dropdown-wrap">
<button
title="Text style"
className={`rte-tool-btn rte-heading-btn${headingOpen ? " is-active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
setHeadingOpen((v) => !v);
}}
>
{editor.isActive("heading", { level: 1 }) ? (
"H1"
) : editor.isActive("heading", { level: 2 }) ? (
"H2"
) : editor.isActive("heading", { level: 3 }) ? (
"H3"
) : (
<>
<i className="mdi mdi-format-text" />
</>
)}
<i className="mdi mdi-chevron-down ms-1" style={{ fontSize: "0.7rem" }} />
</button>
{headingOpen && (
<div className="rte-dropdown-menu" onMouseLeave={() => setHeadingOpen(false)}>
{(["Normal", "H1", "H2", "H3"] as const).map((label) => (
<button
key={label}
className="rte-dropdown-item"
onMouseDown={(e) => {
e.preventDefault();
if (label === "Normal") editor.chain().focus().setParagraph().run();
else
editor
.chain()
.focus()
.setHeading({ level: Number(label[1]) as 1 | 2 | 3 })
.run();
setHeadingOpen(false);
}}
>
{label}
</button>
))}
</div>
)}
</div>
<div className="rte-toolbar-sep" />
{/* Inline marks */}
<ToolBtn title="Bold (Ctrl+B)" active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>
<i className="mdi mdi-format-bold" />
</ToolBtn>
<ToolBtn title="Italic (Ctrl+I)" active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>
<i className="mdi mdi-format-italic" />
</ToolBtn>
<ToolBtn title="Strikethrough" active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()}>
<i className="mdi mdi-format-strikethrough" />
</ToolBtn>
<ToolBtn title="Inline code" active={editor.isActive("code")} onClick={() => editor.chain().focus().toggleCode().run()}>
<i className="mdi mdi-code-tags" />
</ToolBtn>
<div className="rte-toolbar-sep" />
{/* Lists */}
<ToolBtn title="Bullet list" active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>
<i className="mdi mdi-format-list-bulleted" />
</ToolBtn>
<ToolBtn title="Ordered list" active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
<i className="mdi mdi-format-list-numbered" />
</ToolBtn>
<ToolBtn title="Task list (checklist)" active={editor.isActive("taskList")} onClick={() => editor.chain().focus().toggleTaskList().run()}>
<i className="mdi mdi-format-list-checks" />
</ToolBtn>
<div className="rte-toolbar-sep" />
{/* Blocks */}
<ToolBtn title="Blockquote" active={editor.isActive("blockquote")} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
<i className="mdi mdi-format-quote-open" />
</ToolBtn>
<ToolBtn title="Code block" active={editor.isActive("codeBlock")} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
<i className="mdi mdi-code-braces" />
</ToolBtn>
<div className="rte-toolbar-sep" />
{/* Table */}
{!isTable ? (
<ToolBtn title="Insert table" onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}>
<i className="mdi mdi-table" />
</ToolBtn>
) : (
<>
<ToolBtn title="Add column after" onClick={() => editor.chain().focus().addColumnAfter().run()}>
<i className="mdi mdi-table-column-plus-after" />
</ToolBtn>
<ToolBtn title="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()}>
<i className="mdi mdi-table-column-remove" />
</ToolBtn>
<ToolBtn title="Add row after" onClick={() => editor.chain().focus().addRowAfter().run()}>
<i className="mdi mdi-table-row-plus-after" />
</ToolBtn>
<ToolBtn title="Delete row" onClick={() => editor.chain().focus().deleteRow().run()}>
<i className="mdi mdi-table-row-remove" />
</ToolBtn>
<ToolBtn title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>
<i className="mdi mdi-table-remove" />
</ToolBtn>
</>
)}
{/* Link */}
<ToolBtn
title="Insert / edit link"
active={editor.isActive("link")}
onClick={() => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
} else {
setShowLinkDialog(true);
}
}}
>
<i className="mdi mdi-link-variant" />
</ToolBtn>
</div>
{/* Link dialog */}
{showLinkDialog && (
<div className="rte-link-dialog-wrap">
<LinkDialog
onConfirm={(url) => {
editor.chain().focus().setLink({ href: url }).run();
setShowLinkDialog(false);
}}
onCancel={() => setShowLinkDialog(false)}
/>
</div>
)}
{/* Editor area */}
<div className="rte-editor-area">
<EditorContent editor={editor} />
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Category } from "@/stores/spaceStore";
interface SidebarProps {
open: boolean;
onClose: () => void;
navbarHeight?: number;
onOpenCreateCategory?: () => void;
onOpenSpaceSettings?: () => void;
}
export default function Sidebar({ open, onClose, navbarHeight = 56, onOpenCreateCategory, onOpenSpaceSettings }: SidebarProps) {
const currentSpace = useSpaceStore((s) => s.currentSpace);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const hasPermission = useAuthStore((s) => s.hasPermission);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const canCreateCategories = hasPermission("*") || hasSpacePermission(currentSpace, "categories.create");
const canManageSettings = hasPermission("*") || hasSpacePermission(currentSpace, "settings.manage");
return (
<>
{/* Mobile backdrop */}
{open && <div className="sidebar-backdrop" style={{ top: navbarHeight }} onClick={onClose} />}
<aside className={`sidebar bg-light border-end${open ? " open" : ""}`} style={open ? { top: navbarHeight } : undefined}>
<div className="sidebar-header p-3">
<h6 className="mb-0">Categories</h6>
{canCreateCategories && (
<button className="btn btn-sm btn-outline-primary mt-2 w-100" onClick={onOpenCreateCategory}>
<i className="mdi mdi-folder-plus-outline me-1" aria-hidden="true" />
New Category
</button>
)}
</div>
<div className="sidebar-content p-2">
{categoryTree.length === 0 ? <p className="text-muted small p-2">No categories yet.</p> : <CategoryTreeView categories={categoryTree} spaceId={currentSpace?.id ?? ""} />}
</div>
{canManageSettings && (
<div className="sidebar-footer p-2 border-top">
<button className="btn btn-sm btn-outline-secondary w-100" onClick={onOpenSpaceSettings}>
<i className="mdi mdi-cog-outline me-1" aria-hidden="true" />
Space Settings
</button>
</div>
)}
</aside>
</>
);
}
function CategoryTreeView({ categories, spaceId }: { categories: Category[]; spaceId: string }) {
return (
<>
{categories.map((cat) => (
<CategoryNode key={cat.id} category={cat} spaceId={spaceId} />
))}
</>
);
}
function CategoryNode({ category, spaceId }: { category: Category; spaceId: string }) {
const router = useRouter();
const [expanded, setExpanded] = useState(true);
const subcategories = category.subcategories ?? category.children ?? [];
const notes = category.notes ?? [];
const taskLists = category.task_lists ?? [];
const hasContent = subcategories.length > 0 || notes.length > 0 || taskLists.length > 0;
return (
<div>
<div className="category-tree-item" onClick={() => setExpanded((v) => !v)} style={{ cursor: "pointer" }}>
{hasContent ? (
<i className={`mdi ${expanded ? "mdi-chevron-down" : "mdi-chevron-right"} text-muted`} style={{ fontSize: "0.9rem" }} />
) : (
<i className="mdi mdi-folder-outline text-muted" style={{ fontSize: "0.9rem" }} />
)}
<span>{category.name}</span>
</div>
{expanded && hasContent && (
<div className="category-tree-children">
{taskLists.map((tl) => (
<div
key={tl.id}
className="note-item"
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
router.push(`/dashboard/spaces/${spaceId}/tasklists/${tl.id}`);
}}
>
<i className="mdi mdi-format-list-checkbox me-1" aria-hidden="true" />
<span>{tl.name}</span>
</div>
))}
{notes.map((note) => (
<div
key={note.id}
className={`note-item${note.is_pinned ? " is-pinned" : note.is_favorite ? " is-featured" : ""}`}
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
router.push(`/dashboard/spaces/${spaceId}/notes/${note.id}`);
}}
>
<i className="mdi mdi-file-document-outline me-1" aria-hidden="true" />
<span>{note.title}</span>
{note.is_pinned && <i className="mdi mdi-pin pin-icon ms-1" style={{ fontSize: "0.75rem" }} aria-hidden="true" />}
{!note.is_pinned && note.is_favorite && <i className="mdi mdi-star featured-icon ms-1" style={{ fontSize: "0.75rem" }} aria-hidden="true" />}
</div>
))}
{subcategories.length > 0 && <CategoryTreeView categories={subcategories} spaceId={spaceId} />}
</div>
)}
</div>
);
}
@@ -0,0 +1,320 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Space, SpaceMember } from "@/stores/spaceStore";
import { useAuthStore } from "@/stores/authStore";
import apiClient from "@/lib/apiClient";
interface SpaceSettingsModalProps {
space: Space;
onClose: () => void;
onSaved: (space: Space) => void;
onDeleted: () => void;
}
interface AvailableUser {
id: string;
username: string;
}
type ConfirmIntent = { type: "member"; payload: SpaceMember } | { type: "space" } | null;
export default function SpaceSettingsModal({ space, onClose, onSaved, onDeleted }: SpaceSettingsModalProps) {
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const hasPermission = useAuthStore((s) => s.hasPermission);
const canViewMembers = hasPermission("*") || hasSpacePermission(space, "settings.member.view");
const canManageMembers = hasPermission("*") || hasSpacePermission(space, "settings.member.manage");
const canDeleteSpace = hasPermission("*") || hasSpacePermission(space, "settings.delete");
const [form, setForm] = useState({ name: space.name || "", is_public: !!space.is_public });
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [members, setMembers] = useState<SpaceMember[]>([]);
const [userOptions, setUserOptions] = useState<AvailableUser[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [addingMember, setAddingMember] = useState(false);
const [memberUserId, setMemberUserId] = useState("");
const [removingMemberId, setRemovingMemberId] = useState("");
const [confirmIntent, setConfirmIntent] = useState<ConfirmIntent>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const clearMessages = () => {
setError("");
setSuccess("");
};
const loadMembers = useCallback(async () => {
if (!canViewMembers) return;
setLoadingMembers(true);
clearMessages();
try {
const res = await apiClient.get(`/api/v1/spaces/${space.id}/members`);
setMembers(res.data.members || []);
} catch (e: unknown) {
const err = e as { response?: { data?: string } };
setError(err.response?.data || "Failed to load members.");
} finally {
setLoadingMembers(false);
}
}, [space.id, canViewMembers]);
const loadUserOptions = useCallback(async () => {
if (!canManageMembers) return;
try {
const res = await apiClient.get(`/api/v1/spaces/${space.id}/available-users`);
setUserOptions(res.data.users || []);
} catch {
// ignore
}
}, [space.id, canManageMembers]);
useEffect(() => {
if (canViewMembers) {
Promise.all([loadMembers(), loadUserOptions()]);
}
}, []);
async function saveSettings() {
setSaving(true);
clearMessages();
try {
const res = await apiClient.put(`/api/v1/spaces/${space.id}`, {
name: form.name,
is_public: form.is_public,
});
setSuccess("Space settings saved.");
onSaved(res.data);
} catch (e: unknown) {
const err = e as { response?: { data?: string } };
setError(err.response?.data || "Failed to save settings.");
} finally {
setSaving(false);
}
}
async function addMember(e: React.FormEvent) {
e.preventDefault();
if (!canManageMembers || !memberUserId) return;
setAddingMember(true);
clearMessages();
try {
await apiClient.post(`/api/v1/spaces/${space.id}/members`, { user_id: memberUserId });
setSuccess("Member added.");
setMemberUserId("");
await Promise.all([loadMembers(), loadUserOptions()]);
} catch (e: unknown) {
const err = e as { response?: { data?: string } };
setError(err.response?.data || "Failed to add member.");
} finally {
setAddingMember(false);
}
}
async function confirmAction() {
if (!confirmIntent || confirmBusy) return;
setConfirmBusy(true);
clearMessages();
try {
if (confirmIntent.type === "member") {
const member = confirmIntent.payload;
setRemovingMemberId(member.user_id);
await apiClient.delete(`/api/v1/spaces/${space.id}/members/${member.user_id}`);
setSuccess("Member removed.");
setConfirmIntent(null);
await Promise.all([loadMembers(), loadUserOptions()]);
} else if (confirmIntent.type === "space") {
await apiClient.delete(`/api/v1/spaces/${space.id}`);
onDeleted();
}
} catch (e: unknown) {
const err = e as { response?: { data?: string } };
setError(err.response?.data || "Action failed.");
setConfirmIntent(null);
} finally {
setConfirmBusy(false);
setRemovingMemberId("");
}
}
const formatDate = (iso?: string) => (iso ? new Date(iso).toLocaleDateString() : "-");
const confirmTitle = confirmIntent?.type === "member" ? "Remove Member" : "Delete Space";
const confirmMessage =
confirmIntent?.type === "member"
? `Remove "${confirmIntent.payload.username || confirmIntent.payload.user_id}" from this space?`
: `Permanently delete space "${space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
return (
<>
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
onClick={(e) => e.target === e.currentTarget && !confirmIntent && onClose()}
>
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Space Settings</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Space Name</label>
<input
type="text"
className="form-control"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="form-check form-switch mb-4">
<input
id="spacePublicToggle"
className="form-check-input"
type="checkbox"
checked={form.is_public}
onChange={(e) => setForm((p) => ({ ...p, is_public: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="spacePublicToggle">
Public space
</label>
</div>
<div className="d-flex justify-content-end mb-4">
<button className="btn btn-primary" disabled={saving} onClick={saveSettings}>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
{canViewMembers && (
<>
<hr />
<div className="d-flex justify-content-between align-items-center mb-2 mt-3">
<h6 className="mb-0">Members</h6>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingMembers} onClick={loadMembers}>
Refresh
</button>
</div>
{canManageMembers && (
<form className="row g-2 align-items-end mb-3" onSubmit={addMember}>
<div className="col-md-10">
<label className="form-label form-label-sm mb-1">Username</label>
<select
className="form-select form-select-sm"
value={memberUserId}
onChange={(e) => setMemberUserId(e.target.value)}
required
>
<option value="" disabled>
Select user
</option>
{userOptions.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={addingMember || !memberUserId}>
{addingMember ? "..." : "Add"}
</button>
</div>
</form>
)}
{loadingMembers ? (
<p className="text-muted small">Loading members...</p>
) : members.length === 0 ? (
<p className="text-muted small">No members found.</p>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.user_id}>
<td className="small text-muted">{m.username || m.user_id}</td>
<td className="small text-muted">{formatDate(m.joined_at)}</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={!canManageMembers || removingMemberId === m.user_id}
onClick={() => setConfirmIntent({ type: "member", payload: m })}
>
{removingMemberId === m.user_id ? "Removing..." : "Remove"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{canDeleteSpace && (
<>
<hr />
<div className="border border-danger rounded p-3 mt-4">
<h6 className="text-danger mb-1">Danger Zone</h6>
<p className="small text-muted mb-2">
Permanently delete this space and all its notes, categories, and members. This cannot be undone.
</p>
<button className="btn btn-danger" type="button" onClick={() => setConfirmIntent({ type: "space" })}>
<i className="mdi mdi-delete-outline me-1" aria-hidden="true" />
Delete Space
</button>
</div>
</>
)}
{error && <div className="alert alert-danger mt-3 mb-0">{error}</div>}
{success && <div className="alert alert-success mt-3 mb-0">{success}</div>}
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</div>
{confirmIntent && (
<div className="modal fade show d-block" tabIndex={-1} role="dialog" aria-modal="true" style={{ zIndex: 1060 }}>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{confirmTitle}</h5>
</div>
<div className="modal-body">
<p className="mb-0">{confirmMessage}</p>
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" disabled={confirmBusy} onClick={() => setConfirmIntent(null)}>
Cancel
</button>
<button className="btn btn-danger" disabled={confirmBusy} onClick={confirmAction}>
{confirmBusy ? "Processing..." : "Confirm"}
</button>
</div>
</div>
</div>
<div className="modal-backdrop fade show" style={{ zIndex: 1055 }} />
</div>
)}
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import axios from "axios";
const apiClient = axios.create({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
withCredentials: true,
});
let isRefreshing = false;
let refreshSubscribers: Array<() => void> = [];
function onRefreshed() {
refreshSubscribers.forEach((cb) => cb());
refreshSubscribers = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.url?.includes("/auth/refresh") || originalRequest.url?.includes("/auth/login")) {
// Lazy-import to avoid circular dependency
const { useAuthStore } = await import("@/stores/authStore");
useAuthStore.getState().clearSession();
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshSubscribers.push(() => {
originalRequest._retry = true;
apiClient(originalRequest).then(resolve).catch(reject);
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
await apiClient.post("/api/v1/auth/refresh");
onRefreshed();
return apiClient(originalRequest);
} catch {
refreshSubscribers = [];
const { useAuthStore } = await import("@/stores/authStore");
useAuthStore.getState().clearSession();
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
export default apiClient;
+35
View File
@@ -0,0 +1,35 @@
import { marked } from "marked";
import { markedHighlight } from "marked-highlight";
import hljs from "highlight.js/lib/common";
marked.use(
markedHighlight({
langPrefix: "hljs language-",
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
}),
);
/**
* Preprocesses markdown with extended image size syntax:
* ![alt](url =WIDTHxHEIGHT)
*/
export function preprocessMarkdown(content: string): string {
if (!content) return content;
return content.replace(/!\[([^\]]*)\]\(([^\s)"]+)(?:\s+"([^"]*)")?\s+=(\d*%?)[xX](\d*%?)\)/gi, (_, alt, url, title, w, h) => {
const safeAlt = alt.replace(/"/g, "&quot;");
let attrs = `src="${url}" alt="${safeAlt}"`;
if (title) attrs += ` title="${title.replace(/"/g, "&quot;")}"`;
if (w) attrs += ` width="${w}"`;
if (h) attrs += ` height="${h}"`;
return `<img ${attrs}>`;
});
}
export function renderMarkdown(content: string): string {
return marked.parse(preprocessMarkdown(content || ""), { async: false }) as string;
}
+127
View File
@@ -0,0 +1,127 @@
import { create } from "zustand";
import apiClient from "@/lib/apiClient";
export interface User {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
is_active: boolean;
created_at: string;
permissions: string[];
groups?: Array<{ id: string; name: string }>;
}
interface AuthState {
user: User | null;
initialized: boolean;
initPromise: Promise<void> | null;
// Actions
setSession: (data: unknown) => void;
clearSession: () => void;
loadSession: () => Promise<void>;
ensureInitialized: () => Promise<void>;
login: (email: string, password: string) => Promise<unknown>;
logout: () => void;
register: (email: string, username: string, password: string, firstName?: string, lastName?: string) => Promise<unknown>;
hasPermission: (permission: string) => boolean;
hasSpacePermission: (space: { permission_key?: string } | null, action: string) => boolean;
}
const normalizePermission = (p: string) => (p || "").trim().toLowerCase();
const permissionMatches = (pattern: string, permission: string): boolean => {
const p = normalizePermission(pattern);
const q = normalizePermission(permission);
if (!p || !q) return false;
if (p === "*" || p === q) return true;
if (!p.includes("*")) return false;
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
return new RegExp(`^${escaped}$`).test(q);
};
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
initialized: false,
initPromise: null,
setSession(data: unknown) {
const d = data as { user?: User } | null;
set({ user: d?.user ?? null, initialized: true });
},
clearSession() {
set({ user: null, initialized: true });
},
async loadSession() {
try {
const res = await apiClient.get("/api/v1/auth/me");
set({ user: res.data?.user ?? null, initialized: true });
} catch {
set({ user: null, initialized: true });
}
},
async ensureInitialized() {
const state = get();
if (state.initialized) return;
if (state.initPromise) return state.initPromise;
const promise = get()
.loadSession()
.finally(() => set({ initPromise: null }));
set({ initPromise: promise });
return promise;
},
async login(email, password) {
try {
const res = await apiClient.post("/api/v1/auth/login", {
email: email?.trim(),
password,
});
get().setSession(res.data);
return res.data;
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string };
throw e.response?.data?.message || e.message;
}
},
logout() {
apiClient.post("/api/v1/auth/logout").catch(() => {});
get().clearSession();
},
async register(email, username, password, firstName = "", lastName = "") {
try {
const res = await apiClient.post("/api/v1/auth/register", {
email,
username,
password,
password_confirm: password,
first_name: firstName,
last_name: lastName,
});
get().setSession(res.data);
return res.data;
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string };
throw e.response?.data?.message || e.message;
}
},
hasPermission(permission: string) {
const perms = get().user?.permissions ?? [];
return perms.some((p) => permissionMatches(p, permission));
},
hasSpacePermission(space, action) {
const token = space?.permission_key ?? "";
if (!token) return false;
return get().hasPermission(`space.${token}.${action}`);
},
}));
+42
View File
@@ -0,0 +1,42 @@
import { create } from "zustand";
import apiClient from "@/lib/apiClient";
export interface FeatureFlags {
registration_enabled: boolean;
provider_login_enabled: boolean;
public_sharing_enabled: boolean;
file_explorer_enabled: boolean;
}
const DEFAULT_FLAGS: FeatureFlags = {
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
};
interface SettingsState {
featureFlags: FeatureFlags;
flagsLoaded: boolean;
loadFeatureFlags: (force?: boolean) => Promise<FeatureFlags>;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
featureFlags: { ...DEFAULT_FLAGS },
flagsLoaded: false,
async loadFeatureFlags(force = false) {
const state = get();
if (state.flagsLoaded && !force) return state.featureFlags;
try {
const res = await apiClient.get("/api/v1/settings/feature-flags");
const flags = { ...DEFAULT_FLAGS, ...res.data };
set({ featureFlags: flags, flagsLoaded: true });
return flags;
} catch {
set({ featureFlags: { ...DEFAULT_FLAGS }, flagsLoaded: true });
return get().featureFlags;
}
},
}));
+213
View File
@@ -0,0 +1,213 @@
import { create } from "zustand";
import apiClient from "@/lib/apiClient";
export interface Space {
id: string;
name: string;
description: string;
is_public: boolean;
permission_key?: string;
}
export interface SpaceMember {
user_id: string;
username?: string;
joined_at?: string;
}
export interface NoteListItem {
id: string;
space_id: string;
category_id?: string | null;
title: string;
description: string;
is_pinned: boolean;
is_favorite: boolean;
is_public: boolean;
is_password_protected: boolean;
updated_at: string;
}
export interface Category {
id: string;
name: string;
space_id: string;
parent_id?: string | null;
description?: string;
icon?: string;
order?: number;
subcategories?: Category[];
children?: Category[];
notes?: NoteListItem[];
task_lists?: TaskList[];
}
export interface Note {
id: string;
title: string;
description: string;
content: string;
tags: string[];
space_id: string;
category_id?: string | null;
is_pinned: boolean;
is_favorite: boolean;
is_public: boolean;
is_password_protected: boolean;
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
}
export interface TaskList {
id: string;
name: string;
description?: string;
space_id: string;
category_id?: string | null;
created_at?: string;
updated_at?: string;
}
export interface Task {
id: string;
space_id: string;
title: string;
description: string;
task_list_id: string;
status_id: string;
parent_task_id?: string | null;
depth: number;
note_links?: string[];
created_by: string;
updated_by: string;
created_at: string;
updated_at: string;
status_name?: string;
status_color?: string;
status_order?: number;
subtasks?: Task[];
}
export interface TaskStatus {
id: string;
task_list_id: string;
name: string;
color?: string;
order: number;
created_at: string;
updated_at: string;
}
interface SpaceState {
spaces: Space[];
currentSpace: Space | null;
notes: Note[];
categoryTree: Category[];
taskLists: TaskList[];
notesLoading: boolean;
notesHasMore: boolean;
fetchSpaces: () => Promise<void>;
selectSpace: (spaceId: string) => Promise<void>;
fetchCategories: (spaceId: string) => Promise<void>;
fetchNotes: (spaceId: string, options?: { reset?: boolean }) => Promise<void>;
fetchTaskLists: (spaceId: string) => Promise<void>;
setCurrentSpace: (space: Space | null) => void;
reset: () => void;
}
export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [],
currentSpace: null,
notes: [],
categoryTree: [],
taskLists: [],
notesLoading: false,
notesHasMore: true,
async fetchSpaces() {
try {
const res = await apiClient.get("/api/v1/spaces");
const spaces: Space[] = res.data || [];
set({ spaces });
// Auto-restore previously selected space
const savedId = typeof window !== "undefined" ? localStorage.getItem("selectedSpaceId") : null;
if (savedId && spaces.some((s) => s.id === savedId)) {
const current = get().currentSpace;
if (!current || current.id !== savedId) {
await get().selectSpace(savedId);
}
}
} catch {
set({ spaces: [] });
}
},
async selectSpace(spaceId) {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}`);
set({ currentSpace: res.data });
if (typeof window !== "undefined") {
localStorage.setItem("selectedSpaceId", spaceId);
}
await Promise.all([get().fetchCategories(spaceId), get().fetchNotes(spaceId), get().fetchTaskLists(spaceId)]);
} catch {
// ignore
}
},
async fetchCategories(spaceId) {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/categories`);
set({ categoryTree: res.data || [] });
} catch {
set({ categoryTree: [] });
}
},
async fetchNotes(spaceId, { reset = true } = {}) {
if (get().notesLoading) return;
set({ notesLoading: true });
try {
const skip = reset ? 0 : get().notes.length;
const limit = 20;
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/notes`, {
params: { skip, limit },
});
const fetched: Note[] = res.data || [];
set((s) => ({
notes: reset ? fetched : [...s.notes, ...fetched],
notesHasMore: fetched.length === limit,
notesLoading: false,
}));
} catch {
set({ notesLoading: false });
}
},
async fetchTaskLists(spaceId) {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
set({ taskLists: res.data || [] });
} catch {
set({ taskLists: [] });
}
},
setCurrentSpace(space) {
set({ currentSpace: space });
},
reset() {
set({
currentSpace: null,
notes: [],
categoryTree: [],
taskLists: [],
notesHasMore: true,
});
},
}));
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long