Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ead8219f3b | |||
| b690b00016 | |||
| 503d2415e6 |
@@ -43,3 +43,7 @@ secret*
|
||||
*.a
|
||||
*.so
|
||||
.go/
|
||||
|
||||
frontend_new/out
|
||||
backend/public
|
||||
frontend_new/.next
|
||||
+114
-39
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -288,12 +289,12 @@ func main() {
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
||||
|
||||
// Task status endpoints
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||
// Task status endpoints (scoped to task list)
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||
|
||||
// File explorer endpoints (space-scoped)
|
||||
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}", adminHandler.DeleteProvider).Methods("DELETE")
|
||||
|
||||
// Serve static files (frontend) for all other routes
|
||||
// This must be after all API route handlers to allow API routes to take precedence
|
||||
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
})
|
||||
// Serve static files (NextJS frontend) for all other routes.
|
||||
// Must come after all API route handlers.
|
||||
router.PathPrefix("/").HandlerFunc(serveNextJS)
|
||||
|
||||
// Start server
|
||||
server := &http.Server{
|
||||
@@ -529,3 +500,107 @@ func ensureDefaultAdminUser(
|
||||
log.Printf("default admin user synchronized from environment: %s", adminEmail)
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
|
||||
// TaskStatusDTO represents a task status in API responses.
|
||||
type TaskStatusDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
TaskListID string `json:"task_list_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
@@ -621,7 +621,7 @@ func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
|
||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||
return &TaskStatusDTO{
|
||||
ID: status.ID.Hex(),
|
||||
SpaceID: status.SpaceID.Hex(),
|
||||
TaskListID: status.TaskListID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
|
||||
@@ -46,8 +46,8 @@ func NewTaskService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
|
||||
|
||||
for idx, status := range defaults {
|
||||
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
TaskListID: taskListID,
|
||||
Name: status.name,
|
||||
Color: status.color,
|
||||
Order: idx,
|
||||
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
|
||||
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)
|
||||
if err != nil || status.SpaceID != spaceID {
|
||||
if err != nil || status.TaskListID != taskListID {
|
||||
return nil, errors.New("invalid task status")
|
||||
}
|
||||
return status, nil
|
||||
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
|
||||
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 {
|
||||
return true, nil
|
||||
}
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -205,10 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
return nil, errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
noteLinks, err := toObjectIDs(req.NoteLinks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
return nil, err
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,7 +252,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
|
||||
if parseErr != nil {
|
||||
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
|
||||
}
|
||||
statusID = parsedStatusID
|
||||
@@ -299,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -331,9 +331,6 @@ func (s *TaskService) ListTasks(
|
||||
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filters := map[string]any{}
|
||||
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 {
|
||||
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})
|
||||
if err != nil {
|
||||
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))
|
||||
for _, task := range tasks {
|
||||
status := statusByID[task.StatusID]
|
||||
status := getStatus(task.TaskListID, task.StatusID)
|
||||
if status == nil {
|
||||
continue
|
||||
}
|
||||
@@ -509,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
|
||||
if parseErr != nil {
|
||||
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
|
||||
}
|
||||
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID)
|
||||
adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -582,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -816,17 +823,24 @@ func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, u
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
|
||||
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -837,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -848,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
||||
if !hasPermission {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
status := &entities.TaskStatus{
|
||||
SpaceID: spaceID,
|
||||
TaskListID: taskListID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Color: strings.TrimSpace(req.Color),
|
||||
Order: len(statuses),
|
||||
@@ -868,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -881,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -896,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -908,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
||||
return errors.New("insufficient permissions")
|
||||
}
|
||||
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -928,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -942,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
if !hasPermission {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -965,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
}
|
||||
status := statusByID[statusID]
|
||||
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 {
|
||||
return nil, errors.New("duplicate status id in ordered_status_ids")
|
||||
@@ -996,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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1008,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
|
||||
func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
|
||||
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ type Task struct {
|
||||
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 {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||
Name string `bson:"name"`
|
||||
Color string `bson:"color,omitempty"`
|
||||
Order int `bson:"order"`
|
||||
|
||||
@@ -245,7 +245,8 @@ type TaskListRepository interface {
|
||||
type TaskStatusRepository interface {
|
||||
CreateStatus(ctx context.Context, status *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
|
||||
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
||||
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||
}
|
||||
|
||||
@@ -196,8 +196,7 @@ func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, space
|
||||
|
||||
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
|
||||
_, 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}}},
|
||||
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -232,8 +231,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID 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}}))
|
||||
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
|
||||
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,14 +256,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
|
||||
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 {
|
||||
_, 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),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -380,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -397,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req)
|
||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -421,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
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"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
@@ -433,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -449,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
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"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -468,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Frontend build stage
|
||||
FROM node:25-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
WORKDIR /frontend_new
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend_new/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY frontend/ .
|
||||
COPY frontend_new/ .
|
||||
RUN npm run build
|
||||
|
||||
# Backend build stage
|
||||
@@ -32,7 +32,7 @@ RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /root/
|
||||
|
||||
COPY --from=backend-builder /app/server .
|
||||
COPY --from=frontend-builder /frontend/dist ./public
|
||||
COPY --from=frontend-builder /frontend_new/out ./public
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -5,6 +5,7 @@
|
||||
<h4 class="mb-0">Tasks</h4>
|
||||
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||
</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 class="task-filters">
|
||||
@@ -23,36 +24,6 @@
|
||||
</select>
|
||||
</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 v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
||||
|
||||
@@ -184,65 +155,11 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="selectedTaskList && canDeleteTaskList"
|
||||
title-id="task-list-danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Delete this task list and all associated tasks permanently."
|
||||
>
|
||||
<button type="button" class="btn btn-danger" @click="emitDeleteTaskList">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Task List
|
||||
</button>
|
||||
</DangerZonePanel>
|
||||
|
||||
<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>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="statusMode === 'edit'"
|
||||
class="mt-4"
|
||||
title-id="status-danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Deleting this status is permanent and cannot be undone."
|
||||
copy-class="mb-2"
|
||||
>
|
||||
<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"></div>
|
||||
</teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
tasks: {
|
||||
@@ -257,27 +174,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
canDeleteTaskList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status", "delete-task-list"]);
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
|
||||
|
||||
const filterStatus = 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 openStatusMenuTaskId = ref("");
|
||||
const statusForm = ref({
|
||||
name: "",
|
||||
color: "#7c8596",
|
||||
});
|
||||
|
||||
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
||||
const tasksById = computed(() => {
|
||||
@@ -389,120 +293,6 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
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();
|
||||
};
|
||||
|
||||
const emitDeleteTaskList = () => {
|
||||
if (!props.selectedTaskList?.id || !props.canDeleteTaskList) {
|
||||
return;
|
||||
}
|
||||
emit("delete-task-list", props.selectedTaskList);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||
|
||||
@@ -5,15 +5,10 @@
|
||||
:tasks="tasks"
|
||||
:statuses="taskStatuses"
|
||||
:selected-task-list="selectedTaskList"
|
||||
:can-delete-task-list="canDeleteTasks"
|
||||
@select-task="emit('select-task', $event)"
|
||||
@filter-change="emit('filter-change', $event)"
|
||||
@reorder-status="emit('reorder-status', $event)"
|
||||
@create-status="emit('create-status', $event)"
|
||||
@rename-status="emit('rename-status', $event)"
|
||||
@delete-status="emit('delete-status', $event)"
|
||||
@update-task-status="emit('update-task-status', $event)"
|
||||
@delete-task-list="emit('delete-task-list', $event)"
|
||||
@edit-task-list="emit('edit-task-list')"
|
||||
/>
|
||||
<SearchResultsPage
|
||||
v-else-if="isSearchRoute"
|
||||
@@ -151,12 +146,8 @@ defineProps({
|
||||
const emit = defineEmits([
|
||||
"select-task",
|
||||
"filter-change",
|
||||
"reorder-status",
|
||||
"create-status",
|
||||
"rename-status",
|
||||
"delete-status",
|
||||
"update-task-status",
|
||||
"delete-task-list",
|
||||
"edit-task-list",
|
||||
"select-note",
|
||||
"select-task-list",
|
||||
"page-change",
|
||||
|
||||
@@ -202,7 +202,6 @@
|
||||
:tasks="tasks"
|
||||
:task-statuses="taskStatuses"
|
||||
:selected-task-list="selectedTaskList"
|
||||
:can-delete-tasks="canDeleteTasks"
|
||||
:is-search-route="isSearchRoute"
|
||||
:search-items="searchItems"
|
||||
:search-query="searchQuery"
|
||||
@@ -220,12 +219,8 @@
|
||||
:is-loading-more-main-notes="spaceStore.notesLoading"
|
||||
@select-task="openTaskDetail"
|
||||
@filter-change="applyTaskFilters"
|
||||
@reorder-status="reorderTaskStatuses"
|
||||
@create-status="createTaskStatus"
|
||||
@rename-status="renameTaskStatus"
|
||||
@delete-status="requestDeleteTaskStatus"
|
||||
@update-task-status="updateTaskStatusFromBoard"
|
||||
@delete-task-list="requestRemoveTaskList"
|
||||
@edit-task-list="showEditTaskListModal = true"
|
||||
@select-note="selectSearchResultNote"
|
||||
@select-task-list="selectTaskList"
|
||||
@page-change="setSearchPage"
|
||||
@@ -319,6 +314,21 @@
|
||||
<div v-if="showUnlockModal" class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
|
||||
<EditTaskListModal
|
||||
v-if="showEditTaskListModal && selectedTaskList"
|
||||
:task-list="selectedTaskList"
|
||||
:statuses="taskStatuses"
|
||||
:category-options="categoryOptions"
|
||||
:can-delete-task-list="canDeleteTasks"
|
||||
@close="showEditTaskListModal = false"
|
||||
@update-task-list="updateTaskListFromModal"
|
||||
@reorder-status="reorderTaskStatuses"
|
||||
@create-status="createTaskStatus"
|
||||
@rename-status="renameTaskStatus"
|
||||
@delete-status="requestDeleteTaskStatus"
|
||||
@delete-task-list="requestRemoveTaskList"
|
||||
/>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showTaskDeleteConfirmModal"
|
||||
:title="taskDeleteConfirmTitle"
|
||||
@@ -339,6 +349,7 @@ import CategoryTree from "../components/CategoryTree.vue";
|
||||
import AppWorkspaceContent from "../components/app/AppWorkspaceContent.vue";
|
||||
import AppModalHost from "../components/app/AppModalHost.vue";
|
||||
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
||||
import EditTaskListModal from "../components/EditTaskListModal.vue";
|
||||
import { sortNotesByPriority } from "../utils/noteSort";
|
||||
import apiClient from "../services/apiClient";
|
||||
|
||||
@@ -354,6 +365,7 @@ const showCreateSpaceModal = ref(false);
|
||||
const showCreateCategoryModal = ref(false);
|
||||
const showCreateNoteModal = ref(false);
|
||||
const showCreateTaskListModal = ref(false);
|
||||
const showEditTaskListModal = ref(false);
|
||||
const showSidebar = ref(false);
|
||||
const navbarRef = ref(null);
|
||||
const navbarHeight = ref(56);
|
||||
@@ -819,6 +831,7 @@ watch(
|
||||
isEditingNote.value = false;
|
||||
selectedCategory.value = null;
|
||||
selectedTaskList.value = taskLists.value.find((list) => list.id === taskListId) || null;
|
||||
await spaceStore.fetchTaskStatuses(currentSpace.value?.id, taskListId);
|
||||
await applyTaskFilters({ taskListId });
|
||||
return;
|
||||
}
|
||||
@@ -1056,6 +1069,7 @@ const selectTaskList = async (taskList) => {
|
||||
showSidebar.value = false;
|
||||
if (currentSpace.value?.id && taskList?.id) {
|
||||
await router.push(dashboardTaskRoute(currentSpace.value.id, taskList.id));
|
||||
await spaceStore.fetchTaskStatuses(currentSpace.value.id, taskList.id);
|
||||
}
|
||||
|
||||
await applyTaskFilters({
|
||||
@@ -1253,22 +1267,22 @@ const createSubtask = (parentTask) => {
|
||||
};
|
||||
|
||||
const createTaskStatus = async (payload) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.createTaskStatus(currentSpace.value.id, payload);
|
||||
await spaceStore.createTaskStatus(currentSpace.value.id, selectedTaskList.value.id, payload);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to create status.");
|
||||
}
|
||||
};
|
||||
|
||||
const renameTaskStatus = async (status) => {
|
||||
if (!currentSpace.value?.id || !status?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.updateTaskStatus(currentSpace.value.id, status.id, {
|
||||
await spaceStore.updateTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id, {
|
||||
name: status.name,
|
||||
color: status.color,
|
||||
});
|
||||
@@ -1300,12 +1314,12 @@ const requestDeleteTaskStatus = (status) => {
|
||||
};
|
||||
|
||||
const deleteTaskStatus = async (status) => {
|
||||
if (!currentSpace.value?.id || !status?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id || !status?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await spaceStore.deleteTaskStatus(currentSpace.value.id, status.id);
|
||||
await spaceStore.deleteTaskStatus(currentSpace.value.id, selectedTaskList.value.id, status.id);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to delete status.");
|
||||
throw error;
|
||||
@@ -1313,11 +1327,11 @@ const deleteTaskStatus = async (status) => {
|
||||
};
|
||||
|
||||
const reorderTaskStatuses = async (orderedIds) => {
|
||||
if (!currentSpace.value?.id) {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.reorderTaskStatuses(currentSpace.value.id, orderedIds);
|
||||
await spaceStore.reorderTaskStatuses(currentSpace.value.id, selectedTaskList.value.id, orderedIds);
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to reorder statuses.");
|
||||
}
|
||||
@@ -1390,10 +1404,25 @@ const createTaskList = async (taskListData) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateTaskListFromModal = async (payload) => {
|
||||
if (!currentSpace.value?.id || !selectedTaskList.value?.id) return;
|
||||
try {
|
||||
await spaceStore.updateTaskList(currentSpace.value.id, selectedTaskList.value.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
category_id: payload.category_id,
|
||||
});
|
||||
selectedTaskList.value = { ...selectedTaskList.value, name: payload.name, category_id: payload.category_id };
|
||||
} catch (error) {
|
||||
alert(error?.response?.data || "Unable to update task list.");
|
||||
}
|
||||
};
|
||||
|
||||
const requestRemoveTaskList = (taskList) => {
|
||||
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
|
||||
return;
|
||||
}
|
||||
showEditTaskListModal.value = false;
|
||||
taskDeleteIntent.value = {
|
||||
type: "task-list",
|
||||
payload: taskList,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
const noteLinkedTasks = ref([]);
|
||||
|
||||
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 () => {
|
||||
@@ -212,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
searchResults.value = [];
|
||||
};
|
||||
|
||||
const fetchTaskStatuses = async (spaceId) => {
|
||||
if (!spaceId) {
|
||||
const fetchTaskStatuses = async (spaceId, taskListId) => {
|
||||
if (!spaceId || !taskListId) {
|
||||
taskStatuses.value = [];
|
||||
return [];
|
||||
}
|
||||
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 || [];
|
||||
return taskStatuses.value;
|
||||
} catch (error) {
|
||||
@@ -261,25 +261,25 @@ export const useSpaceStore = defineStore("space", () => {
|
||||
await fetchTaskLists(spaceId);
|
||||
};
|
||||
|
||||
const createTaskStatus = async (spaceId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const createTaskStatus = async (spaceId, taskListId, payload) => {
|
||||
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const updateTaskStatus = async (spaceId, statusId, payload) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const deleteTaskStatus = async (spaceId, statusId) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`);
|
||||
await fetchTaskStatuses(spaceId);
|
||||
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
|
||||
await fetchTaskStatuses(spaceId, taskListId);
|
||||
};
|
||||
|
||||
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, {
|
||||
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
|
||||
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
|
||||
ordered_status_ids: orderedStatusIds,
|
||||
});
|
||||
taskStatuses.value = response.data || [];
|
||||
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
trailingSlash: true,
|
||||
distDir: "out",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2281
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>"{searchQuery}"</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 />;
|
||||
}
|
||||
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import TaskListPageClient from "./TaskListPageClient";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ spaceId: "__space__", taskListId: "__tasklist__" }];
|
||||
}
|
||||
|
||||
export default function TaskListPage() {
|
||||
return <TaskListPageClient />;
|
||||
}
|
||||
+132
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't have an account? <Link href="/register">Register here</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
* 
|
||||
*/
|
||||
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, """);
|
||||
let attrs = `src="${url}" alt="${safeAlt}"`;
|
||||
if (title) attrs += ` title="${title.replace(/"/g, """)}"`;
|
||||
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;
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user