diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c67f04d..2298dea 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -135,6 +135,7 @@ func main() { db.MembershipRepo, db.NoteRepo, db.CategoryRepo, + db.TaskListRepo, db.UserRepo, permissionService, ) @@ -151,6 +152,7 @@ func main() { categoryService := services.NewCategoryService( db.CategoryRepo, + db.TaskListRepo, db.MembershipRepo, db.NoteRepo, permissionService, @@ -158,6 +160,7 @@ func main() { taskService := services.NewTaskService( db.TaskRepo, + db.TaskListRepo, db.TaskStatusRepo, db.NoteRepo, db.CategoryRepo, @@ -269,6 +272,11 @@ func main() { api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") // Task endpoints + api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST") api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET") diff --git a/backend/internal/application/dto/dto.go b/backend/internal/application/dto/dto.go index a188f14..46afb45 100644 --- a/backend/internal/application/dto/dto.go +++ b/backend/internal/application/dto/dto.go @@ -430,6 +430,7 @@ type CategoryTreeDTO struct { *CategoryDTO Subcategories []*CategoryTreeDTO `json:"subcategories"` Notes []*NoteListItemDTO `json:"notes"` + TaskLists []*TaskListDTO `json:"task_lists"` } // NewCategoryDTO creates a DTO from a category entity @@ -458,7 +459,7 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO { type CreateTaskRequest struct { Title string `json:"title" validate:"required,min=1,max=255"` Description string `json:"description" validate:"max=2000"` - CategoryID *string `json:"category_id,omitempty"` + TaskListID string `json:"task_list_id" validate:"required"` StatusID string `json:"status_id" validate:"required"` ParentTaskID *string `json:"parent_task_id,omitempty"` NoteLinks []string `json:"note_links"` @@ -468,7 +469,7 @@ type CreateTaskRequest struct { type UpdateTaskRequest struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - CategoryID *string `json:"category_id,omitempty"` + TaskListID *string `json:"task_list_id,omitempty"` StatusID *string `json:"status_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"` NoteLinks []string `json:"note_links,omitempty"` @@ -490,7 +491,7 @@ type TaskDTO struct { SpaceID string `json:"space_id"` Title string `json:"title"` Description string `json:"description"` - CategoryID *string `json:"category_id,omitempty"` + TaskListID string `json:"task_list_id"` StatusID string `json:"status_id"` ParentTaskID *string `json:"parent_task_id,omitempty"` Depth int `json:"depth"` @@ -538,14 +539,35 @@ type TaskStatusDTO struct { UpdatedAt string `json:"updated_at"` } +// CreateTaskListRequest represents task list creation input. +type CreateTaskListRequest struct { + Name string `json:"name" validate:"required,min=1,max=120"` + Description string `json:"description" validate:"max=500"` + CategoryID *string `json:"category_id,omitempty"` +} + +// UpdateTaskListRequest represents task list update input. +type UpdateTaskListRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + CategoryID *string `json:"category_id,omitempty"` +} + +// TaskListDTO represents a task list in API responses. +type TaskListDTO struct { + ID string `json:"id"` + SpaceID string `json:"space_id"` + CategoryID *string `json:"category_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // NewTaskDTO creates a DTO from a task entity. func NewTaskDTO(task *entities.Task) *TaskDTO { - var categoryID *string - if task.CategoryID != nil { - id := task.CategoryID.Hex() - categoryID = &id - } - var parentTaskID *string if task.ParentTaskID != nil { id := task.ParentTaskID.Hex() @@ -562,7 +584,7 @@ func NewTaskDTO(task *entities.Task) *TaskDTO { SpaceID: task.SpaceID.Hex(), Title: task.Title, Description: task.Description, - CategoryID: categoryID, + TaskListID: task.TaskListID.Hex(), StatusID: task.StatusID.Hex(), ParentTaskID: parentTaskID, Depth: task.Depth, @@ -574,6 +596,27 @@ func NewTaskDTO(task *entities.Task) *TaskDTO { } } +// NewTaskListDTO creates a DTO from a task list entity. +func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO { + var categoryID *string + if taskList.CategoryID != nil { + id := taskList.CategoryID.Hex() + categoryID = &id + } + + return &TaskListDTO{ + ID: taskList.ID.Hex(), + SpaceID: taskList.SpaceID.Hex(), + CategoryID: categoryID, + Name: taskList.Name, + Description: taskList.Description, + CreatedBy: taskList.CreatedBy.Hex(), + UpdatedBy: taskList.UpdatedBy.Hex(), + CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + // NewTaskStatusDTO creates a DTO from a task status entity. func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { return &TaskStatusDTO{ diff --git a/backend/internal/application/services/category_service.go b/backend/internal/application/services/category_service.go index a31a5cc..5551253 100644 --- a/backend/internal/application/services/category_service.go +++ b/backend/internal/application/services/category_service.go @@ -15,6 +15,7 @@ import ( // CategoryService handles category operations type CategoryService struct { categoryRepo repositories.CategoryRepository + taskListRepo repositories.TaskListRepository membershipRepo repositories.MembershipRepository noteRepo repositories.NoteRepository permissionService *PermissionService @@ -23,12 +24,14 @@ type CategoryService struct { // NewCategoryService creates a new category service func NewCategoryService( categoryRepo repositories.CategoryRepository, + taskListRepo repositories.TaskListRepository, membershipRepo repositories.MembershipRepository, noteRepo repositories.NoteRepository, permissionService *PermissionService, ) *CategoryService { return &CategoryService{ categoryRepo: categoryRepo, + taskListRepo: taskListRepo, membershipRepo: membershipRepo, noteRepo: noteRepo, permissionService: permissionService, @@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit } } + // Get task lists in this category + taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID) + if err == nil { + for _, taskList := range taskLists { + tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList)) + } + } + return tree, nil } diff --git a/backend/internal/application/services/space_service.go b/backend/internal/application/services/space_service.go index 07f281a..fb9c257 100644 --- a/backend/internal/application/services/space_service.go +++ b/backend/internal/application/services/space_service.go @@ -17,6 +17,7 @@ type SpaceService struct { membershipRepo repositories.MembershipRepository noteRepo repositories.NoteRepository categoryRepo repositories.CategoryRepository + taskListRepo repositories.TaskListRepository userRepo repositories.UserRepository permissionService *PermissionService } @@ -27,6 +28,7 @@ func NewSpaceService( membershipRepo repositories.MembershipRepository, noteRepo repositories.NoteRepository, categoryRepo repositories.CategoryRepository, + taskListRepo repositories.TaskListRepository, userRepo repositories.UserRepository, permissionService *PermissionService, ) *SpaceService { @@ -35,6 +37,7 @@ func NewSpaceService( membershipRepo: membershipRepo, noteRepo: noteRepo, categoryRepo: categoryRepo, + taskListRepo: taskListRepo, userRepo: userRepo, permissionService: permissionService, } @@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { return err } + if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil { + return err + } if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { return err } diff --git a/backend/internal/application/services/task_service.go b/backend/internal/application/services/task_service.go index 2809efb..0daf053 100644 --- a/backend/internal/application/services/task_service.go +++ b/backend/internal/application/services/task_service.go @@ -17,6 +17,7 @@ import ( // TaskService handles task and task status operations. type TaskService struct { taskRepo repositories.TaskRepository + taskListRepo repositories.TaskListRepository taskStatusRepo repositories.TaskStatusRepository noteRepo repositories.NoteRepository categoryRepo repositories.CategoryRepository @@ -27,6 +28,7 @@ type TaskService struct { // NewTaskService creates a task service. func NewTaskService( taskRepo repositories.TaskRepository, + taskListRepo repositories.TaskListRepository, taskStatusRepo repositories.TaskStatusRepository, noteRepo repositories.NoteRepository, categoryRepo repositories.CategoryRepository, @@ -35,6 +37,7 @@ func NewTaskService( ) *TaskService { return &TaskService{ taskRepo: taskRepo, + taskListRepo: taskListRepo, taskStatusRepo: taskStatusRepo, noteRepo: noteRepo, categoryRepo: categoryRepo, @@ -121,13 +124,10 @@ func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) { return result, nil } -func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error { - if categoryID == nil { - return nil - } - category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID) - if err != nil || category.SpaceID != spaceID { - return errors.New("invalid category") +func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error { + taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || taskList.SpaceID != spaceID { + return errors.New("invalid task list") } return nil } @@ -209,14 +209,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec return nil, err } - categoryID, err := toObjectIDPtr(req.CategoryID) - if err != nil { - return nil, errors.New("invalid category") - } - if err := s.validateCategory(ctx, spaceID, categoryID); err != nil { - return nil, err - } - parentTaskID, err := toObjectIDPtr(req.ParentTaskID) if err != nil { return nil, errors.New("invalid parent task") @@ -226,6 +218,14 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec return nil, err } + taskListID, err := bson.ObjectIDFromHex(strings.TrimSpace(req.TaskListID)) + if err != nil { + return nil, errors.New("invalid task list") + } + if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil { + return nil, err + } + noteLinks, err := toObjectIDs(req.NoteLinks) if err != nil { return nil, err @@ -258,11 +258,19 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec statusID = parsedStatusID } + if parentTaskID != nil { + parent, parentErr := s.taskRepo.GetTaskByID(ctx, *parentTaskID) + if parentErr != nil || parent.SpaceID != spaceID { + return nil, errors.New("invalid parent task") + } + taskListID = parent.TaskListID + } + task := &entities.Task{ SpaceID: spaceID, Title: strings.TrimSpace(req.Title), Description: strings.TrimSpace(req.Description), - CategoryID: categoryID, + TaskListID: taskListID, StatusID: statusID, ParentTaskID: parentTaskID, Depth: depth, @@ -318,7 +326,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b func (s *TaskService) ListTasks( ctx context.Context, spaceID, userID bson.ObjectID, - categoryID, statusID, parentTaskID *string, + taskListID, statusID, parentTaskID *string, ) ([]*dto.TaskDTO, error) { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { return nil, err @@ -328,12 +336,12 @@ func (s *TaskService) ListTasks( } filters := map[string]any{} - if categoryID != nil && strings.TrimSpace(*categoryID) != "" { - id, err := bson.ObjectIDFromHex(*categoryID) + if taskListID != nil && strings.TrimSpace(*taskListID) != "" { + id, err := bson.ObjectIDFromHex(*taskListID) if err != nil { - return nil, errors.New("invalid category filter") + return nil, errors.New("invalid task list filter") } - filters["category_id"] = id + filters["task_list_id"] = id } if statusID != nil && strings.TrimSpace(*statusID) != "" { id, err := bson.ObjectIDFromHex(*statusID) @@ -453,19 +461,21 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs task.Description = strings.TrimSpace(*req.Description) } - if req.CategoryID != nil { - if strings.TrimSpace(*req.CategoryID) == "" { - task.CategoryID = nil - } else { - categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID) - if parseErr != nil { - return nil, errors.New("invalid category") - } - task.CategoryID = &categoryID + if req.TaskListID != nil { + if strings.TrimSpace(*req.TaskListID) == "" { + return nil, errors.New("task list is required") } - if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil { + taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID) + if parseErr != nil { + return nil, errors.New("invalid task list") + } + if task.ParentTaskID != nil { + return nil, errors.New("subtasks inherit task list from parent") + } + if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil { return nil, err } + task.TaskListID = taskListID } if req.ParentTaskID != nil { @@ -486,6 +496,11 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs } task.ParentTaskID = &parentID task.Depth = depth + parentTask, parentErr := s.taskRepo.GetTaskByID(ctx, parentID) + if parentErr != nil || parentTask.SpaceID != spaceID { + return nil, errors.New("invalid parent task") + } + task.TaskListID = parentTask.TaskListID } } @@ -670,6 +685,144 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n return dto.NewTaskDTO(task), nil } +func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID) + if err != nil { + return nil, err + } + result := make([]*dto.TaskListDTO, 0, len(lists)) + for _, list := range lists { + result = append(result, dto.NewTaskListDTO(list)) + } + return result, nil +} + +func (s *TaskService) CreateTaskList(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskListRequest) (*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errors.New("task list name is required") + } + + var categoryID *bson.ObjectID + if req.CategoryID != nil && strings.TrimSpace(*req.CategoryID) != "" { + id, parseErr := bson.ObjectIDFromHex(*req.CategoryID) + if parseErr != nil { + return nil, errors.New("invalid category") + } + category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, id) + if categoryErr != nil || category.SpaceID != spaceID { + return nil, errors.New("invalid category") + } + categoryID = &id + } + + list := &entities.TaskList{ + SpaceID: spaceID, + CategoryID: categoryID, + Name: name, + Description: strings.TrimSpace(req.Description), + CreatedBy: userID, + UpdatedBy: userID, + } + + if err := s.taskListRepo.CreateTaskList(ctx, list); err != nil { + return nil, err + } + return dto.NewTaskListDTO(list), nil +} + +func (s *TaskService) UpdateTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.UpdateTaskListRequest) (*dto.TaskListDTO, error) { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return nil, err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || list.SpaceID != spaceID { + return nil, errors.New("task list not found") + } + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, errors.New("task list name is required") + } + list.Name = name + } + if req.Description != nil { + list.Description = strings.TrimSpace(*req.Description) + } + if req.CategoryID != nil { + if strings.TrimSpace(*req.CategoryID) == "" { + list.CategoryID = nil + } else { + categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID) + if parseErr != nil { + return nil, errors.New("invalid category") + } + category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, categoryID) + if categoryErr != nil || category.SpaceID != spaceID { + return nil, errors.New("invalid category") + } + list.CategoryID = &categoryID + } + } + + list.UpdatedBy = userID + if err := s.taskListRepo.UpdateTaskList(ctx, list); err != nil { + return nil, err + } + return dto.NewTaskListDTO(list), nil +} + +func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) error { + if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { + return err + } + hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete") + if err != nil { + return err + } + if !hasPermission { + return errors.New("insufficient permissions") + } + + list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID) + if err != nil || list.SpaceID != spaceID { + return errors.New("task list not found") + } + + tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"task_list_id": taskListID}) + if err != nil { + return err + } + if len(tasks) > 0 { + return errors.New("cannot delete task list with tasks") + } + + return s.taskListRepo.DeleteTaskList(ctx, taskListID) +} + func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { return nil, err diff --git a/backend/internal/domain/entities/task.go b/backend/internal/domain/entities/task.go index 40fc291..12d68b3 100644 --- a/backend/internal/domain/entities/task.go +++ b/backend/internal/domain/entities/task.go @@ -14,7 +14,7 @@ type Task struct { SpaceID bson.ObjectID `bson:"space_id"` Title string `bson:"title"` Description string `bson:"description"` - CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + TaskListID bson.ObjectID `bson:"task_list_id"` StatusID bson.ObjectID `bson:"status_id"` ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` Depth int `bson:"depth"` @@ -35,3 +35,16 @@ type TaskStatus struct { CreatedAt time.Time `bson:"created_at"` UpdatedAt time.Time `bson:"updated_at"` } + +// TaskList groups tasks under a named list that can be attached to a category. +type TaskList struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + Name string `bson:"name"` + Description string `bson:"description,omitempty"` + CreatedBy bson.ObjectID `bson:"created_by"` + UpdatedBy bson.ObjectID `bson:"updated_by"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} diff --git a/backend/internal/domain/repositories/interfaces.go b/backend/internal/domain/repositories/interfaces.go index 6be8bb9..4432152 100644 --- a/backend/internal/domain/repositories/interfaces.go +++ b/backend/internal/domain/repositories/interfaces.go @@ -229,6 +229,17 @@ type TaskRepository interface { CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) } +// TaskListRepository defines task list operations. +type TaskListRepository interface { + CreateTaskList(ctx context.Context, list *entities.TaskList) error + GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) + ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) + ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) + UpdateTaskList(ctx context.Context, list *entities.TaskList) error + DeleteTaskList(ctx context.Context, id bson.ObjectID) error + DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error +} + // TaskStatusRepository defines task status operations type TaskStatusRepository interface { CreateStatus(ctx context.Context, status *entities.TaskStatus) error diff --git a/backend/internal/infrastructure/database/database.go b/backend/internal/infrastructure/database/database.go index ae850b0..67cc9b4 100644 --- a/backend/internal/infrastructure/database/database.go +++ b/backend/internal/infrastructure/database/database.go @@ -16,6 +16,7 @@ type Database struct { MembershipRepo *MembershipRepository NoteRepo *NoteRepository CategoryRepo *CategoryRepository + TaskListRepo *TaskListRepository TaskRepo *TaskRepository TaskStatusRepo *TaskStatusRepository RevisionRepo *NoteRevisionRepository @@ -49,6 +50,7 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) { MembershipRepo: NewMembershipRepository(db), NoteRepo: NewNoteRepository(db), CategoryRepo: NewCategoryRepository(db), + TaskListRepo: NewTaskListRepository(db), TaskRepo: NewTaskRepository(db), TaskStatusRepo: NewTaskStatusRepository(db), RevisionRepo: NewNoteRevisionRepository(db), @@ -87,6 +89,9 @@ func (d *Database) EnsureIndexes(ctx context.Context) error { if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { return err } + if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil { + return err + } if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { return err } diff --git a/backend/internal/infrastructure/database/task_repository.go b/backend/internal/infrastructure/database/task_repository.go index 742f586..688ec0e 100644 --- a/backend/internal/infrastructure/database/task_repository.go +++ b/backend/internal/infrastructure/database/task_repository.go @@ -108,13 +108,95 @@ func (r *TaskRepository) EnsureIndexes(ctx context.Context) error { _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}}, - {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}}, }) return err } +// TaskListRepository implements task list data access. +type TaskListRepository struct { + collection *mongo.Collection +} + +// NewTaskListRepository creates a new task list repository. +func NewTaskListRepository(db *mongo.Database) *TaskListRepository { + return &TaskListRepository{collection: db.Collection("task_lists")} +} + +func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error { + list.ID = bson.NewObjectID() + list.CreatedAt = time.Now() + list.UpdatedAt = time.Now() + _, err := r.collection.InsertOne(ctx, list) + return err +} + +func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) { + var list entities.TaskList + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("task list not found") + } + return nil, err + } + return &list, nil +} + +func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) { + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var lists []*entities.TaskList + if err := cursor.All(ctx, &lists); err != nil { + return nil, err + } + return lists, nil +} + +func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) { + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var lists []*entities.TaskList + if err := cursor.All(ctx, &lists); err != nil { + return nil, err + } + return lists, nil +} + +func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error { + list.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list) + return err +} + +func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error { + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}}, + }) + return err +} + // TaskStatusRepository implements task status data access. type TaskStatusRepository struct { collection *mongo.Collection diff --git a/backend/internal/interfaces/handlers/task_handler.go b/backend/internal/interfaces/handlers/task_handler.go index b31aa81..a1d28ca 100644 --- a/backend/internal/interfaces/handlers/task_handler.go +++ b/backend/internal/interfaces/handlers/task_handler.go @@ -64,15 +64,15 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { return } - categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId")) + taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId")) statusID := strings.TrimSpace(r.URL.Query().Get("statusId")) parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) - categoryFilter := &categoryID + taskListFilter := &taskListID statusFilter := &statusID parentFilter := &parentTaskID - if categoryID == "" { - categoryFilter = nil + if taskListID == "" { + taskListFilter = nil } if statusID == "" { statusFilter = nil @@ -81,7 +81,7 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { parentFilter = nil } - tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter) + tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -91,6 +91,94 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(tasks) } +func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(lists) +} + +func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + var req dto.CreateTaskListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(list) +} + +func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"]) + if err != nil { + http.Error(w, "invalid task list id", http.StatusBadRequest) + return + } + + var req dto.UpdateTaskListRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(list) +} + +func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) { + userID, spaceID, err := parseIDsFromRequest(r) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"]) + if err != nil { + http.Error(w, "invalid task list id", http.StatusBadRequest) + return + } + + if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) { userID, spaceID, err := parseIDsFromRequest(r) if err != nil { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 75ccefc..7a75c3d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -44,7 +44,7 @@ @@ -98,6 +98,7 @@ :on-add-subcategory="openCreateSubcategoryModal" :on-edit-category="openEditCategoryModal" :on-delete-category="removeCategory" + :on-select-task-list="selectTaskList" :can-create-categories="canCreateCategories" :can-edit-categories="canEditCategories" :can-delete-categories="canDeleteCategories" @@ -128,9 +129,36 @@
-
- - +
- -
@@ -192,8 +212,6 @@ v-if="activeView === 'tasks'" :tasks="tasks" :statuses="taskStatuses" - :category-options="categoryOptions" - @create-task="openTaskCreateModal" @select-task="openTaskDetail" @filter-change="applyTaskFilters" @reorder-status="reorderTaskStatuses" @@ -204,12 +222,13 @@ /> - @@ -279,6 +299,13 @@ @submit="submitCategory" /> + authStore.user); const isAdminRoute = computed(() => route.path === "/admin"); @@ -410,6 +441,19 @@ const isAuthRoute = computed(() => route.path === "/login" || route.path === "/r const spaces = computed(() => spaceStore.spaces); const currentSpace = computed(() => spaceStore.currentSpace); const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults)); +const searchItems = computed(() => { + const query = searchQuery.value.toLowerCase(); + const noteItems = searchResults.value.map((note) => ({ + ...note, + kind: "note", + })); + const matchingTaskLists = (spaceStore.taskLists || []).filter((tl) => tl.name.toLowerCase().includes(query)); + const taskListItems = matchingTaskLists.map((tl) => ({ + ...tl, + kind: "task-list", + })); + return [...taskListItems, ...noteItems]; +}); const searchPageSize = 12; const searchPage = computed(() => { const pageValue = Number.parseInt(route.query.page || "1", 10); @@ -469,13 +513,46 @@ const collectNotesFromCategory = (category, bucket = []) => { return bucket; }; +const collectTaskListsFromCategory = (category, bucket = []) => { + if (!category) { + return bucket; + } + if (Array.isArray(category.task_lists) && category.task_lists.length) { + bucket.push(...category.task_lists); + } + for (const subcategory of category.subcategories || []) { + collectTaskListsFromCategory(subcategory, bucket); + } + return bucket; +}; + const displayedNotes = computed(() => { if (!selectedCategory.value) { return sortNotesByPriority(spaceStore.notes); } return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, [])); }); + +const displayedTaskLists = computed(() => { + if (!selectedCategory.value) { + return [...(spaceStore.taskLists || [])].sort((left, right) => left.name.localeCompare(right.name)); + } + return [...collectTaskListsFromCategory(selectedCategory.value, [])].sort((left, right) => left.name.localeCompare(right.name)); +}); + +const displayedItems = computed(() => { + const taskListItems = displayedTaskLists.value.map((taskList) => ({ + ...taskList, + kind: "task-list", + })); + const noteItems = displayedNotes.value.map((note) => ({ + ...note, + kind: "note", + })); + return [...taskListItems, ...noteItems]; +}); const tasks = computed(() => spaceStore.tasks || []); +const taskLists = computed(() => spaceStore.taskLists || []); const taskStatuses = computed(() => spaceStore.taskStatuses || []); const initialTaskStatusId = computed(() => { if (!taskStatuses.value.length) { @@ -518,6 +595,13 @@ const openSpaceHome = () => { unlockPassword.value = ""; unlockError.value = ""; searchQuery.value = ""; + selectedTaskList.value = null; + activeView.value = "notes"; + taskFilters.value = { + taskListId: null, + statusId: null, + parentTaskId: null, + }; spaceStore.clearSearchResults(); if (route.path !== "/") { router.push("/"); @@ -580,6 +664,27 @@ const breadcrumbItems = computed(() => { return items; } + if (activeView.value === "tasks" && selectedTaskList.value) { + const taskListCategoryId = selectedTaskList.value.category_id; + if (taskListCategoryId) { + const categoryTrail = findCategoryPath(categoryTree.value, taskListCategoryId) || []; + for (const category of categoryTrail) { + items.push({ + label: category.name, + clickable: true, + onClick: () => openCategoryFromBreadcrumb(category), + }); + } + } + + items.push({ + label: selectedTaskList.value.name, + clickable: false, + onClick: null, + }); + return items; + } + if (selectedCategory.value) { const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value]; for (let i = 0; i < categoryTrail.length; i++) { @@ -710,6 +815,14 @@ const toggleUserMenu = () => { } }; +const toggleCreateMenu = () => { + showCreateMenu.value = !showCreateMenu.value; + if (showCreateMenu.value) { + showSpaceDropdown.value = false; + showUserMenu.value = false; + } +}; + const handleDocumentClick = (event) => { const target = event.target; @@ -720,6 +833,10 @@ const handleDocumentClick = (event) => { if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) { showUserMenu.value = false; } + + if (showCreateMenu.value && createDropdownRef.value && !createDropdownRef.value.contains(target)) { + showCreateMenu.value = false; + } }; const handleEscapeKey = (event) => { @@ -728,6 +845,7 @@ const handleEscapeKey = (event) => { } showSpaceDropdown.value = false; showUserMenu.value = false; + showCreateMenu.value = false; showSidebar.value = false; }; @@ -737,7 +855,9 @@ const selectSpace = async (space) => { localStorage.setItem("currentSpaceId", space.id); selectedNote.value = null; selectedCategory.value = null; + selectedTaskList.value = null; isEditingNote.value = false; + activeView.value = "notes"; linkedTasksForSelectedNote.value = []; await applyTaskFilters(taskFilters.value); }; @@ -782,8 +902,13 @@ const selectNote = async (note) => { showUnlockModal.value = true; selectedNote.value = null; selectedCategory.value = null; + selectedTaskList.value = null; isEditingNote.value = false; + activeView.value = "notes"; showSidebar.value = false; + if (route.path === "/search") { + router.push("/"); + } return; } @@ -795,8 +920,13 @@ const selectNote = async (note) => { const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`); selectedNote.value = response.data; selectedCategory.value = null; + selectedTaskList.value = null; isEditingNote.value = false; + activeView.value = "notes"; showSidebar.value = false; + if (route.path === "/search") { + router.push("/"); + } } catch { alert("Unable to load note content."); } @@ -828,7 +958,9 @@ const unlockProtectedNote = async () => { }); selectedNote.value = response.data; selectedCategory.value = null; + selectedTaskList.value = null; isEditingNote.value = false; + activeView.value = "notes"; showSidebar.value = false; closeUnlockModal(); } catch (error) { @@ -840,11 +972,31 @@ const unlockProtectedNote = async () => { const selectCategory = (category) => { selectedCategory.value = category; + selectedTaskList.value = null; selectedNote.value = null; isEditingNote.value = false; + activeView.value = "notes"; showSidebar.value = false; }; +const selectTaskList = async (taskList) => { + selectedTaskList.value = taskList; + selectedCategory.value = null; + selectedNote.value = null; + isEditingNote.value = false; + activeView.value = "tasks"; + showSidebar.value = false; + + if (route.path === "/search") { + router.push("/"); + } + + await applyTaskFilters({ + ...taskFilters.value, + taskListId: taskList?.id || null, + }); +}; + const performSearch = async () => { const q = searchQuery.value.trim(); if (!q) { @@ -903,22 +1055,30 @@ const loadMoreMainNotes = async () => { }; const applyTaskFilters = async (filters) => { - taskFilters.value = filters; + taskFilters.value = { + ...taskFilters.value, + ...filters, + taskListId: selectedTaskList.value?.id || taskFilters.value.taskListId || null, + }; if (!currentSpace.value?.id) { return; } - await spaceStore.fetchTasks(currentSpace.value.id, filters); + await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value); }; const openTaskCreateModal = () => { if (!canCreateTasks.value) { return; } + if (!selectedTaskList.value?.id) { + alert("Select a task list first."); + return; + } taskDetail.value = null; taskModalDraft.value = { title: "", description: "", - category_id: selectedCategory.value?.id || null, + task_list_id: selectedTaskList.value?.id || null, status_id: initialTaskStatusId.value, parent_task_id: null, note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], @@ -1010,7 +1170,7 @@ const createSubtask = (parentTask) => { taskModalDraft.value = { title: "", description: "", - category_id: parentTask.category_id || null, + task_list_id: parentTask.task_list_id || selectedTaskList.value?.id || null, status_id: initialTaskStatusId.value, parent_task_id: parentTask.id, note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], @@ -1102,10 +1262,38 @@ const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatus }; const openLinkedTaskFromNote = async (task) => { + selectedTaskList.value = taskLists.value.find((list) => list.id === task.task_list_id) || null; + selectedNote.value = null; + await applyTaskFilters({ taskListId: selectedTaskList.value?.id || null }); activeView.value = "tasks"; await openTaskDetail(task); }; +const openCreateNoteModalFromMenu = () => { + showCreateMenu.value = false; + showCreateNoteModal.value = true; +}; + +const openCreateTaskListModalFromMenu = () => { + showCreateMenu.value = false; + showCreateTaskListModal.value = true; +}; + +const createTaskList = async (taskListData) => { + if (!currentSpace.value?.id || !canCreateTasks.value) { + showCreateTaskListModal.value = false; + return; + } + + try { + const created = await spaceStore.createTaskList(currentSpace.value.id, taskListData); + showCreateTaskListModal.value = false; + await selectTaskList(created); + } catch (error) { + alert(error?.response?.data || "Unable to create task list."); + } +}; + const createSpace = async (spaceData) => { showCreateSpaceModal.value = false; await spaceStore.createSpace(spaceData); @@ -1259,6 +1447,3 @@ const logout = () => { - - - diff --git a/frontend/src/assets/styles/scoped/components/CategoryTree.css b/frontend/src/assets/styles/scoped/components/CategoryTree.css index cab2106..f11a881 100644 --- a/frontend/src/assets/styles/scoped/components/CategoryTree.css +++ b/frontend/src/assets/styles/scoped/components/CategoryTree.css @@ -90,6 +90,27 @@ margin-bottom: 0.25rem; } +.task-list-item { + display: flex; + align-items: center; + padding: 0.5rem; + cursor: pointer; + border-radius: 4px; + font-size: 0.95rem; + margin-bottom: 0.25rem; + color: #2f3d52; + background: #f7f9ff; + border: 1px solid #d9e3ff; +} + +.task-list-item:hover { + background: #e9efff; +} + +.task-list-item span { + flex-grow: 1; +} + .note-item:hover { background-color: #e9ecef; } @@ -120,12 +141,12 @@ } .note-item.is-featured { - background: var(--color-surface)9db; + background: #fff9db; border: 1px solid #ffd8a8; } .note-item.is-featured:hover { - background: var(--color-surface)6c5; + background: #fff3c5; } .subcategories { @@ -163,6 +184,16 @@ background-color: var(--color-surface-muted); } +:root[data-bs-theme="dark"] .task-list-item { + background: #1f2a44; + border-color: #334b7d; + color: #bfceef; +} + +:root[data-bs-theme="dark"] .task-list-item:hover { + background: #26365b; +} + :root[data-bs-theme="dark"] .note-item.is-pinned { background: #1a3a5c; border-color: #2d6a9f; @@ -180,5 +211,3 @@ :root[data-bs-theme="dark"] .note-item.is-featured:hover { background: #453710; } - - diff --git a/frontend/src/assets/styles/scoped/components/TaskBoard.css b/frontend/src/assets/styles/scoped/components/TaskBoard.css index c89c592..e8cf942 100644 --- a/frontend/src/assets/styles/scoped/components/TaskBoard.css +++ b/frontend/src/assets/styles/scoped/components/TaskBoard.css @@ -13,7 +13,7 @@ .task-filters { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; } @@ -292,7 +292,7 @@ .danger-zone { border: 1px solid #f3b5b5; border-radius: 0.75rem; - background: var(--color-surface)5f5; + background: var(--color-surface) 5f5; padding: 0.75rem; } @@ -399,5 +399,3 @@ :root[data-bs-theme="dark"] .empty-state { color: #7a8fa8; } - - diff --git a/frontend/src/assets/styles/scoped/components/NoteList.css b/frontend/src/assets/styles/scoped/components/WorkspaceList.css similarity index 65% rename from frontend/src/assets/styles/scoped/components/NoteList.css rename to frontend/src/assets/styles/scoped/components/WorkspaceList.css index cff41a8..b9da808 100644 --- a/frontend/src/assets/styles/scoped/components/NoteList.css +++ b/frontend/src/assets/styles/scoped/components/WorkspaceList.css @@ -1,10 +1,10 @@ -.note-list { +.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; } -.empty-notes-state { +.empty-workspace-state { grid-column: 1 / -1; min-height: 48vh; display: flex; @@ -18,41 +18,42 @@ padding: 2rem 1.5rem; } -.empty-notes-icon { +.empty-workspace-icon { font-size: 5.25rem; line-height: 1; color: #60789a; margin-bottom: 0.85rem; } -.empty-notes-title { +.empty-workspace-title { margin: 0; color: #23364f; font-size: 1.8rem; font-weight: 700; } -.empty-notes-message { +.empty-workspace-message { margin: 0.75rem 0 0; max-width: 460px; color: #4f637d; font-size: 1.05rem; } -.note-card { +.content-card { border: 1px solid var(--color-border); border-radius: 8px; padding: 1rem; cursor: pointer; transition: all 0.3s ease; + background: var(--color-surface); } -.note-card:hover { +.content-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); transform: translateY(-2px); } -.note-title { +.content-title { margin-bottom: 0.5rem; color: var(--color-text); display: flex; @@ -60,6 +61,14 @@ gap: 0.3rem; } +.content-preview { + color: #666; + margin-bottom: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .pin-icon { color: #408aca; font-size: 0.9em; @@ -72,22 +81,25 @@ flex-shrink: 0; } -.note-card.is-pinned { +.list-icon { + color: #5568a8; + font-size: 1rem; + flex-shrink: 0; +} + +.content-card.is-pinned { background: #dbf5ff; border-color: #a8d1ff; } -.note-card.is-featured { +.content-card.is-featured { border-color: #ffd8a8; - background: var(--color-surface)9db; + background: #fff9db; } -.note-preview { - color: #666; - margin-bottom: 0.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.content-card.is-task-list { + border-color: #d9e3ff; + background: #f7f9ff; } .list-footer { @@ -97,14 +109,13 @@ margin-top: 0.5rem; } -/* List view overrides */ -.note-list--list { +.workspace-list--list { display: flex; flex-direction: column; gap: 0.4rem; } -.note-list--list .note-card { +.workspace-list--list .content-card { display: flex; align-items: center; gap: 1rem; @@ -112,7 +123,7 @@ border-radius: 6px; } -.note-list--list .note-card:hover { +.workspace-list--list .content-card:hover { transform: none; box-shadow: none; background-color: #eef2ff; @@ -120,7 +131,7 @@ border-left: 3px solid var(--color-primary); } -.note-list--list .note-title { +.workspace-list--list .content-title { flex: 0 0 220px; margin-bottom: 0; white-space: nowrap; @@ -128,80 +139,82 @@ text-overflow: ellipsis; } -.note-list--list .note-preview { +.workspace-list--list .content-preview { flex: 1; margin-bottom: 0; } -.note-list--list .note-card > small { +.workspace-list--list .content-card > small { flex: 0 0 auto; white-space: nowrap; } -.note-list--list .list-footer { - grid-column: unset; -} - @media (max-width: 768px) { - .empty-notes-state { + .empty-workspace-state { min-height: 40vh; padding: 1.5rem 1rem; } - .empty-notes-icon { + .empty-workspace-icon { font-size: 4.3rem; } - .empty-notes-title { + .empty-workspace-title { font-size: 1.45rem; } } -/* Dark mode overrides */ -:root[data-bs-theme="dark"] .empty-notes-state { +:root[data-bs-theme="dark"] .empty-workspace-state { border-color: var(--color-border); background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%); } -:root[data-bs-theme="dark"] .empty-notes-title { +:root[data-bs-theme="dark"] .empty-workspace-title { color: var(--color-text); } -:root[data-bs-theme="dark"] .empty-notes-message { +:root[data-bs-theme="dark"] .empty-workspace-message { color: #94a3b8; } -:root[data-bs-theme="dark"] .note-card { +:root[data-bs-theme="dark"] .content-card { border-color: var(--color-border); background-color: var(--color-surface); } -:root[data-bs-theme="dark"] .note-card:hover { +:root[data-bs-theme="dark"] .content-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } -:root[data-bs-theme="dark"] .note-list--list .note-card:hover { +:root[data-bs-theme="dark"] .workspace-list--list .content-card:hover { background-color: #2a2f3a; border-color: #7aa2f7; border-left-color: #7aa2f7; } -:root[data-bs-theme="dark"] .note-title { +:root[data-bs-theme="dark"] .content-title { color: var(--color-text); } -:root[data-bs-theme="dark"] .note-preview { +:root[data-bs-theme="dark"] .content-preview { color: #94a3b8; } -:root[data-bs-theme="dark"] .note-card.is-pinned { +:root[data-bs-theme="dark"] .content-card.is-pinned { background: #1a3a5c; border-color: #2d6a9f; } -:root[data-bs-theme="dark"] .note-card.is-featured { +:root[data-bs-theme="dark"] .content-card.is-featured { background: #3a2e0a; border-color: #7a5a0a; } +:root[data-bs-theme="dark"] .content-card.is-task-list { + background: #1f2a44; + border-color: #334b7d; +} +:root[data-bs-theme="dark"] .list-icon { + color: #bfceef; +} diff --git a/frontend/src/components/CategoryTree.vue b/frontend/src/components/CategoryTree.vue index db7f273..a84a7e4 100644 --- a/frontend/src/components/CategoryTree.vue +++ b/frontend/src/components/CategoryTree.vue @@ -2,7 +2,7 @@
- + @@ -20,6 +20,11 @@
+
+ + {{ taskList.name }} +
+
{ - - - diff --git a/frontend/src/components/CreateTaskListModal.vue b/frontend/src/components/CreateTaskListModal.vue new file mode 100644 index 0000000..175eaf9 --- /dev/null +++ b/frontend/src/components/CreateTaskListModal.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/src/components/NoteList.vue b/frontend/src/components/NoteList.vue deleted file mode 100644 index 1b86afc..0000000 --- a/frontend/src/components/NoteList.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - diff --git a/frontend/src/components/SearchResultsPage.vue b/frontend/src/components/SearchResultsPage.vue index 982c2cf..a4caa66 100644 --- a/frontend/src/components/SearchResultsPage.vue +++ b/frontend/src/components/SearchResultsPage.vue @@ -3,23 +3,23 @@

Search Results

{{ totalResults }} matches for "{{ query }}"

-

Type in the top bar and press Enter to search notes.

+

Type in the top bar and press Enter to search notes and task lists.

Start your search

-

Use a title, content keyword, or tag to find matching notes in the selected space.

+

Use a title, content keyword, or tag to find matching notes and task lists in the selected space.

-

No matching notes

+

No matching results

Try different keywords or a shorter phrase.

- +
-
- - - -