4 Commits

Author SHA1 Message Date
domrichardson
503d2415e6 feat: associated task status with task list not space
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m52s
2026-04-01 14:29:15 +01:00
domrichardson
74d8899eec feat: Updates to dashboard and delete confirmations
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 34s
2026-04-01 13:40:18 +01:00
domrichardson
295e03feb4 fix: removed hardcoded api url
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-30 10:58:36 +01:00
domrichardson
b09137eca5 feat: Added the ability to delete task lists
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m48s
2026-03-30 10:14:07 +01:00
36 changed files with 3249 additions and 1875 deletions

View File

@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
FRONTEND_URL="http://localhost" FRONTEND_URL="http://localhost"
VITE_API_BASE_URL="http://localhost"
# Default Admin # Default Admin
DEFAULT_ADMIN_EMAIL=admin@notely.local DEFAULT_ADMIN_EMAIL=admin@notely.local
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin

View File

@@ -46,8 +46,6 @@ jobs:
context: . context: .
file: ./devops/docker/Dockerfile file: ./devops/docker/Dockerfile
push: true push: true
build-args: |
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}

View File

@@ -21,7 +21,6 @@ Required or commonly used:
- `JWT_SECRET` - `JWT_SECRET`
- `ENCRYPTION_KEY` - `ENCRYPTION_KEY`
- `FRONTEND_URL` - `FRONTEND_URL`
- `VITE_API_BASE_URL`
- `DEFAULT_ADMIN_EMAIL` - `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD` - `DEFAULT_ADMIN_PASSWORD`
@@ -41,7 +40,6 @@ Optional backend runtime values that Docker Compose will also pass through if pr
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin` - MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
- Backend port: `8080` - Backend port: `8080`
- Public frontend URL: `http://localhost` - Public frontend URL: `http://localhost`
- Browser API base URL for container builds: `http://localhost`
## 2. `backend/.env` ## 2. `backend/.env`
@@ -107,13 +105,12 @@ cp .env.example .env
### Frontend Variables In `frontend/.env.example` ### Frontend Variables In `frontend/.env.example`
- `VITE_API_BASE_URL`
- `VITE_ENV` - `VITE_ENV`
- `VITE_ENABLE_ANALYTICS` - `VITE_ENABLE_ANALYTICS`
### Variables Currently Relevant To The Frontend App ### Variables Currently Relevant To The Frontend App
- `VITE_API_BASE_URL`: used by the API client - API requests are sent to the current browser origin (same-origin runtime behavior)
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them. The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.

View File

@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
Check: Check:
- backend is running on port `8080` - backend is running on port `8080`
- frontend `VITE_API_BASE_URL` - frontend and API are reachable through the same host/origin
- Vite proxy settings in `frontend/vite.config.js` - Vite proxy settings in `frontend/vite.config.js`
### OAuth callback redirects to the wrong URL ### OAuth callback redirects to the wrong URL

View File

@@ -288,12 +288,12 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET") api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints // Task status endpoints (scoped to task list)
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped) // File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET") api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")

View File

@@ -531,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
// TaskStatusDTO represents a task status in API responses. // TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct { type TaskStatusDTO struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"space_id"` TaskListID string `json:"task_list_id"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Order int `json:"order"` Order int `json:"order"`
@@ -621,7 +621,7 @@ func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{ return &TaskStatusDTO{
ID: status.ID.Hex(), ID: status.ID.Hex(),
SpaceID: status.SpaceID.Hex(), TaskListID: status.TaskListID.Hex(),
Name: status.Name, Name: status.Name,
Color: status.Color, Color: status.Color,
Order: status.Order, Order: status.Order,

View File

@@ -46,8 +46,8 @@ func NewTaskService(
} }
} }
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -66,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
for idx, status := range defaults { for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{ if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: status.name, Name: status.name,
Color: status.color, Color: status.color,
Order: idx, Order: idx,
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
return nil return nil
} }
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) { func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
return status, nil return status, nil
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
return depth, nil return depth, nil
} }
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) { func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID { if currentStatusID == requestedStatusID {
return true, nil return true, nil
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -205,10 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID) parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil { if err != nil {
return nil, errors.New("invalid parent task") return nil, errors.New("invalid parent task")
@@ -226,6 +222,10 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,7 +252,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil { if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
return nil, validateErr return nil, validateErr
} }
statusID = parsedStatusID statusID = parsedStatusID
@@ -299,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
status, err := s.validateStatus(ctx, spaceID, task.StatusID) status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -331,9 +331,6 @@ func (s *TaskService) ListTasks(
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
filters := map[string]any{} filters := map[string]any{}
if taskListID != nil && strings.TrimSpace(*taskListID) != "" { if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
@@ -403,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil { if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found") return nil, errors.New("note not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID}) tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Collect statuses per task list
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
byID, ok := statusCache[taskListID]
if !ok {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil {
return nil
}
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, st := range statuses {
byID[st.ID] = st
}
statusCache[taskListID] = byID
}
return byID[statusID]
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks)) result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
status := statusByID[task.StatusID] status := getStatus(task.TaskListID, task.StatusID)
if status == nil { if status == nil {
continue continue
} }
@@ -509,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid status") return nil, errors.New("invalid status")
} }
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil { if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
return nil, err return nil, err
} }
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID) adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -582,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -812,25 +819,28 @@ func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, u
return errors.New("task list not found") return errors.New("task list not found")
} }
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID}) if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
if err != nil {
return err return err
} }
if len(tasks) > 0 {
return errors.New("cannot delete task list with tasks") if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
return err
} }
return s.taskListRepo.DeleteTaskList(ctx, taskListID) return s.taskListRepo.DeleteTaskList(ctx, taskListID)
} }
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil { if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -841,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
return result, nil return result, nil
} }
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -852,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
status := &entities.TaskStatus{ status := &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color), Color: strings.TrimSpace(req.Color),
Order: len(statuses), Order: len(statuses),
@@ -872,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -885,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
} }
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("task status not found") return nil, errors.New("task status not found")
} }
@@ -900,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error { func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err return err
} }
@@ -912,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return errors.New("insufficient permissions") return errors.New("insufficient permissions")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -932,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return err return err
} }
return s.normalizeStatusOrder(ctx, spaceID) return s.normalizeStatusOrder(ctx, taskListID)
} }
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -946,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -969,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
status := statusByID[statusID] status := statusByID[statusID]
if status == nil { if status == nil {
return nil, errors.New("status id does not belong to this space") return nil, errors.New("status id does not belong to this task list")
} }
if _, exists := seen[statusID]; exists { if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids") return nil, errors.New("duplicate status id in ordered_status_ids")
@@ -1000,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
} }
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1012,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
return result, nil return result, nil
} }
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -25,10 +25,10 @@ type Task struct {
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskStatus defines the ordered linear status progression for a space. // TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct { type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"` ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"` TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"` Name string `bson:"name"`
Color string `bson:"color,omitempty"` Color string `bson:"color,omitempty"`
Order int `bson:"order"` Order int `bson:"order"`

View File

@@ -225,6 +225,7 @@ type TaskRepository interface {
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) error UpdateTask(ctx context.Context, task *entities.Task) error
DeleteTask(ctx context.Context, id bson.ObjectID) error DeleteTask(ctx context.Context, id bson.ObjectID) error
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
} }
@@ -244,7 +245,8 @@ type TaskListRepository interface {
type TaskStatusRepository interface { type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
} }

View File

@@ -95,6 +95,11 @@ func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error
return err return err
} }
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err return err
@@ -227,8 +232,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
return &status, nil return &status, nil
} }
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) { func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}})) cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,14 +257,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
return err return err
} }
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error { func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
}) })

View File

@@ -380,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID) statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -397,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.CreateTaskStatusRequest var req dto.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -404,7 +414,7 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req) status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -421,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
@@ -433,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req) status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -449,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
return return
} }
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil { if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -468,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.ReorderTaskStatusesRequest var req dto.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -475,7 +500,7 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
return return
} }
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs) statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return

View File

@@ -3,9 +3,6 @@ FROM node:25-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install RUN npm install

View File

@@ -36,8 +36,6 @@ services:
build: build:
context: . context: .
dockerfile: ./devops/docker/Dockerfile dockerfile: ./devops/docker/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: notely-app container_name: notely-app
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"

View File

@@ -1,8 +1,5 @@
# Frontend Environment Example # Frontend Environment Example
# API Base URL (Backend server)
VITE_API_BASE_URL=http://localhost:8080
# Environment # Environment
VITE_ENV=development VITE_ENV=development

File diff suppressed because it is too large Load Diff

View File

@@ -66,24 +66,6 @@
max-height: 600px; max-height: 600px;
} }
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface)5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
.task-mention-panel { .task-mention-panel {
margin-top: 0.45rem; margin-top: 0.45rem;
border: 1px solid #dbe4f0; border: 1px solid #dbe4f0;
@@ -225,19 +207,6 @@
background-color: var(--color-surface); background-color: var(--color-surface);
} }
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel { :root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558; border-color: #3a4558;
background: #1f2733; background: #1f2733;
@@ -296,5 +265,3 @@
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%); background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%); color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
} }

View File

@@ -289,24 +289,6 @@
align-items: center; align-items: center;
} }
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: var(--color-surface) 5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.task-filters { .task-filters {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -0,0 +1,32 @@
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
margin: 0;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
margin-bottom: 0;
}
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}

View File

@@ -62,17 +62,17 @@
</div> </div>
<div v-if="mode === 'edit'" class="col-12"> <div v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2"> <DangerZonePanel
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2"> class="mt-4"
<div> title-id="danger-zone-title"
<div class="fw-semibold text-danger">Danger Zone</div> title="Danger Zone"
<div class="small text-muted">Permanently delete this provider configuration.</div> description="Permanently delete this provider configuration. This action cannot be undone."
</div> >
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)"> <button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button> </button>
</div> </DangerZonePanel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -179,6 +180,3 @@ const handleSubmit = () => {
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -85,24 +85,39 @@
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div> <div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -133,6 +148,20 @@ const error = ref("");
const success = ref(""); const success = ref("");
const newMember = ref({ user_id: "" }); const newMember = ref({ user_id: "" });
const deleting = ref(false); const deleting = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-"); const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
@@ -208,9 +237,20 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -236,10 +276,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -247,13 +292,51 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style> <style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>

View File

@@ -0,0 +1,62 @@
<template>
<teleport to="body">
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
<i class="mdi mdi-alert-outline text-danger" aria-hidden="true"></i>
<span>{{ title }}</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
{{ busy ? busyLabel : confirmLabel }}
</button>
</div>
</div>
</div>
</div>
<div v-if="visible" class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "Confirm Deletion",
},
message: {
type: String,
default: "Are you sure you want to continue?",
},
confirmLabel: {
type: String,
default: "Delete",
},
cancelLabel: {
type: String,
default: "Cancel",
},
busyLabel: {
type: String,
default: "Deleting...",
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "confirm"]);
</script>

View File

@@ -0,0 +1,24 @@
<template>
<section class="danger-zone" :aria-labelledby="titleId">
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
<p class="danger-zone-copy mb-3">{{ description }}</p>
<slot></slot>
</section>
</template>
<script setup>
defineProps({
titleId: {
type: String,
required: true,
},
title: {
type: String,
default: "Danger Zone",
},
description: {
type: String,
default: "This action is permanent and cannot be undone.",
},
});
</script>

View File

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

View File

@@ -78,7 +78,7 @@
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i> <i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span> <span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span> <span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)"> <button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i> <i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -87,11 +87,14 @@
<!-- Hidden file input --> <!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" /> <input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div> </div>
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
spaceId: { spaceId: {
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
const newFolderName = ref(""); const newFolderName = ref("");
const fileInputRef = ref(null); const fileInputRef = ref(null);
const newFolderInputRef = ref(null); const newFolderInputRef = ref(null);
const showDeleteConfirmModal = ref(false);
const pendingDeleteItem = ref(null);
const deletingItem = ref(false);
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
if (!currentPrefix.value) return []; if (!currentPrefix.value) return [];
@@ -215,9 +221,37 @@ const createFolder = async () => {
} }
}; };
const deleteItem = async (obj) => { const requestDeleteItem = (obj) => {
const label = displayName(obj); if (!obj) {
if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return; return;
}
pendingDeleteItem.value = obj;
showDeleteConfirmModal.value = true;
};
const closeDeleteConfirmModal = () => {
if (deletingItem.value) {
return;
}
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
};
const deleteConfirmMessage = computed(() => {
const obj = pendingDeleteItem.value;
const label = obj ? displayName(obj) : "this item";
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
});
const confirmDeleteItem = async () => {
const obj = pendingDeleteItem.value;
if (!obj) {
return;
}
deletingItem.value = true;
error.value = ""; error.value = "";
try { try {
if (obj.is_folder) { if (obj.is_folder) {
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } }); await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
} }
await loadFiles(); await loadFiles();
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
} catch (e) { } catch (e) {
error.value = e.response?.data || "Delete failed"; error.value = e.response?.data || "Delete failed";
} finally {
deletingItem.value = false;
} }
}; };

View File

@@ -125,16 +125,22 @@
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" /> <input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
</div> </div>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title"> <DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3> <button class="btn btn-danger" type="button" @click="requestDelete">
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
<button class="btn btn-danger" type="button" @click="confirmDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note Delete Note
</button> </button>
</section> </DangerZonePanel>
</div> </div>
</div> </div>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
@close="showDeleteConfirmModal = false"
@confirm="confirmDelete"
/>
</template> </template>
<script setup> <script setup>
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore"; import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue"; import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
const showTaskPicker = ref(false); const showTaskPicker = ref(false);
const taskPickerQuery = ref(""); const taskPickerQuery = ref("");
const taskPickerLoading = ref(false); const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value); const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value); const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
@@ -374,13 +383,22 @@ const autoSave = () => {
detectTaskMention(); detectTaskMention();
}; };
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
};
const confirmDelete = () => { const confirmDelete = () => {
if (!props.canDelete) { if (!props.canDelete) {
return; return;
} }
if (confirm("Are you sure you want to delete this note?")) { if (!editingNote.value?.id) {
emit("delete", editingNote.value.id); return;
} }
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
}; };
/** Insert markdown snippet at the textarea cursor position. */ /** Insert markdown snippet at the textarea cursor position. */

View File

@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document"> <div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -76,13 +76,17 @@
<template v-if="canDeleteSpace"> <template v-if="canDeleteSpace">
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</template> </template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div> <div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
@@ -93,12 +97,23 @@
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -130,6 +145,20 @@ const success = ref("");
const memberForm = ref({ user_id: "" }); const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view")); const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage")); const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
watch( watch(
() => props.space, () => props.space,
@@ -224,13 +253,24 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
if (!canManageMembers.value) { if (!canManageMembers.value) {
return; return;
} }
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]); Promise.all([loadMembers(), loadUserOptions()]);
} }
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>

View File

@@ -5,6 +5,7 @@
<h4 class="mb-0">Tasks</h4> <h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p> <p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div> </div>
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
</div> </div>
<div class="task-filters"> <div class="task-filters">
@@ -23,36 +24,6 @@
</select> </select>
</div> </div>
<div class="status-lane">
<div class="lane-header">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<div class="task-status-groups"> <div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div> <div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
@@ -184,42 +155,6 @@
</div> </div>
</section> </section>
</div> </div>
<teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="taskStatusName">Status Name</label>
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
<div class="status-color-row">
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
</teleport>
</section> </section>
</template> </template>
@@ -235,23 +170,18 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
selectedTaskList: {
type: Object,
default: null,
},
}); });
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]); const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
const filterStatus = ref(""); const filterStatus = ref("");
const filterParent = ref(""); const filterParent = ref("");
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const expandedTaskIds = ref({}); const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref(""); const openStatusMenuTaskId = ref("");
const statusForm = ref({
name: "",
color: "#7c8596",
});
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2)); const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => { const tasksById = computed(() => {
@@ -363,113 +293,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick); document.removeEventListener("click", onDocumentClick);
}); });
const onStatusDragStart = (statusId) => {
draggedStatusId.value = statusId;
};
const onStatusDragOver = (statusId) => {
dragOverStatusId.value = statusId;
};
const onStatusDragLeave = (statusId) => {
if (dragOverStatusId.value === statusId) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetStatusId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((item) => item.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetStatusId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetStatusId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
};
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = {
name: status.name || "",
color: status.color || "#7c8596",
};
showStatusModal.value = true;
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else {
if (!editingStatusId.value) {
return;
}
emit("rename-status", {
id: editingStatusId.value,
name,
color,
});
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script> </script>
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style> <style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>

View File

@@ -0,0 +1,145 @@
<template>
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
<CreateCategoryModal
v-if="showCreateCategoryModal"
:category="editingCategory"
:parent-options="categoryParentOptions"
:parent-id="categoryModalParentId"
@close="emit('close-create-category')"
@submit="emit('submit-category', $event)"
/>
<CreateNoteModal
v-if="showCreateNoteModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-note')"
@create="emit('create-note', $event)"
/>
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategoryId"
@close="emit('close-create-task-list')"
@create="emit('create-task-list', $event)"
/>
<SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace"
@close="emit('close-space-settings')"
@saved="emit('saved-space', $event)"
@deleted="emit('deleted-space', $event)"
/>
<TaskDetailModal
v-if="showTaskModal"
:task="taskModalDraft || {}"
:statuses="taskStatuses"
:parent-task-options="taskParentOptions"
:subtasks="taskDetailSubtasks"
@close="emit('close-task-modal')"
@save-task="emit('save-task', $event)"
@delete-task="emit('delete-task', $event)"
@transition="emit('transition-task', $event)"
@create-subtask="emit('create-subtask', $event)"
@open-task="emit('open-task', $event)"
/>
</template>
<script setup>
import CreateSpaceModal from "../CreateSpaceModal.vue";
import CreateCategoryModal from "../CreateCategoryModal.vue";
import CreateNoteModal from "../CreateNoteModal.vue";
import CreateTaskListModal from "../CreateTaskListModal.vue";
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
import TaskDetailModal from "../TaskDetailModal.vue";
defineProps({
showCreateSpaceModal: {
type: Boolean,
default: false,
},
showCreateCategoryModal: {
type: Boolean,
default: false,
},
editingCategory: {
type: Object,
default: null,
},
categoryParentOptions: {
type: Array,
default: () => [],
},
categoryModalParentId: {
type: [String, Number, null],
default: null,
},
showCreateNoteModal: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
selectedCategoryId: {
type: [String, Number, null],
default: null,
},
showCreateTaskListModal: {
type: Boolean,
default: false,
},
showSpaceSettingsModal: {
type: Boolean,
default: false,
},
currentSpace: {
type: Object,
default: null,
},
canManageSpaceSettings: {
type: Boolean,
default: false,
},
showTaskModal: {
type: Boolean,
default: false,
},
taskModalDraft: {
type: Object,
default: null,
},
taskStatuses: {
type: Array,
default: () => [],
},
taskParentOptions: {
type: Array,
default: () => [],
},
taskDetailSubtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits([
"close-create-space",
"create-space",
"close-create-category",
"submit-category",
"close-create-note",
"create-note",
"close-create-task-list",
"create-task-list",
"close-space-settings",
"saved-space",
"deleted-space",
"close-task-modal",
"save-task",
"delete-task",
"transition-task",
"create-subtask",
"open-task",
]);
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="content p-4">
<TaskBoard
v-if="activeView === 'tasks'"
:tasks="tasks"
:statuses="taskStatuses"
:selected-task-list="selectedTaskList"
@select-task="emit('select-task', $event)"
@filter-change="emit('filter-change', $event)"
@update-task-status="emit('update-task-status', $event)"
@edit-task-list="emit('edit-task-list')"
/>
<SearchResultsPage
v-else-if="isSearchRoute"
:items="searchItems"
:query="searchQuery"
:current-page="searchPage"
:page-size="searchPageSize"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@page-change="emit('page-change', $event)"
/>
<NoteEditor
v-else-if="selectedNote && isEditingNote"
:note="selectedNote"
:category-options="categoryOptions"
:can-delete="canDeleteNotes"
:space-id="currentSpaceId"
@save="emit('save-note', $event)"
@delete="emit('delete-note', $event)"
@cancel="emit('cancel-edit-note')"
@open-linked-task="emit('open-linked-task', $event)"
/>
<NoteViewer
v-else-if="selectedNote"
:note="selectedNote"
:category-options="categoryOptions"
:space-id="currentSpaceId"
:linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="emit('open-linked-task', $event)"
/>
<WorkspaceList
v-else
:items="displayedItems"
:can-load-more="canLoadMoreMainNotes"
:is-loading-more="isLoadingMoreMainNotes"
:view-mode="noteViewMode"
@select-note="emit('select-note', $event)"
@select-task-list="emit('select-task-list', $event)"
@load-more="emit('load-more')"
/>
</div>
</template>
<script setup>
import TaskBoard from "../TaskBoard.vue";
import SearchResultsPage from "../SearchResultsPage.vue";
import NoteEditor from "../NoteEditor.vue";
import NoteViewer from "../NoteViewer.vue";
import WorkspaceList from "../WorkspaceList.vue";
defineProps({
activeView: {
type: String,
required: true,
},
tasks: {
type: Array,
default: () => [],
},
taskStatuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
canDeleteTasks: {
type: Boolean,
default: false,
},
isSearchRoute: {
type: Boolean,
default: false,
},
searchItems: {
type: Array,
default: () => [],
},
searchQuery: {
type: String,
default: "",
},
searchPage: {
type: Number,
default: 1,
},
searchPageSize: {
type: Number,
default: 12,
},
noteViewMode: {
type: String,
default: "grid",
},
selectedNote: {
type: Object,
default: null,
},
isEditingNote: {
type: Boolean,
default: false,
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteNotes: {
type: Boolean,
default: false,
},
currentSpaceId: {
type: String,
default: "",
},
linkedTasksForSelectedNote: {
type: Array,
default: () => [],
},
displayedItems: {
type: Array,
default: () => [],
},
canLoadMoreMainNotes: {
type: Boolean,
default: false,
},
isLoadingMoreMainNotes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"select-task",
"filter-change",
"update-task-status",
"edit-task-list",
"select-note",
"select-task-list",
"page-change",
"save-note",
"delete-note",
"cancel-edit-note",
"open-linked-task",
"load-more",
]);
</script>

View File

@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css"; import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App); const app = createApp(App);

View File

@@ -73,7 +73,7 @@
<div class="user-row-actions"> <div class="user-row-actions">
<div class="d-flex gap-2 user-actions-stack"> <div class="d-flex gap-2 user-actions-stack">
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button> <button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button> <button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +286,16 @@
:deleting="deletingProviderModal" :deleting="deletingProviderModal"
@close="closeProviderModal" @close="closeProviderModal"
@submit="submitProviderModal" @submit="submitProviderModal"
@delete="deleteProviderFromModal" @delete="requestDeleteProvider"
/>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/> />
</template> </template>
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
import AdminGroupModal from "../components/AdminGroupModal.vue"; import AdminGroupModal from "../components/AdminGroupModal.vue";
import AdminUserModal from "../components/AdminUserModal.vue"; import AdminUserModal from "../components/AdminUserModal.vue";
import AdminProviderModal from "../components/AdminProviderModal.vue"; import AdminProviderModal from "../components/AdminProviderModal.vue";
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
const router = useRouter(); const router = useRouter();
const activeTab = ref("users"); const activeTab = ref("users");
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
const selectedProvider = ref(null); const selectedProvider = ref(null);
const submittingProviderModal = ref(false); const submittingProviderModal = ref(false);
const deletingProviderModal = ref(false); const deletingProviderModal = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const loadingFeatureFlags = ref(false); const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false); const savingFeatureFlags = ref(false);
@@ -365,6 +381,47 @@ const clearMessages = () => {
successMessage.value = ""; successMessage.value = "";
}; };
const deleteConfirmTitle = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
return "Delete User";
}
if (deleteConfirmIntent.value.type === "group") {
return "Delete Group";
}
if (deleteConfirmIntent.value.type === "provider") {
return "Delete Identity Provider";
}
return "Confirm Deletion";
});
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
const username = deleteConfirmIntent.value.payload?.username || "this user";
return `Delete user "${username}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "group") {
const name = deleteConfirmIntent.value.payload?.name || "this group";
return `Delete group "${name}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "provider") {
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
return `Delete identity provider "${name}"? This action cannot be undone.`;
}
return "Are you sure you want to continue?";
});
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const formatDate = (iso) => { const formatDate = (iso) => {
if (!iso) return ""; if (!iso) return "";
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
} }
}; };
const requestDeleteUser = (user) => {
if (!user?.id) {
return;
}
deleteConfirmIntent.value = {
type: "user",
payload: user,
};
showDeleteConfirmModal.value = true;
};
const deleteUser = async (user) => { const deleteUser = async (user) => {
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) { if (!user?.id) {
return; return;
} }
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
if (group.is_system) { if (group.is_system) {
return; return;
} }
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) { if (!group?.id) {
return; return;
} }
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
await Promise.all([loadGroups(), loadUsers()]); await Promise.all([loadGroups(), loadUsers()]);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete group."; error.value = e.response?.data || "Failed to delete group.";
throw e;
} }
}; };
const requestDeleteGroup = (group) => {
if (!group?.id || group.is_system) {
return;
}
deleteConfirmIntent.value = {
type: "group",
payload: group,
};
showDeleteConfirmModal.value = true;
};
const loadSpaces = async () => { const loadSpaces = async () => {
loadingSpaces.value = true; loadingSpaces.value = true;
clearMessages(); clearMessages();
@@ -631,12 +713,21 @@ const loadProviders = async () => {
} }
}; };
const deleteProviderFromModal = async (provider) => { const requestDeleteProvider = (provider) => {
if (!provider?.id) { if (!provider?.id) {
return; return;
} }
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) { closeProviderModal();
deleteConfirmIntent.value = {
type: "provider",
payload: { ...provider },
};
showDeleteConfirmModal.value = true;
};
const deleteProviderFromModal = async (provider) => {
if (!provider?.id) {
return; return;
} }
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
closeProviderModal(); closeProviderModal();
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete provider."; error.value = e.response?.data || "Failed to delete provider.";
throw e;
} finally { } finally {
deletingProviderModal.value = false; deletingProviderModal.value = false;
} }
}; };
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type || !payload) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "user") {
await deleteUser(payload);
} else if (type === "group") {
await deleteGroup(payload);
} else if (type === "provider") {
await deleteProviderFromModal(payload);
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
const loadFeatureFlags = async () => { const loadFeatureFlags = async () => {
loadingFeatureFlags.value = true; loadingFeatureFlags.value = true;
clearMessages(); clearMessages();

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
<template>
<div class="home-page">
<router-view />
</div>
</template>
<script setup></script>

View File

@@ -18,13 +18,25 @@ const routes = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/n/:noteId?",
name: "DashboardNote",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/t/:taskListId",
name: "DashboardTaskList",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "/search", path: "/search",
name: "Search", name: "Search",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {

View File

@@ -1,8 +1,10 @@
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : "";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", baseURL: runtimeOrigin,
withCredentials: true, withCredentials: true,
}); });

View File

@@ -19,7 +19,7 @@ export const useSpaceStore = defineStore("space", () => {
const noteLinkedTasks = ref([]); const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => { const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -212,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = []; searchResults.value = [];
}; };
const fetchTaskStatuses = async (spaceId) => { const fetchTaskStatuses = async (spaceId, taskListId) => {
if (!spaceId) { if (!spaceId || !taskListId) {
taskStatuses.value = []; taskStatuses.value = [];
return []; return [];
} }
try { try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`); const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
return taskStatuses.value; return taskStatuses.value;
} catch (error) { } catch (error) {
@@ -261,25 +261,25 @@ export const useSpaceStore = defineStore("space", () => {
await fetchTaskLists(spaceId); await fetchTaskLists(spaceId);
}; };
const createTaskStatus = async (spaceId, payload) => { const createTaskStatus = async (spaceId, taskListId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
return response.data; return response.data;
}; };
const updateTaskStatus = async (spaceId, statusId, payload) => { const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload); const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
return response.data; return response.data;
}; };
const deleteTaskStatus = async (spaceId, statusId) => { const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`); await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId, taskListId);
}; };
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => { const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, { const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
ordered_status_ids: orderedStatusIds, ordered_status_ids: orderedStatusIds,
}); });
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];