4 Commits

Author SHA1 Message Date
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
domrichardson
b9ca845b9c feat: Created task lists that work in categories
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m20s
2026-03-29 16:14:23 +01:00
domrichardson
a1dd2f2c00 feat: Updated styling into seperate css files 2026-03-29 15:28:44 +01:00
domrichardson
a081bff35b fix: Fixed task status on mobile 2026-03-29 14:53:03 +01:00
55 changed files with 3485 additions and 2683 deletions

View File

@@ -135,6 +135,7 @@ func main() {
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.UserRepo, db.UserRepo,
permissionService, permissionService,
) )
@@ -151,6 +152,7 @@ func main() {
categoryService := services.NewCategoryService( categoryService := services.NewCategoryService(
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
permissionService, permissionService,
@@ -158,6 +160,7 @@ func main() {
taskService := services.NewTaskService( taskService := services.NewTaskService(
db.TaskRepo, db.TaskRepo,
db.TaskListRepo,
db.TaskStatusRepo, db.TaskStatusRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
@@ -269,6 +272,11 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints // 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.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")

View File

@@ -430,6 +430,7 @@ type CategoryTreeDTO struct {
*CategoryDTO *CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"` Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"` Notes []*NoteListItemDTO `json:"notes"`
TaskLists []*TaskListDTO `json:"task_lists"`
} }
// NewCategoryDTO creates a DTO from a category entity // NewCategoryDTO creates a DTO from a category entity
@@ -458,7 +459,7 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
type CreateTaskRequest struct { type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"` Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"` 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"` StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"` NoteLinks []string `json:"note_links"`
@@ -468,7 +469,7 @@ type CreateTaskRequest struct {
type UpdateTaskRequest struct { type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,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"` StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"` NoteLinks []string `json:"note_links,omitempty"`
@@ -490,7 +491,7 @@ type TaskDTO struct {
SpaceID string `json:"space_id"` SpaceID string `json:"space_id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
CategoryID *string `json:"category_id,omitempty"` TaskListID string `json:"task_list_id"`
StatusID string `json:"status_id"` StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"` Depth int `json:"depth"`
@@ -538,14 +539,35 @@ type TaskStatusDTO struct {
UpdatedAt string `json:"updated_at"` 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. // NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO { func NewTaskDTO(task *entities.Task) *TaskDTO {
var categoryID *string
if task.CategoryID != nil {
id := task.CategoryID.Hex()
categoryID = &id
}
var parentTaskID *string var parentTaskID *string
if task.ParentTaskID != nil { if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex() id := task.ParentTaskID.Hex()
@@ -562,7 +584,7 @@ func NewTaskDTO(task *entities.Task) *TaskDTO {
SpaceID: task.SpaceID.Hex(), SpaceID: task.SpaceID.Hex(),
Title: task.Title, Title: task.Title,
Description: task.Description, Description: task.Description,
CategoryID: categoryID, TaskListID: task.TaskListID.Hex(),
StatusID: task.StatusID.Hex(), StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: task.Depth, 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. // NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{ return &TaskStatusDTO{

View File

@@ -15,6 +15,7 @@ import (
// CategoryService handles category operations // CategoryService handles category operations
type CategoryService struct { type CategoryService struct {
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
permissionService *PermissionService permissionService *PermissionService
@@ -23,12 +24,14 @@ type CategoryService struct {
// NewCategoryService creates a new category service // NewCategoryService creates a new category service
func NewCategoryService( func NewCategoryService(
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *CategoryService { ) *CategoryService {
return &CategoryService{ return &CategoryService{
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
permissionService: permissionService, 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 return tree, nil
} }

View File

@@ -17,6 +17,7 @@ type SpaceService struct {
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
userRepo repositories.UserRepository userRepo repositories.UserRepository
permissionService *PermissionService permissionService *PermissionService
} }
@@ -27,6 +28,7 @@ func NewSpaceService(
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *SpaceService { ) *SpaceService {
@@ -35,6 +37,7 @@ func NewSpaceService(
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
userRepo: userRepo, userRepo: userRepo,
permissionService: permissionService, 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 { if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err return err
} }
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err return err
} }

View File

@@ -17,6 +17,7 @@ import (
// TaskService handles task and task status operations. // TaskService handles task and task status operations.
type TaskService struct { type TaskService struct {
taskRepo repositories.TaskRepository taskRepo repositories.TaskRepository
taskListRepo repositories.TaskListRepository
taskStatusRepo repositories.TaskStatusRepository taskStatusRepo repositories.TaskStatusRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
@@ -27,6 +28,7 @@ type TaskService struct {
// NewTaskService creates a task service. // NewTaskService creates a task service.
func NewTaskService( func NewTaskService(
taskRepo repositories.TaskRepository, taskRepo repositories.TaskRepository,
taskListRepo repositories.TaskListRepository,
taskStatusRepo repositories.TaskStatusRepository, taskStatusRepo repositories.TaskStatusRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
@@ -35,6 +37,7 @@ func NewTaskService(
) *TaskService { ) *TaskService {
return &TaskService{ return &TaskService{
taskRepo: taskRepo, taskRepo: taskRepo,
taskListRepo: taskListRepo,
taskStatusRepo: taskStatusRepo, taskStatusRepo: taskStatusRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
@@ -121,13 +124,10 @@ func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
return result, nil return result, nil
} }
func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error { func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error {
if categoryID == nil { taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
return nil if err != nil || taskList.SpaceID != spaceID {
} return errors.New("invalid task list")
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
if err != nil || category.SpaceID != spaceID {
return errors.New("invalid category")
} }
return nil return nil
} }
@@ -209,14 +209,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err 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) 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 +218,14 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err 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) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -258,11 +258,19 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
statusID = parsedStatusID 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{ task := &entities.Task{
SpaceID: spaceID, SpaceID: spaceID,
Title: strings.TrimSpace(req.Title), Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
CategoryID: categoryID, TaskListID: taskListID,
StatusID: statusID, StatusID: statusID,
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: depth, Depth: depth,
@@ -318,7 +326,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
func (s *TaskService) ListTasks( func (s *TaskService) ListTasks(
ctx context.Context, ctx context.Context,
spaceID, userID bson.ObjectID, spaceID, userID bson.ObjectID,
categoryID, statusID, parentTaskID *string, taskListID, statusID, parentTaskID *string,
) ([]*dto.TaskDTO, error) { ) ([]*dto.TaskDTO, 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
@@ -328,12 +336,12 @@ func (s *TaskService) ListTasks(
} }
filters := map[string]any{} filters := map[string]any{}
if categoryID != nil && strings.TrimSpace(*categoryID) != "" { if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
id, err := bson.ObjectIDFromHex(*categoryID) id, err := bson.ObjectIDFromHex(*taskListID)
if err != nil { 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) != "" { if statusID != nil && strings.TrimSpace(*statusID) != "" {
id, err := bson.ObjectIDFromHex(*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) task.Description = strings.TrimSpace(*req.Description)
} }
if req.CategoryID != nil { if req.TaskListID != nil {
if strings.TrimSpace(*req.CategoryID) == "" { if strings.TrimSpace(*req.TaskListID) == "" {
task.CategoryID = nil return nil, errors.New("task list is required")
} else { }
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID) taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID)
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid category") return nil, errors.New("invalid task list")
} }
task.CategoryID = &categoryID if task.ParentTaskID != nil {
return nil, errors.New("subtasks inherit task list from parent")
} }
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil { if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err return nil, err
} }
task.TaskListID = taskListID
} }
if req.ParentTaskID != nil { if req.ParentTaskID != nil {
@@ -486,6 +496,11 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
} }
task.ParentTaskID = &parentID task.ParentTaskID = &parentID
task.Depth = depth 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,140 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n
return dto.NewTaskDTO(task), nil 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")
}
if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
return err
}
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
}
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ListStatuses(ctx context.Context, spaceID, 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

View File

@@ -14,7 +14,7 @@ type Task struct {
SpaceID bson.ObjectID `bson:"space_id"` SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"` Title string `bson:"title"`
Description string `bson:"description"` Description string `bson:"description"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"` TaskListID bson.ObjectID `bson:"task_list_id"`
StatusID bson.ObjectID `bson:"status_id"` StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"` Depth int `bson:"depth"`
@@ -35,3 +35,16 @@ type TaskStatus struct {
CreatedAt time.Time `bson:"created_at"` CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_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"`
}

View File

@@ -225,10 +225,22 @@ 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)
} }
// 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 // TaskStatusRepository defines task status operations
type TaskStatusRepository interface { type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error CreateStatus(ctx context.Context, status *entities.TaskStatus) error

View File

@@ -16,6 +16,7 @@ type Database struct {
MembershipRepo *MembershipRepository MembershipRepo *MembershipRepository
NoteRepo *NoteRepository NoteRepo *NoteRepository
CategoryRepo *CategoryRepository CategoryRepo *CategoryRepository
TaskListRepo *TaskListRepository
TaskRepo *TaskRepository TaskRepo *TaskRepository
TaskStatusRepo *TaskStatusRepository TaskStatusRepo *TaskStatusRepository
RevisionRepo *NoteRevisionRepository RevisionRepo *NoteRevisionRepository
@@ -49,6 +50,7 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
MembershipRepo: NewMembershipRepository(db), MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db), NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db), CategoryRepo: NewCategoryRepository(db),
TaskListRepo: NewTaskListRepository(db),
TaskRepo: NewTaskRepository(db), TaskRepo: NewTaskRepository(db),
TaskStatusRepo: NewTaskStatusRepository(db), TaskStatusRepo: NewTaskStatusRepository(db),
RevisionRepo: NewNoteRevisionRepository(db), RevisionRepo: NewNoteRevisionRepository(db),
@@ -87,6 +89,9 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }

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
@@ -108,13 +113,95 @@ func (r *TaskRepository) 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: "updated_at", Value: -1}}}, {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: "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: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
}) })
return err 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. // TaskStatusRepository implements task status data access.
type TaskStatusRepository struct { type TaskStatusRepository struct {
collection *mongo.Collection collection *mongo.Collection

View File

@@ -64,15 +64,15 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
return 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")) statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
categoryFilter := &categoryID taskListFilter := &taskListID
statusFilter := &statusID statusFilter := &statusID
parentFilter := &parentTaskID parentFilter := &parentTaskID
if categoryID == "" { if taskListID == "" {
categoryFilter = nil taskListFilter = nil
} }
if statusID == "" { if statusID == "" {
statusFilter = nil statusFilter = nil
@@ -81,7 +81,7 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
parentFilter = nil 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 { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -91,6 +91,94 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(tasks) 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) { func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r) userID, spaceID, err := parseIDsFromRequest(r)
if err != nil { if err != nil {

View File

@@ -1,4 +1,4 @@
<template> <template>
<div id="app" class="app-container"> <div id="app" class="app-container">
<nav v-if="!isPublicRoute && !isAuthRoute" ref="navbarRef" class="navbar navbar-dark bg-dark sticky-top"> <nav v-if="!isPublicRoute && !isAuthRoute" ref="navbarRef" class="navbar navbar-dark bg-dark sticky-top">
<div class="container-fluid app-navbar"> <div class="container-fluid app-navbar">
@@ -44,7 +44,7 @@
<!-- Search --> <!-- Search -->
<div class="search-box nav-search" v-if="!isAdminRoute"> <div class="search-box nav-search" v-if="!isAdminRoute">
<input type="text" class="form-control" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" /> <input type="text" class="form-control" placeholder="Search notes & task lists..." v-model="searchQuery" @keyup.enter="performSearch" />
</div> </div>
<!-- Theme Toggle --> <!-- Theme Toggle -->
@@ -98,6 +98,7 @@
:on-add-subcategory="openCreateSubcategoryModal" :on-add-subcategory="openCreateSubcategoryModal"
:on-edit-category="openEditCategoryModal" :on-edit-category="openEditCategoryModal"
:on-delete-category="removeCategory" :on-delete-category="removeCategory"
:on-select-task-list="selectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -128,9 +129,36 @@
</h5> </h5>
</div> </div>
<div class="col-auto d-flex align-items-center"> <div class="col-auto d-flex align-items-center">
<div class="btn-group me-2" role="group" aria-label="Workspace mode"> <div
<button type="button" class="btn action-button" :class="activeView === 'notes' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'notes'">Notes</button> v-if="(activeView === 'notes' || activeView === 'tasks') && (canCreateNotes || canCreateTasks)"
<button type="button" class="btn action-button" :class="activeView === 'tasks' ? 'btn-secondary' : 'btn-outline-secondary'" @click="activeView = 'tasks'">Tasks</button> ref="createDropdownRef"
class="dropdown me-2"
@mouseleave="showCreateMenu = false"
>
<button class="btn btn-primary dropdown-toggle action-button" type="button" aria-label="Create" title="Create" @click="toggleCreateMenu">
<i class="mdi mdi-plus-circle-outline me-1" aria-hidden="true"></i>
<span class="action-label">Create</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: showCreateMenu }">
<li v-if="activeView === 'notes' && canCreateNotes">
<button class="dropdown-item" type="button" @click="openCreateNoteModalFromMenu">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
New Note
</button>
</li>
<li v-if="activeView === 'notes' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openCreateTaskListModalFromMenu">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
New Task List
</button>
</li>
<li v-if="activeView === 'tasks' && canCreateTasks">
<button class="dropdown-item" type="button" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</li>
</ul>
</div> </div>
<div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode"> <div v-if="activeView === 'notes' && (!selectedNote || isSearchRoute)" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
<button <button
@@ -174,14 +202,6 @@
<i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-share-variant-outline me-1" aria-hidden="true"></i>
<span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span> <span class="action-label">{{ shareCopied ? "Copied" : "Share" }}</span>
</button> </button>
<button v-if="activeView === 'notes' && canCreateNotes" class="btn btn-primary action-button" aria-label="New note" title="New note" @click="showCreateNoteModal = true">
<i class="mdi mdi-note-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Note</span>
</button>
<button v-if="activeView === 'tasks' && canCreateTasks" class="btn btn-primary action-button" aria-label="New task" title="New task" @click="openTaskCreateModal">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
<span class="action-label">New Task</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -192,8 +212,8 @@
v-if="activeView === 'tasks'" v-if="activeView === 'tasks'"
:tasks="tasks" :tasks="tasks"
:statuses="taskStatuses" :statuses="taskStatuses"
:category-options="categoryOptions" :selected-task-list="selectedTaskList"
@create-task="openTaskCreateModal" :can-delete-task-list="canDeleteTasks"
@select-task="openTaskDetail" @select-task="openTaskDetail"
@filter-change="applyTaskFilters" @filter-change="applyTaskFilters"
@reorder-status="reorderTaskStatuses" @reorder-status="reorderTaskStatuses"
@@ -201,15 +221,17 @@
@rename-status="renameTaskStatus" @rename-status="renameTaskStatus"
@delete-status="deleteTaskStatus" @delete-status="deleteTaskStatus"
@update-task-status="updateTaskStatusFromBoard" @update-task-status="updateTaskStatusFromBoard"
@delete-task-list="removeTaskList"
/> />
<SearchResultsPage <SearchResultsPage
v-else-if="isSearchRoute" v-else-if="isSearchRoute"
:notes="searchResults" :items="searchItems"
:query="searchQuery" :query="searchQuery"
:current-page="searchPage" :current-page="searchPage"
:page-size="searchPageSize" :page-size="searchPageSize"
:view-mode="noteViewMode" :view-mode="noteViewMode"
@select-note="selectSearchResultNote" @select-note="selectSearchResultNote"
@select-task-list="selectTaskList"
@page-change="setSearchPage" @page-change="setSearchPage"
/> />
<NoteEditor <NoteEditor
@@ -231,13 +253,14 @@
:linked-tasks="linkedTasksForSelectedNote" :linked-tasks="linkedTasksForSelectedNote"
@open-linked-task="openLinkedTaskFromNote" @open-linked-task="openLinkedTaskFromNote"
/> />
<NoteList <WorkspaceList
v-else v-else
:notes="displayedNotes" :items="displayedItems"
:can-load-more="canLoadMoreMainNotes" :can-load-more="canLoadMoreMainNotes"
:is-loading-more="spaceStore.notesLoading" :is-loading-more="spaceStore.notesLoading"
:view-mode="noteViewMode" :view-mode="noteViewMode"
@select-note="selectNote" @select-note="selectNote"
@select-task-list="selectTaskList"
@load-more="loadMoreMainNotes" @load-more="loadMoreMainNotes"
/> />
</div> </div>
@@ -279,6 +302,13 @@
@submit="submitCategory" @submit="submitCategory"
/> />
<CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" /> <CreateNoteModal v-if="showCreateNoteModal" :category-options="categoryOptions" :default-category-id="selectedCategory?.id || null" @close="showCreateNoteModal = false" @create="createNote" />
<CreateTaskListModal
v-if="showCreateTaskListModal"
:category-options="categoryOptions"
:default-category-id="selectedCategory?.id || null"
@close="showCreateTaskListModal = false"
@create="createTaskList"
/>
<SpaceSettingsModal <SpaceSettingsModal
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings" v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
:space="currentSpace" :space="currentSpace"
@@ -290,7 +320,6 @@
v-if="showTaskModal" v-if="showTaskModal"
:task="taskModalDraft || {}" :task="taskModalDraft || {}"
:statuses="taskStatuses" :statuses="taskStatuses"
:category-options="categoryOptions"
:parent-task-options="taskParentOptions" :parent-task-options="taskParentOptions"
:subtasks="taskDetail?.subtasks || []" :subtasks="taskDetail?.subtasks || []"
@close="showTaskModal = false" @close="showTaskModal = false"
@@ -342,11 +371,12 @@ import { useSpaceStore } from "./stores/spaceStore";
import CategoryTree from "./components/CategoryTree.vue"; import CategoryTree from "./components/CategoryTree.vue";
import NoteEditor from "./components/NoteEditor.vue"; import NoteEditor from "./components/NoteEditor.vue";
import NoteViewer from "./components/NoteViewer.vue"; import NoteViewer from "./components/NoteViewer.vue";
import NoteList from "./components/NoteList.vue"; import WorkspaceList from "./components/WorkspaceList.vue";
import SearchResultsPage from "./components/SearchResultsPage.vue"; import SearchResultsPage from "./components/SearchResultsPage.vue";
import CreateSpaceModal from "./components/CreateSpaceModal.vue"; import CreateSpaceModal from "./components/CreateSpaceModal.vue";
import CreateCategoryModal from "./components/CreateCategoryModal.vue"; import CreateCategoryModal from "./components/CreateCategoryModal.vue";
import CreateNoteModal from "./components/CreateNoteModal.vue"; import CreateNoteModal from "./components/CreateNoteModal.vue";
import CreateTaskListModal from "./components/CreateTaskListModal.vue";
import SpaceSettingsModal from "./components/SpaceSettingsModal.vue"; import SpaceSettingsModal from "./components/SpaceSettingsModal.vue";
import TaskBoard from "./components/TaskBoard.vue"; import TaskBoard from "./components/TaskBoard.vue";
import TaskDetailModal from "./components/TaskDetailModal.vue"; import TaskDetailModal from "./components/TaskDetailModal.vue";
@@ -363,12 +393,14 @@ const showUserMenu = ref(false);
const showCreateSpaceModal = ref(false); const showCreateSpaceModal = ref(false);
const showCreateCategoryModal = ref(false); const showCreateCategoryModal = ref(false);
const showCreateNoteModal = ref(false); const showCreateNoteModal = ref(false);
const showCreateTaskListModal = ref(false);
const showSpaceSettingsModal = ref(false); const showSpaceSettingsModal = ref(false);
const showSidebar = ref(false); const showSidebar = ref(false);
const navbarRef = ref(null); const navbarRef = ref(null);
const navbarHeight = ref(56); const navbarHeight = ref(56);
const spaceDropdownRef = ref(null); const spaceDropdownRef = ref(null);
const userDropdownRef = ref(null); const userDropdownRef = ref(null);
const createDropdownRef = ref(null);
const searchQuery = ref(""); const searchQuery = ref("");
const selectedNote = ref(null); const selectedNote = ref(null);
const selectedCategory = ref(null); const selectedCategory = ref(null);
@@ -393,14 +425,16 @@ const unlockError = ref("");
const unlockingNote = ref(false); const unlockingNote = ref(false);
const activeView = ref("notes"); const activeView = ref("notes");
const taskFilters = ref({ const taskFilters = ref({
categoryId: null, taskListId: null,
statusId: null, statusId: null,
parentTaskId: null, parentTaskId: null,
}); });
const selectedTaskList = ref(null);
const showTaskModal = ref(false); const showTaskModal = ref(false);
const taskDetail = ref(null); const taskDetail = ref(null);
const taskModalDraft = ref(null); const taskModalDraft = ref(null);
const linkedTasksForSelectedNote = ref([]); const linkedTasksForSelectedNote = ref([]);
const showCreateMenu = ref(false);
const currentUser = computed(() => authStore.user); const currentUser = computed(() => authStore.user);
const isAdminRoute = computed(() => route.path === "/admin"); const isAdminRoute = computed(() => route.path === "/admin");
@@ -410,6 +444,19 @@ const isAuthRoute = computed(() => route.path === "/login" || route.path === "/r
const spaces = computed(() => spaceStore.spaces); const spaces = computed(() => spaceStore.spaces);
const currentSpace = computed(() => spaceStore.currentSpace); const currentSpace = computed(() => spaceStore.currentSpace);
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults)); 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 searchPageSize = 12;
const searchPage = computed(() => { const searchPage = computed(() => {
const pageValue = Number.parseInt(route.query.page || "1", 10); const pageValue = Number.parseInt(route.query.page || "1", 10);
@@ -469,13 +516,46 @@ const collectNotesFromCategory = (category, bucket = []) => {
return 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(() => { const displayedNotes = computed(() => {
if (!selectedCategory.value) { if (!selectedCategory.value) {
return sortNotesByPriority(spaceStore.notes); return sortNotesByPriority(spaceStore.notes);
} }
return sortNotesByPriority(collectNotesFromCategory(selectedCategory.value, [])); 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 tasks = computed(() => spaceStore.tasks || []);
const taskLists = computed(() => spaceStore.taskLists || []);
const taskStatuses = computed(() => spaceStore.taskStatuses || []); const taskStatuses = computed(() => spaceStore.taskStatuses || []);
const initialTaskStatusId = computed(() => { const initialTaskStatusId = computed(() => {
if (!taskStatuses.value.length) { if (!taskStatuses.value.length) {
@@ -518,6 +598,13 @@ const openSpaceHome = () => {
unlockPassword.value = ""; unlockPassword.value = "";
unlockError.value = ""; unlockError.value = "";
searchQuery.value = ""; searchQuery.value = "";
selectedTaskList.value = null;
activeView.value = "notes";
taskFilters.value = {
taskListId: null,
statusId: null,
parentTaskId: null,
};
spaceStore.clearSearchResults(); spaceStore.clearSearchResults();
if (route.path !== "/") { if (route.path !== "/") {
router.push("/"); router.push("/");
@@ -580,6 +667,27 @@ const breadcrumbItems = computed(() => {
return items; 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) { if (selectedCategory.value) {
const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value]; const categoryTrail = findCategoryPath(categoryTree.value, selectedCategory.value.id) || [selectedCategory.value];
for (let i = 0; i < categoryTrail.length; i++) { for (let i = 0; i < categoryTrail.length; i++) {
@@ -710,6 +818,14 @@ const toggleUserMenu = () => {
} }
}; };
const toggleCreateMenu = () => {
showCreateMenu.value = !showCreateMenu.value;
if (showCreateMenu.value) {
showSpaceDropdown.value = false;
showUserMenu.value = false;
}
};
const handleDocumentClick = (event) => { const handleDocumentClick = (event) => {
const target = event.target; const target = event.target;
@@ -720,6 +836,10 @@ const handleDocumentClick = (event) => {
if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) { if (showUserMenu.value && userDropdownRef.value && !userDropdownRef.value.contains(target)) {
showUserMenu.value = false; showUserMenu.value = false;
} }
if (showCreateMenu.value && createDropdownRef.value && !createDropdownRef.value.contains(target)) {
showCreateMenu.value = false;
}
}; };
const handleEscapeKey = (event) => { const handleEscapeKey = (event) => {
@@ -728,6 +848,7 @@ const handleEscapeKey = (event) => {
} }
showSpaceDropdown.value = false; showSpaceDropdown.value = false;
showUserMenu.value = false; showUserMenu.value = false;
showCreateMenu.value = false;
showSidebar.value = false; showSidebar.value = false;
}; };
@@ -737,7 +858,9 @@ const selectSpace = async (space) => {
localStorage.setItem("currentSpaceId", space.id); localStorage.setItem("currentSpaceId", space.id);
selectedNote.value = null; selectedNote.value = null;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
linkedTasksForSelectedNote.value = []; linkedTasksForSelectedNote.value = [];
await applyTaskFilters(taskFilters.value); await applyTaskFilters(taskFilters.value);
}; };
@@ -782,8 +905,13 @@ const selectNote = async (note) => {
showUnlockModal.value = true; showUnlockModal.value = true;
selectedNote.value = null; selectedNote.value = null;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
return; return;
} }
@@ -795,8 +923,13 @@ const selectNote = async (note) => {
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`); const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/${note.id}`);
selectedNote.value = response.data; selectedNote.value = response.data;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
if (route.path === "/search") {
router.push("/");
}
} catch { } catch {
alert("Unable to load note content."); alert("Unable to load note content.");
} }
@@ -828,7 +961,9 @@ const unlockProtectedNote = async () => {
}); });
selectedNote.value = response.data; selectedNote.value = response.data;
selectedCategory.value = null; selectedCategory.value = null;
selectedTaskList.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; showSidebar.value = false;
closeUnlockModal(); closeUnlockModal();
} catch (error) { } catch (error) {
@@ -840,11 +975,31 @@ const unlockProtectedNote = async () => {
const selectCategory = (category) => { const selectCategory = (category) => {
selectedCategory.value = category; selectedCategory.value = category;
selectedTaskList.value = null;
selectedNote.value = null; selectedNote.value = null;
isEditingNote.value = false; isEditingNote.value = false;
activeView.value = "notes";
showSidebar.value = false; 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 performSearch = async () => {
const q = searchQuery.value.trim(); const q = searchQuery.value.trim();
if (!q) { if (!q) {
@@ -903,22 +1058,30 @@ const loadMoreMainNotes = async () => {
}; };
const applyTaskFilters = async (filters) => { const applyTaskFilters = async (filters) => {
taskFilters.value = filters; taskFilters.value = {
...taskFilters.value,
...filters,
taskListId: selectedTaskList.value?.id || taskFilters.value.taskListId || null,
};
if (!currentSpace.value?.id) { if (!currentSpace.value?.id) {
return; return;
} }
await spaceStore.fetchTasks(currentSpace.value.id, filters); await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
}; };
const openTaskCreateModal = () => { const openTaskCreateModal = () => {
if (!canCreateTasks.value) { if (!canCreateTasks.value) {
return; return;
} }
if (!selectedTaskList.value?.id) {
alert("Select a task list first.");
return;
}
taskDetail.value = null; taskDetail.value = null;
taskModalDraft.value = { taskModalDraft.value = {
title: "", title: "",
description: "", description: "",
category_id: selectedCategory.value?.id || null, task_list_id: selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value, status_id: initialTaskStatusId.value,
parent_task_id: null, parent_task_id: null,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1010,7 +1173,7 @@ const createSubtask = (parentTask) => {
taskModalDraft.value = { taskModalDraft.value = {
title: "", title: "",
description: "", description: "",
category_id: parentTask.category_id || null, task_list_id: parentTask.task_list_id || selectedTaskList.value?.id || null,
status_id: initialTaskStatusId.value, status_id: initialTaskStatusId.value,
parent_task_id: parentTask.id, parent_task_id: parentTask.id,
note_links: selectedNote.value?.id ? [selectedNote.value.id] : [], note_links: selectedNote.value?.id ? [selectedNote.value.id] : [],
@@ -1102,10 +1265,68 @@ const updateTaskStatusFromBoard = async ({ taskId, currentStatusId, targetStatus
}; };
const openLinkedTaskFromNote = async (task) => { 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"; activeView.value = "tasks";
await openTaskDetail(task); 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 removeTaskList = async (taskList) => {
if (!currentSpace.value?.id || !taskList?.id || !canDeleteTasks.value) {
return;
}
if (!confirm(`Delete task list "${taskList.name}" and all associated tasks?`)) {
return;
}
try {
await spaceStore.deleteTaskList(currentSpace.value.id, taskList.id);
if (selectedTaskList.value?.id === taskList.id) {
selectedTaskList.value = null;
taskDetail.value = null;
taskModalDraft.value = null;
showTaskModal.value = false;
taskFilters.value = {
taskListId: null,
statusId: null,
parentTaskId: null,
};
await spaceStore.fetchTasks(currentSpace.value.id, taskFilters.value);
activeView.value = "notes";
}
} catch (error) {
alert(error?.response?.data || "Unable to delete task list.");
}
};
const createSpace = async (spaceData) => { const createSpace = async (spaceData) => {
showCreateSpaceModal.value = false; showCreateSpaceModal.value = false;
await spaceStore.createSpace(spaceData); await spaceStore.createSpace(spaceData);
@@ -1258,257 +1479,4 @@ const logout = () => {
}; };
</script> </script>
<style scoped> <style scoped src="./assets/styles/scoped/App.css"></style>
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow-x: hidden;
overflow-y: visible;
}
.navbar {
z-index: 1100;
overflow: visible;
}
.app-navbar {
align-items: center;
}
.navbar-left {
min-width: 0;
}
.navbar-controls {
min-width: 0;
}
.app-brand {
white-space: nowrap;
}
.app-main {
flex: 1;
overflow: hidden;
}
.sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.sidebar-header {
border-bottom: 1px solid #dee2e6;
}
.main-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.content {
flex: 1;
overflow-y: auto;
}
.breadcrumb-title {
font-size: 1rem;
color: #495057;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumb-link {
border: 0;
background: transparent;
padding: 0;
color: #0d6efd;
text-decoration: none;
cursor: pointer;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.breadcrumb-separator {
color: #6c757d;
}
.search-box {
width: 300px;
}
.dropdown {
position: relative;
}
.dropdown-menu {
z-index: 1200;
max-width: min(92vw, 320px);
}
.dropdown-menu-end {
right: 0;
left: auto;
}
.dropdown-menu.show {
display: block;
}
.admin-route-view {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
}
@media (max-width: 768px) {
.app-navbar {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"left user"
"space space"
"search search";
row-gap: 0.5rem;
column-gap: 0.5rem;
align-items: center;
}
.navbar-left {
grid-area: left;
}
.navbar-controls {
width: 100%;
}
.nav-user-menu {
grid-area: user;
justify-self: end;
}
.nav-space-selector {
grid-area: space;
}
.nav-space-selector > .btn {
width: 100%;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-search {
grid-area: search;
width: 100%;
}
.nav-search .form-control {
width: 100%;
}
.search-box {
width: 100%;
}
.app-brand {
font-size: 1.5rem;
}
.nav-menu-toggle {
padding: 0.35rem 0.55rem;
}
.sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.sidebar {
position: fixed;
left: 0;
bottom: 0;
width: 280px;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
.toolbar {
position: relative;
z-index: 0;
}
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-label {
display: none;
}
.action-button .mdi {
margin-right: 0 !important;
}
.col-auto .action-button {
min-width: 2.75rem;
}
.app-main {
flex-direction: column;
}
.main-content {
width: 100%;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .sidebar-header {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .breadcrumb-title {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .breadcrumb-link {
color: #7aa2f7;
}
:root[data-bs-theme="dark"] .breadcrumb-separator {
color: #4a5568;
}
</style>

View File

@@ -1,42 +1,70 @@
:root { :root {
--primary-color: #667eea; --color-primary: #667eea;
--secondary-color: #764ba2; --color-primary-strong: #4f46a5;
--text-color: #333; --color-text: #333333;
--bg-color: #f8f9fa; --color-text-muted: #6c757d;
--border-color: #dee2e6; --color-bg: #f8f9fa;
--color-surface: #ffffff;
--color-surface-muted: #f1f3f5;
--color-border: #dee2e6;
--color-info: #748ffc;
--color-code-bg: #353943;
--color-code-text: #f9fafb;
--color-scroll-track: #f1f1f1;
--color-scroll-thumb: #888888;
--color-scroll-thumb-hover: #555555;
--primary-color: var(--color-primary);
--secondary-color: var(--color-primary-strong);
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
} }
[data-bs-theme="dark"] { [data-bs-theme="dark"] {
--text-color: #e2e8f0; --color-text: #e2e8f0;
--bg-color: #1a1d23; --color-text-muted: #94a3b8;
--border-color: #3a3f4b; --color-bg: #1a1d23;
--color-surface: #21252e;
--color-surface-muted: #2d3748;
--color-border: #3a3f4b;
--color-info: #7aa2f7;
--color-code-bg: #2d3748;
--color-code-text: #e2e8f0;
--color-scroll-track: #2d3748;
--color-scroll-thumb: #4a5568;
--color-scroll-thumb-hover: #718096;
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
} }
[data-bs-theme="dark"] body { [data-bs-theme="dark"] body {
background-color: #1a1d23; background-color: var(--color-bg);
color: #e2e8f0; color: var(--color-text);
} }
[data-bs-theme="dark"] .sidebar { [data-bs-theme="dark"] .sidebar {
background-color: #21252e !important; background-color: var(--color-surface) !important;
border-color: #3a3f4b !important; border-color: var(--color-border) !important;
} }
[data-bs-theme="dark"] .toolbar { [data-bs-theme="dark"] .toolbar {
background-color: #21252e; background-color: var(--color-surface);
border-color: #3a3f4b !important; border-color: var(--color-border) !important;
} }
[data-bs-theme="dark"] .main-content { [data-bs-theme="dark"] .main-content {
background-color: #1a1d23; background-color: var(--color-bg);
} }
[data-bs-theme="dark"] .markdown-body table { [data-bs-theme="dark"] .markdown-body table {
background: #21252e; background: var(--color-surface);
} }
[data-bs-theme="dark"] .markdown-body th { [data-bs-theme="dark"] .markdown-body th {
background: #2a2f3a; background: var(--color-surface-muted);
} }
[data-bs-theme="dark"] .markdown-body tr:nth-child(even) td { [data-bs-theme="dark"] .markdown-body tr:nth-child(even) td {
@@ -49,8 +77,8 @@
} }
[data-bs-theme="dark"] .markdown-body :not(pre) > code { [data-bs-theme="dark"] .markdown-body :not(pre) > code {
background: #2d3748; background: var(--color-surface-muted);
color: #e2e8f0; color: var(--color-text);
} }
[data-bs-theme="dark"] .markdown-body pre code { [data-bs-theme="dark"] .markdown-body pre code {
@@ -59,20 +87,20 @@
} }
[data-bs-theme="dark"] .markdown-body pre { [data-bs-theme="dark"] .markdown-body pre {
background: #2d3748; background: var(--color-code-bg);
color: #e2e8f0; color: var(--color-code-text);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-track { [data-bs-theme="dark"] ::-webkit-scrollbar-track {
background: #2d3748; background: var(--color-scroll-track);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb { [data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
background: #4a5568; background: var(--color-scroll-thumb);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover { [data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: #718096; background: var(--color-scroll-thumb-hover);
} }
* { * {
@@ -99,7 +127,7 @@ body,
margin: 1rem 0; margin: 1rem 0;
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
background: #fff; background: var(--color-surface);
} }
.markdown-body th, .markdown-body th,
@@ -126,7 +154,7 @@ body,
.markdown-body blockquote { .markdown-body blockquote {
margin: 1rem 0; margin: 1rem 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-left: 4px solid #748ffc; border-left: 4px solid var(--color-info);
background: #f8f9ff; background: #f8f9ff;
color: #334155; color: #334155;
} }
@@ -139,8 +167,8 @@ body,
margin: 1rem 0; margin: 1rem 0;
padding: 1rem; padding: 1rem;
border-radius: 0.75rem; border-radius: 0.75rem;
background: #353943; background: var(--color-code-bg);
color: #f9fafb; color: var(--color-code-text);
overflow-x: auto; overflow-x: auto;
} }
@@ -155,7 +183,7 @@ body,
font-size: 0.95em; font-size: 0.95em;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
border-radius: 0.35rem; border-radius: 0.35rem;
background: #f1f3f5; background: var(--color-surface-muted);
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -165,14 +193,14 @@ body,
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--color-scroll-track);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: var(--color-scroll-thumb);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--color-scroll-thumb-hover);
} }

View File

@@ -0,0 +1,254 @@
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow-x: hidden;
overflow-y: visible;
}
.navbar {
z-index: 1100;
overflow: visible;
}
.app-navbar {
align-items: center;
}
.navbar-left {
min-width: 0;
}
.navbar-controls {
min-width: 0;
}
.app-brand {
white-space: nowrap;
}
.app-main {
flex: 1;
overflow: hidden;
}
.sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.sidebar-header {
border-bottom: 1px solid var(--color-border);
}
.main-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.content {
flex: 1;
overflow-y: auto;
}
.breadcrumb-title {
font-size: 1rem;
color: #495057;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumb-link {
border: 0;
background: transparent;
padding: 0;
color: var(--color-primary);
text-decoration: none;
cursor: pointer;
}
.breadcrumb-link:hover {
text-decoration: underline;
}
.breadcrumb-separator {
color: var(--color-text-muted);
}
.search-box {
width: 300px;
}
.dropdown {
position: relative;
}
.dropdown-menu {
z-index: 1200;
max-width: min(92vw, 320px);
}
.dropdown-menu-end {
right: 0;
left: auto;
}
.dropdown-menu.show {
display: block;
}
.admin-route-view {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
}
@media (max-width: 768px) {
.app-navbar {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"left user"
"space space"
"search search";
row-gap: 0.5rem;
column-gap: 0.5rem;
align-items: center;
}
.navbar-left {
grid-area: left;
}
.navbar-controls {
width: 100%;
}
.nav-user-menu {
grid-area: user;
justify-self: end;
}
.nav-space-selector {
grid-area: space;
}
.nav-space-selector > .btn {
width: 100%;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-search {
grid-area: search;
width: 100%;
}
.nav-search .form-control {
width: 100%;
}
.search-box {
width: 100%;
}
.app-brand {
font-size: 1.5rem;
}
.nav-menu-toggle {
padding: 0.35rem 0.55rem;
}
.sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.sidebar {
position: fixed;
left: 0;
bottom: 0;
width: 280px;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
.toolbar {
position: relative;
z-index: 0;
}
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-label {
display: none;
}
.action-button .mdi {
margin-right: 0 !important;
}
.col-auto .action-button {
min-width: 2.75rem;
}
.app-main {
flex-direction: column;
}
.main-content {
width: 100%;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .sidebar-header {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .breadcrumb-title {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .breadcrumb-link {
color: #7aa2f7;
}
:root[data-bs-theme="dark"] .breadcrumb-separator {
color: #4a5568;
}

View File

@@ -0,0 +1,5 @@
@import "./AdminModal.shared.css";
.permissions-textarea {
font-family: "Courier New", monospace;
}

View File

@@ -0,0 +1,24 @@
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}

View File

@@ -0,0 +1 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1,2 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1 @@
@import "./AdminModal.shared.css";

View File

@@ -0,0 +1,213 @@
.category-item {
margin-bottom: 0.25rem;
}
.category-header {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 4px;
}
.category-header:hover {
background-color: #e9ecef;
}
.expand-icon {
width: 20px;
text-align: center;
font-size: 0.875rem;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
position: relative;
}
.menu-button {
border: 0;
background: transparent;
width: 28px;
height: 28px;
border-radius: 6px;
color: #495057;
}
.menu-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.menu-dropdown {
position: absolute;
top: 30px;
right: 0;
min-width: 150px;
padding: 0.35rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
z-index: 5;
}
.menu-item {
display: block;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.45rem 0.6rem;
border-radius: 0.35rem;
}
.menu-item:hover {
background-color: #f1f3f5;
}
.menu-item.danger {
color: #c92a2a;
}
.category-content {
padding-left: 1rem;
}
.note-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
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;
}
.note-item span {
flex-grow: 1;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.9em;
flex-shrink: 0;
}
.note-item.is-pinned {
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.note-item.is-pinned:hover {
background: #c5e9ff;
}
.note-item.is-featured {
background: #fff9db;
border: 1px solid #ffd8a8;
}
.note-item.is-featured:hover {
background: #fff3c5;
}
.subcategories {
margin-top: 0.25rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .category-header:hover {
background-color: var(--color-surface-muted);
}
:root[data-bs-theme="dark"] .menu-button {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .menu-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-bs-theme="dark"] .menu-dropdown {
background: var(--color-surface-muted);
border-color: #4a5568;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .menu-item {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .menu-item:hover {
background-color: #374151;
}
:root[data-bs-theme="dark"] .note-item:hover {
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;
}
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
background: #1e4470;
}
:root[data-bs-theme="dark"] .note-item.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
background: #453710;
}

View File

@@ -0,0 +1,12 @@
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}

View File

@@ -0,0 +1,64 @@
.file-explorer {
background: var(--color-surface);
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: var(--color-text);
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed var(--color-primary);
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .file-explorer {
background: var(--color-surface);
}
:root[data-bs-theme="dark"] .file-explorer-header {
background: var(--color-surface);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .file-item {
border-bottom-color: var(--color-border);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .file-item:hover {
background-color: var(--color-surface-muted);
}

View File

@@ -0,0 +1,92 @@
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem;
}
.modal-panel {
width: min(920px, 100%);
max-height: min(92vh, 980px);
background: var(--color-surface);
border: 1px solid #dbe3ee;
border-radius: 14px;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.provider-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(180deg, #f8fafc 0%, var(--color-surface) 100%);
}
.provider-modal-title {
margin: 0;
font-weight: 600;
}
.provider-modal-close {
flex-shrink: 0;
}
.provider-modal-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-section {
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
padding: 0.9rem;
}
.provider-list {
max-height: 220px;
overflow: auto;
}
@media (max-width: 768px) {
.modal-backdrop-custom {
padding: 0.75rem;
}
.provider-modal-header,
.provider-modal-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .modal-panel {
background: var(--color-surface);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .provider-modal-header {
background: linear-gradient(180deg, #2a2f3a 0%, var(--color-surface) 100%);
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .provider-section {
background: #2a2f3a;
border-color: var(--color-border);
}

View File

@@ -0,0 +1,300 @@
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.save-status {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.save-status.dirty {
color: #b26a00;
}
.save-status.saving {
color: var(--color-primary);
}
.save-status.saved {
color: #2b8a3e;
}
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 600px;
resize: vertical;
}
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg);
margin: 0;
cursor: pointer;
}
.flag-check-input {
margin: 0;
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.flag-check-label {
line-height: 1;
user-select: none;
}
.preview-pane {
background-color: var(--color-bg);
overflow-y: auto;
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 {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: var(--color-surface);
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: var(--color-bg);
min-height: 34px;
}
.task-picker-search {
background: var(--color-surface);
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: var(--color-text);
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .flag-check {
background: var(--color-surface-muted);
border-color: #4a5568;
}
:root[data-bs-theme="dark"] .preview-pane {
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 {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: var(--color-border) !important;
background: var(--color-surface);
}
:root[data-bs-theme="dark"] .task-picker-header {
background: var(--color-surface);
border-bottom-color: var(--color-border) !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: var(--color-surface);
border-bottom-color: var(--color-border) !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: var(--color-border);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .task-picker-item {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: var(--color-surface-muted);
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
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%);
}

View File

@@ -0,0 +1,168 @@
.note-viewer {
max-width: 900px;
}
.note-meta {
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.meta-grid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #eef2ff;
color: #364fc7;
font-size: 0.8rem;
}
.state-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pinned-chip {
color: #005f8f;
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.featured-chip {
color: #8d7619;
border: 1px solid #ffd8a8;
background: #fff9db;
}
.public-chip {
color: #0c5460;
border: 1px solid #a5d8ff;
background: #e7f5ff;
}
.private-chip {
color: #5f3dc4;
border: 1px solid #d0bfff;
background: #f3f0ff;
}
.protected-chip {
color: #7f5539;
border: 1px solid #e0c3a6;
background: #fff4e6;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-body :deep(p),
.markdown-body :deep(li) {
line-height: 1.7;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .note-meta {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .tag-chip {
background: #1e2d5f;
color: #93b4ff;
}
:root[data-bs-theme="dark"] .pinned-chip {
color: #7dd3fc;
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .featured-chip {
color: #fbbf24;
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .public-chip {
color: #67e8f9;
background: #0c2a3a;
border-color: #1d6a7a;
}
:root[data-bs-theme="dark"] .private-chip {
color: #c4b5fd;
background: #2d1f5e;
border-color: #5b3f9a;
}
:root[data-bs-theme="dark"] .protected-chip {
color: #fdba74;
background: #3a1f0a;
border-color: #7a4f1a;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
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%);
}

View File

@@ -0,0 +1,100 @@
.search-results-page {
max-width: 1200px;
margin: 0 auto;
}
.search-results-header {
margin-bottom: 1.5rem;
}
.search-results-header h2 {
margin: 0;
font-size: 1.5rem;
color: #223149;
}
.search-meta {
margin: 0.35rem 0 0;
color: #5b6f8b;
}
.pagination-bar {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
}
.page-indicator {
color: #4f637d;
font-weight: 600;
}
.empty-state {
min-height: 48vh;
border: 1px dashed #cfdae9;
border-radius: 14px;
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 4.2rem;
color: #60789a;
margin-bottom: 0.6rem;
}
.empty-state h3 {
margin: 0;
color: #223149;
}
.empty-state p {
margin: 0.6rem 0 0;
color: #5b6f8b;
max-width: 500px;
}
@media (max-width: 768px) {
.pagination-bar {
flex-direction: column;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .search-results-header h2 {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .search-meta {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .page-indicator {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state {
border-color: var(--color-border);
background: radial-gradient(circle at 20% 20%, #1a2035 0%, #1e2430 70%);
}
:root[data-bs-theme="dark"] .empty-state h3 {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .empty-state p {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state-icon {
color: #4a6fa5;
}

View File

@@ -0,0 +1,401 @@
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: var(--color-surface);
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: var(--color-surface);
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: var(--color-surface);
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid var(--color-surface);
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: var(--color-text-muted);
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
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) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 0.5rem;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: var(--color-border);
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}

View File

@@ -0,0 +1,61 @@
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: var(--color-border);
color: #c8d3e6;
}

View File

@@ -0,0 +1,220 @@
.workspace-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.empty-workspace-state {
grid-column: 1 / -1;
min-height: 48vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
border: 1px dashed #cfd6e4;
border-radius: 14px;
background: linear-gradient(180deg, #f8f9fc 0%, #eef3fb 100%);
padding: 2rem 1.5rem;
}
.empty-workspace-icon {
font-size: 5.25rem;
line-height: 1;
color: #60789a;
margin-bottom: 0.85rem;
}
.empty-workspace-title {
margin: 0;
color: #23364f;
font-size: 1.8rem;
font-weight: 700;
}
.empty-workspace-message {
margin: 0.75rem 0 0;
max-width: 460px;
color: #4f637d;
font-size: 1.05rem;
}
.content-card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
background: var(--color-surface);
}
.content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.content-title {
margin-bottom: 0.5rem;
color: var(--color-text);
display: flex;
align-items: center;
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;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.95em;
flex-shrink: 0;
}
.list-icon {
color: #5568a8;
font-size: 1rem;
flex-shrink: 0;
}
.content-card.is-pinned {
background: #dbf5ff;
border-color: #a8d1ff;
}
.content-card.is-featured {
border-color: #ffd8a8;
background: #fff9db;
}
.content-card.is-task-list {
border-color: #d9e3ff;
background: #f7f9ff;
}
.list-footer {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.workspace-list--list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.workspace-list--list .content-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-radius: 6px;
}
.workspace-list--list .content-card:hover {
transform: none;
box-shadow: none;
background-color: #eef2ff;
border-color: var(--color-primary);
border-left: 3px solid var(--color-primary);
}
.workspace-list--list .content-title {
flex: 0 0 220px;
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-list--list .content-preview {
flex: 1;
margin-bottom: 0;
}
.workspace-list--list .content-card > small {
flex: 0 0 auto;
white-space: nowrap;
}
@media (max-width: 768px) {
.empty-workspace-state {
min-height: 40vh;
padding: 1.5rem 1rem;
}
.empty-workspace-icon {
font-size: 4.3rem;
}
.empty-workspace-title {
font-size: 1.45rem;
}
}
: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-workspace-title {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .empty-workspace-message {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .content-card {
border-color: var(--color-border);
background-color: var(--color-surface);
}
:root[data-bs-theme="dark"] .content-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
: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"] .content-title {
color: var(--color-text);
}
:root[data-bs-theme="dark"] .content-preview {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .content-card.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
: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;
}

View File

@@ -0,0 +1,234 @@
.admin-page {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-topbar {
flex-wrap: wrap;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
.admin-shell {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
overflow: hidden;
}
.admin-sidebar {
width: 280px;
flex-shrink: 0;
background: var(--color-bg);
border-right: 1px solid var(--color-border);
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-nav .nav-link {
border-radius: 0.6rem;
color: #495057;
font-weight: 500;
}
.admin-nav .nav-link:hover {
background: #eef2f7;
color: #212529;
}
.admin-nav .nav-link.active {
background: #212529;
color: var(--color-surface);
}
.admin-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 1rem;
}
.admin-section {
border-radius: 12px;
}
.users-list .list-group-item {
padding: 1rem;
}
.user-row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.user-row-main {
flex: 1;
min-width: 0;
}
.user-row-actions {
flex-shrink: 0;
}
.user-actions-stack {
flex-wrap: wrap;
justify-content: flex-end;
}
.user-name-line {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.user-name {
font-size: 1.1rem;
}
.user-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1.25rem;
}
.user-meta-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 0.1rem;
}
.user-meta-value {
color: #495057;
overflow-wrap: anywhere;
}
.user-meta-item-groups {
grid-column: span 1;
}
@media (max-width: 991.98px) {
.user-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-meta-item-groups {
grid-column: 1 / -1;
}
}
@media (max-width: 767.98px) {
.admin-shell {
display: block;
min-height: auto;
}
.admin-topbar {
padding: 0.75rem;
}
.admin-content {
padding: 0.75rem;
}
.admin-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1400;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(82vw, 320px);
z-index: 1410;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-right: 1px solid var(--color-border);
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-sidebar.open {
transform: translateX(0);
}
.user-row {
flex-direction: column;
align-items: stretch;
}
.user-row-actions {
width: 100%;
}
.user-row-actions .btn {
width: 100%;
}
.user-actions-stack {
flex-direction: column;
}
.user-meta-grid {
grid-template-columns: 1fr;
gap: 0.65rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .admin-topbar {
border-bottom-color: var(--color-border);
}
:root[data-bs-theme="dark"] .admin-sidebar {
background: var(--color-surface);
border-right-color: var(--color-border);
}
:root[data-bs-theme="dark"] .admin-nav .nav-link {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
background: var(--color-text);
color: #1a1d23;
}
:root[data-bs-theme="dark"] .user-meta-value {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-section {
background-color: var(--color-surface);
}

View File

@@ -0,0 +1,116 @@
.login-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 460px;
}
.login-card {
background: var(--color-surface);
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-provider-btn {
min-height: 48px;
border-radius: 0.65rem;
}
.oauth-divider {
display: flex;
align-items: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--color-border);
}
.oauth-divider span {
padding: 0 0.75rem;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.login-page {
padding: 0.85rem;
}
.login-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}

View File

@@ -0,0 +1,71 @@
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.public-body {
flex: 1;
overflow: hidden;
position: relative;
}
.public-sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
.note-item {
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid transparent;
transition: background 0.15s;
}
.note-item:hover {
background: #e9ecef;
}
.note-item.active {
background: #dbe4ff;
border-color: #748ffc;
color: #364fc7;
}
.note-item.is-featured {
background: var(--color-surface)4e6;
border-color: #ffd8a8;
}
.note-item.is-featured:hover {
background: #ffe8cc;
}
@media (max-width: 768px) {
.public-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.public-sidebar {
position: fixed;
left: 0;
bottom: 0;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
.public-sidebar.open {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,93 @@
.register-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 560px;
}
.register-card {
background: var(--color-surface);
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.register-page {
padding: 0.85rem;
}
.register-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}

View File

@@ -78,7 +78,7 @@ const hydrateForm = () => {
form.value = { form.value = {
name: props.group?.name || "", name: props.group?.name || "",
description: props.group?.description || "", description: props.group?.description || "",
permissionsText: (props.group?.permissions || []).join("\n"), permissionsText: (props.group?.permissions || []).join("/n"),
}; };
}; };
@@ -93,33 +93,7 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminGroupModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
.permissions-textarea {
font-family: "Courier New", monospace;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>

View File

@@ -178,28 +178,7 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.5rem;
}
}
</style>

View File

@@ -253,29 +253,7 @@ const deleteSpace = async () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>

View File

@@ -83,29 +83,7 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminUserModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="category-tree"> <div class="category-tree">
<div v-for="category in categories" :key="category.id" class="category-item"> <div v-for="category in categories" :key="category.id" class="category-item">
<div class="category-header" @click="handleCategoryClick(category)"> <div class="category-header" @click="handleCategoryClick(category)">
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length"> <span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i> <i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
</span> </span>
<span v-else class="expand-icon"> </span> <span v-else class="expand-icon"> </span>
@@ -20,6 +20,11 @@
</div> </div>
<div v-if="expandedCategories[category.id]" class="category-content"> <div v-if="expandedCategories[category.id]" class="category-content">
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
<span>{{ taskList.name }}</span>
</div>
<div <div
v-for="note in sortedNotes(category.notes)" v-for="note in sortedNotes(category.notes)"
:key="note.id" :key="note.id"
@@ -41,6 +46,7 @@
:on-add-subcategory="onAddSubcategory" :on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory" :on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory" :on-delete-category="onDeleteCategory"
:on-select-task-list="onSelectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -80,6 +86,10 @@ const props = defineProps({
type: Function, type: Function,
required: true, required: true,
}, },
onSelectTaskList: {
type: Function,
required: true,
},
canCreateCategories: { canCreateCategories: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -141,187 +151,4 @@ const handleDeleteCategory = (category) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>
.category-item {
margin-bottom: 0.25rem;
}
.category-header {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 4px;
}
.category-header:hover {
background-color: #e9ecef;
}
.expand-icon {
width: 20px;
text-align: center;
font-size: 0.875rem;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
position: relative;
}
.menu-button {
border: 0;
background: transparent;
width: 28px;
height: 28px;
border-radius: 6px;
color: #495057;
}
.menu-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.menu-dropdown {
position: absolute;
top: 30px;
right: 0;
min-width: 150px;
padding: 0.35rem;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
z-index: 5;
}
.menu-item {
display: block;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.45rem 0.6rem;
border-radius: 0.35rem;
}
.menu-item:hover {
background-color: #f1f3f5;
}
.menu-item.danger {
color: #c92a2a;
}
.category-content {
padding-left: 1rem;
}
.note-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
margin-bottom: 0.25rem;
}
.note-item:hover {
background-color: #e9ecef;
}
.note-item span {
flex-grow: 1;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.9em;
flex-shrink: 0;
}
.note-item.is-pinned {
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.note-item.is-pinned:hover {
background: #c5e9ff;
}
.note-item.is-featured {
background: #fff9db;
border: 1px solid #ffd8a8;
}
.note-item.is-featured:hover {
background: #fff6c5;
}
.subcategories {
margin-top: 0.25rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .category-header:hover {
background-color: #2d3748;
}
:root[data-bs-theme="dark"] .menu-button {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .menu-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-bs-theme="dark"] .menu-dropdown {
background: #2d3748;
border-color: #4a5568;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .menu-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .menu-item:hover {
background-color: #374151;
}
:root[data-bs-theme="dark"] .note-item:hover {
background-color: #2d3748;
}
:root[data-bs-theme="dark"] .note-item.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
background: #1e4470;
}
:root[data-bs-theme="dark"] .note-item.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
background: #453710;
}
</style>

View File

@@ -90,4 +90,5 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped></style>

View File

@@ -141,16 +141,7 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CreateNoteModal.css"></style>
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
</style>

View File

@@ -49,4 +49,5 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped></style>

View File

@@ -0,0 +1,84 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Task List</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleCreate">
<div class="mb-3">
<label for="taskListName" class="form-label">Task List Name</label>
<input id="taskListName" v-model="form.name" type="text" class="form-control" maxlength="120" required />
</div>
<div class="mb-3">
<label for="taskListCategory" class="form-label">Category</label>
<select id="taskListCategory" v-model="form.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
const props = defineProps({
categoryOptions: {
type: Array,
default: () => [],
},
defaultCategoryId: {
type: String,
default: null,
},
});
const emit = defineEmits(["close", "create"]);
const form = ref({
name: "",
category_id: null,
});
watch(
() => props.defaultCategoryId,
(defaultCategoryId) => {
form.value.category_id = defaultCategoryId || null;
},
{ immediate: true },
);
const closeModal = () => {
emit("close");
};
const handleCreate = () => {
const name = form.value.name.trim();
if (!name) {
return;
}
emit("create", {
name,
category_id: form.value.category_id || null,
});
form.value = {
name: "",
category_id: props.defaultCategoryId || null,
};
};
</script>

View File

@@ -217,7 +217,7 @@ const createFolder = async () => {
const deleteItem = async (obj) => { const deleteItem = async (obj) => {
const label = displayName(obj); const label = displayName(obj);
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return; if (!confirm(`Delete "${label}"?${obj.is_folder ? "./nThis will delete all files inside the folder." : ""}`)) return;
error.value = ""; error.value = "";
try { try {
if (obj.is_folder) { if (obj.is_folder) {
@@ -284,67 +284,4 @@ watch(showNewFolderInput, async (v) => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/FileExplorer.css"></style>
.file-explorer {
background: #fff;
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: #333;
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .file-explorer {
background: #21252e;
}
:root[data-bs-theme="dark"] .file-explorer-header {
background: #21252e;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .file-item {
border-bottom-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .file-item:hover {
background-color: #2d3748;
}
</style>

View File

@@ -187,95 +187,7 @@ const createProvider = async () => {
onMounted(loadProviders); onMounted(loadProviders);
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/ManageAuthProvidersModal.css"></style>
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem;
}
.modal-panel {
width: min(920px, 100%);
max-height: min(92vh, 980px);
background: #fff;
border: 1px solid #dbe3ee;
border-radius: 14px;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.provider-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.provider-modal-title {
margin: 0;
font-weight: 600;
}
.provider-modal-close {
flex-shrink: 0;
}
.provider-modal-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-section {
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
padding: 0.9rem;
}
.provider-list {
max-height: 220px;
overflow: auto;
}
@media (max-width: 768px) {
.modal-backdrop-custom {
padding: 0.75rem;
}
.provider-modal-header,
.provider-modal-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .modal-panel {
background: #21252e;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .provider-modal-header {
background: linear-gradient(180deg, #2a2f3a 0%, #21252e 100%);
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .provider-section {
background: #2a2f3a;
border-color: #3a3f4b;
}
</style>

View File

@@ -571,303 +571,4 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteEditor.css"></style>
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
}
.save-status {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: #6c757d;
}
.save-status.dirty {
color: #b26a00;
}
.save-status.saving {
color: #0d6efd;
}
.save-status.saved {
color: #2b8a3e;
}
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 600px;
resize: vertical;
}
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 999px;
background: #f8f9fa;
margin: 0;
cursor: pointer;
}
.flag-check-input {
margin: 0;
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.flag-check-label {
line-height: 1;
user-select: none;
}
.preview-pane {
background-color: #f8f9fa;
overflow-y: auto;
max-height: 600px;
}
.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;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
.task-mention-panel {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: #fff;
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: #f8f9fa;
min-height: 34px;
}
.task-picker-search {
background: #fff;
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: #333;
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .flag-check {
background: #2d3748;
border-color: #4a5568;
}
:root[data-bs-theme="dark"] .preview-pane {
background-color: #21252e;
}
: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 {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: #3a3f4b !important;
background: #21252e;
}
:root[data-bs-theme="dark"] .task-picker-header {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: #2d3748;
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
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%);
}
</style>

View File

@@ -1,272 +0,0 @@
<template>
<div class="note-list" :class="{ 'note-list--list': viewMode === 'list' }">
<div v-if="notes.length === 0" class="empty-notes-state" role="status" aria-live="polite">
<i class="mdi mdi-file-document-outline empty-notes-icon" aria-hidden="true"></i>
<h3 class="empty-notes-title">No Notes Yet</h3>
<p class="empty-notes-message">This space is empty for now. Create your first note to get started.</p>
</div>
<div v-for="note in notes" :key="note.id" class="note-card" :class="{ 'is-pinned': note.is_pinned, 'is-featured': note.is_favorite || note.is_featured }" @click="selectNote(note)">
<h5 class="note-title">
<i v-if="note.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="note.is_favorite || note.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ note.title }}
</h5>
<p class="note-preview">{{ getDescription(note) }}</p>
<small class="text-muted">Updated: {{ formatDate(note.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
defineProps({
notes: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "loadMore"]);
const selectNote = (note) => {
emit("selectNote", note);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
const getDescription = (note) => {
const description = (note?.description || "").trim();
if (!description) {
return "No description";
}
return description;
};
</script>
<style scoped>
.note-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.empty-notes-state {
grid-column: 1 / -1;
min-height: 48vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
border: 1px dashed #cfd6e4;
border-radius: 14px;
background: linear-gradient(180deg, #f8f9fc 0%, #eef3fb 100%);
padding: 2rem 1.5rem;
}
.empty-notes-icon {
font-size: 5.25rem;
line-height: 1;
color: #60789a;
margin-bottom: 0.85rem;
}
.empty-notes-title {
margin: 0;
color: #23364f;
font-size: 1.8rem;
font-weight: 700;
}
.empty-notes-message {
margin: 0.75rem 0 0;
max-width: 460px;
color: #4f637d;
font-size: 1.05rem;
}
.note-card {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.note-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.note-title {
margin-bottom: 0.5rem;
color: #333;
display: flex;
align-items: center;
gap: 0.3rem;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.95em;
flex-shrink: 0;
}
.note-card.is-pinned {
background: #dbf5ff;
border-color: #a8d1ff;
}
.note-card.is-featured {
border-color: #ffd8a8;
background: #fff9db;
}
.note-preview {
color: #666;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-footer {
grid-column: 1 / -1;
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
/* List view overrides */
.note-list--list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.note-list--list .note-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-radius: 6px;
}
.note-list--list .note-card:hover {
transform: none;
box-shadow: none;
background-color: #eef2ff;
border-color: #667eea;
border-left: 3px solid #667eea;
}
.note-list--list .note-title {
flex: 0 0 220px;
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-list--list .note-preview {
flex: 1;
margin-bottom: 0;
}
.note-list--list .note-card > small {
flex: 0 0 auto;
white-space: nowrap;
}
.note-list--list .list-footer {
grid-column: unset;
}
@media (max-width: 768px) {
.empty-notes-state {
min-height: 40vh;
padding: 1.5rem 1rem;
}
.empty-notes-icon {
font-size: 4.3rem;
}
.empty-notes-title {
font-size: 1.45rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .empty-notes-state {
border-color: #3a3f4b;
background: linear-gradient(180deg, #1e2430 0%, #21252e 100%);
}
:root[data-bs-theme="dark"] .empty-notes-title {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .empty-notes-message {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .note-card {
border-color: #3a3f4b;
background-color: #21252e;
}
:root[data-bs-theme="dark"] .note-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .note-list--list .note-card:hover {
background-color: #2a2f3a;
border-color: #7aa2f7;
border-left-color: #7aa2f7;
}
:root[data-bs-theme="dark"] .note-title {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .note-preview {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .note-card.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .note-card.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
</style>

View File

@@ -152,196 +152,4 @@ const onMarkdownClick = (event) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteViewer.css"></style>
.note-viewer {
max-width: 900px;
}
.note-meta {
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.meta-grid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #eef2ff;
color: #364fc7;
font-size: 0.8rem;
}
.state-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pinned-chip {
color: #005f8f;
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.featured-chip {
color: #8d7619;
border: 1px solid #ffd8a8;
background: #fff9db;
}
.public-chip {
color: #0c5460;
border: 1px solid #a5d8ff;
background: #e7f5ff;
}
.private-chip {
color: #5f3dc4;
border: 1px solid #d0bfff;
background: #f3f0ff;
}
.protected-chip {
color: #7f5539;
border: 1px solid #e0c3a6;
background: #fff4e6;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-body :deep(p),
.markdown-body :deep(li) {
line-height: 1.7;
}
.markdown-body :deep(pre) {
padding: 1rem;
border-radius: 0.75rem;
overflow-x: auto;
}
.markdown-body :deep(code) {
font-family: "Courier New", monospace;
}
.markdown-body :deep(blockquote) {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #748ffc;
background: #f8f9ff;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .note-meta {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .tag-chip {
background: #1e2d5f;
color: #93b4ff;
}
:root[data-bs-theme="dark"] .pinned-chip {
color: #7dd3fc;
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .featured-chip {
color: #fbbf24;
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .public-chip {
color: #67e8f9;
background: #0c2a3a;
border-color: #1d6a7a;
}
:root[data-bs-theme="dark"] .private-chip {
color: #c4b5fd;
background: #2d1f5e;
border-color: #5b3f9a;
}
:root[data-bs-theme="dark"] .protected-chip {
color: #fdba74;
background: #3a1f0a;
border-color: #7a4f1a;
}
:root[data-bs-theme="dark"] .markdown-body :deep(blockquote) {
background: #1e2430;
color: #94a3b8;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
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%);
}
</style>

View File

@@ -3,23 +3,23 @@
<header class="search-results-header"> <header class="search-results-header">
<h2>Search Results</h2> <h2>Search Results</h2>
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p> <p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p> <p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
</header> </header>
<div v-if="!query" class="empty-state"> <div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<h3>Start your search</h3> <h3>Start your search</h3>
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p> <p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
</div> </div>
<div v-else-if="totalResults === 0" class="empty-state"> <div v-else-if="totalResults === 0" class="empty-state">
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
<h3>No matching notes</h3> <h3>No matching results</h3>
<p>Try different keywords or a shorter phrase.</p> <p>Try different keywords or a shorter phrase.</p>
</div> </div>
<div v-else> <div v-else>
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" /> <WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages"> <nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button> <button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
@@ -32,14 +32,14 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import NoteList from "./NoteList.vue"; import WorkspaceList from "./WorkspaceList.vue";
const props = defineProps({ const props = defineProps({
query: { query: {
type: String, type: String,
default: "", default: "",
}, },
notes: { items: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
@@ -57,9 +57,9 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["select-note", "page-change"]); const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
const totalResults = computed(() => props.notes.length); const totalResults = computed(() => props.items.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize))); const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
const normalizedPage = computed(() => { const normalizedPage = computed(() => {
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
return Math.min(props.currentPage, totalPages.value); return Math.min(props.currentPage, totalPages.value);
}); });
const paginatedNotes = computed(() => { const paginatedItems = computed(() => {
const start = (normalizedPage.value - 1) * props.pageSize; const start = (normalizedPage.value - 1) * props.pageSize;
return props.notes.slice(start, start + props.pageSize); return props.items.slice(start, start + props.pageSize);
}); });
const goToPage = (page) => { const goToPage = (page) => {
@@ -82,103 +82,4 @@ const goToPage = (page) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>
.search-results-page {
max-width: 1200px;
margin: 0 auto;
}
.search-results-header {
margin-bottom: 1.5rem;
}
.search-results-header h2 {
margin: 0;
font-size: 1.5rem;
color: #223149;
}
.search-meta {
margin: 0.35rem 0 0;
color: #5b6f8b;
}
.pagination-bar {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
}
.page-indicator {
color: #4f637d;
font-weight: 600;
}
.empty-state {
min-height: 48vh;
border: 1px dashed #cfdae9;
border-radius: 14px;
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 4.2rem;
color: #60789a;
margin-bottom: 0.6rem;
}
.empty-state h3 {
margin: 0;
color: #223149;
}
.empty-state p {
margin: 0.6rem 0 0;
color: #5b6f8b;
max-width: 500px;
}
@media (max-width: 768px) {
.pagination-bar {
flex-direction: column;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .search-results-header h2 {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .search-meta {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .page-indicator {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state {
border-color: #3a3f4b;
background: radial-gradient(circle at 20% 20%, #1a2035 0%, #1e2430 70%);
}
:root[data-bs-theme="dark"] .empty-state h3 {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .empty-state p {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state-icon {
color: #4a6fa5;
}
</style>

View File

@@ -5,19 +5,9 @@
<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 class="btn btn-primary" @click="emit('create-task')">
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</div> </div>
<div class="task-filters"> <div class="task-filters">
<select v-model="filterCategory" class="form-select" @change="emitFilters">
<option value="">All categories</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
<select v-model="filterStatus" class="form-select" @change="emitFilters"> <select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option> <option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id"> <option v-for="status in statuses" :key="status.id" :value="status.id">
@@ -195,6 +185,12 @@
</section> </section>
</div> </div>
<section v-if="selectedTaskList && canDeleteTaskList" class="danger-zone" aria-labelledby="task-list-danger-zone-title">
<h6 id="task-list-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Delete this task list and all associated tasks permanently.</p>
<button type="button" class="btn btn-outline-danger" @click="emitDeleteTaskList">Delete Task List</button>
</section>
<teleport to="body"> <teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal"> <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-dialog modal-dialog-centered" role="document">
@@ -245,15 +241,18 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: { selectedTaskList: {
type: Array, type: Object,
default: () => [], default: null,
},
canDeleteTaskList: {
type: Boolean,
default: false,
}, },
}); });
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]); const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status", "delete-task-list"]);
const filterCategory = ref("");
const filterStatus = ref(""); const filterStatus = ref("");
const filterParent = ref(""); const filterParent = ref("");
const showStatusModal = ref(false); const showStatusModal = ref(false);
@@ -307,7 +306,6 @@ const statusSections = computed(() =>
const emitFilters = () => { const emitFilters = () => {
emit("filter-change", { emit("filter-change", {
categoryId: filterCategory.value || null,
statusId: filterStatus.value || null, statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null, parentTaskId: filterParent.value || null,
}); });
@@ -486,407 +484,13 @@ const deleteStatusFromModal = () => {
}); });
closeStatusModal(); closeStatusModal();
}; };
const emitDeleteTaskList = () => {
if (!props.selectedTaskList?.id || !props.canDeleteTaskList) {
return;
}
emit("delete-task-list", props.selectedTaskList);
};
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: #fff;
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: #fff;
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: #6c757d;
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: center;
}
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
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) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px 1fr;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}
</style>

View File

@@ -16,12 +16,6 @@
<label class="form-label mt-3">Description</label> <label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea> <textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Category</label>
<select v-model="localTask.category_id" class="form-select">
<option value="">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
</select>
<label class="form-label mt-3">Parent Task</label> <label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select"> <select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option> <option value="">No parent (top level)</option>
@@ -83,10 +77,6 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: {
type: Array,
default: () => [],
},
parentTaskOptions: { parentTaskOptions: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -107,12 +97,12 @@ watch(
localTask.value = { localTask.value = {
title: "", title: "",
description: "", description: "",
category_id: "", task_list_id: "",
status_id: props.statuses[0]?.id || "", status_id: props.statuses[0]?.id || "",
parent_task_id: "", parent_task_id: "",
note_links: [], note_links: [],
...value, ...value,
category_id: value?.category_id || "", task_list_id: value?.task_list_id || "",
parent_task_id: value?.parent_task_id || "", parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [], note_links: value?.note_links || [],
}; };
@@ -138,70 +128,10 @@ const stepClass = (status) => {
const saveTask = () => { const saveTask = () => {
emit("save-task", { emit("save-task", {
...localTask.value, ...localTask.value,
category_id: localTask.value.category_id || null, task_list_id: localTask.value.task_list_id || null,
parent_task_id: localTask.value.parent_task_id || null, parent_task_id: localTask.value.parent_task_id || null,
}); });
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="workspace-list" :class="{ 'workspace-list--list': viewMode === 'list' }">
<div v-if="items.length === 0" class="empty-workspace-state" role="status" aria-live="polite">
<i class="mdi mdi-view-grid-outline empty-workspace-icon" aria-hidden="true"></i>
<h3 class="empty-workspace-title">Nothing Here Yet</h3>
<p class="empty-workspace-message">This view has no notes or task lists yet.</p>
</div>
<div v-for="item in items" :key="`${item.kind}-${item.id}`" class="content-card" :class="contentCardClass(item)" @click="openItem(item)">
<h5 class="content-title">
<template v-if="item.kind === 'note'">
<i v-if="item.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
<i v-else-if="item.is_favorite || item.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
{{ item.title }}
</template>
<template v-else>
<i class="mdi mdi-format-list-checkbox list-icon" aria-hidden="true"></i>
{{ item.name }}
</template>
</h5>
<p class="content-preview">{{ getDescription(item) }}</p>
<small class="text-muted">Updated: {{ formatDate(item.updated_at) }}</small>
</div>
<div v-if="canLoadMore" class="list-footer">
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click.stop="emit('loadMore')">
{{ isLoadingMore ? "Loading..." : "Load more" }}
</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
default: () => [],
},
canLoadMore: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
},
viewMode: {
type: String,
default: "grid",
},
});
const emit = defineEmits(["selectNote", "selectTaskList", "loadMore"]);
const openItem = (item) => {
if (item.kind === "task-list") {
emit("selectTaskList", item);
return;
}
emit("selectNote", item);
};
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
const getDescription = (item) => {
const description = (item?.description || "").trim();
if (description) {
return description;
}
return item.kind === "task-list" ? "Open this task list to manage tasks." : "No description";
};
const contentCardClass = (item) => ({
"is-pinned": item.kind === "note" && item.is_pinned,
"is-featured": item.kind === "note" && (item.is_favorite || item.is_featured),
"is-task-list": item.kind === "task-list",
});
</script>
<style scoped src="../assets/styles/scoped/components/WorkspaceList.css"></style>

View File

@@ -718,237 +718,4 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Admin.css"></style>
.admin-page {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-topbar {
flex-wrap: wrap;
padding: 1rem;
border-bottom: 1px solid #dee2e6;
}
.admin-shell {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
overflow: hidden;
}
.admin-sidebar {
width: 280px;
flex-shrink: 0;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-nav .nav-link {
border-radius: 0.6rem;
color: #495057;
font-weight: 500;
}
.admin-nav .nav-link:hover {
background: #eef2f7;
color: #212529;
}
.admin-nav .nav-link.active {
background: #212529;
color: #fff;
}
.admin-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 1rem;
}
.admin-section {
border-radius: 12px;
}
.users-list .list-group-item {
padding: 1rem;
}
.user-row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.user-row-main {
flex: 1;
min-width: 0;
}
.user-row-actions {
flex-shrink: 0;
}
.user-actions-stack {
flex-wrap: wrap;
justify-content: flex-end;
}
.user-name-line {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.user-name {
font-size: 1.1rem;
}
.user-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1.25rem;
}
.user-meta-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6c757d;
margin-bottom: 0.1rem;
}
.user-meta-value {
color: #495057;
overflow-wrap: anywhere;
}
.user-meta-item-groups {
grid-column: span 1;
}
@media (max-width: 991.98px) {
.user-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-meta-item-groups {
grid-column: 1 / -1;
}
}
@media (max-width: 767.98px) {
.admin-shell {
display: block;
min-height: auto;
}
.admin-topbar {
padding: 0.75rem;
}
.admin-content {
padding: 0.75rem;
}
.admin-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1400;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(82vw, 320px);
z-index: 1410;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-right: 1px solid #dee2e6;
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-sidebar.open {
transform: translateX(0);
}
.user-row {
flex-direction: column;
align-items: stretch;
}
.user-row-actions {
width: 100%;
}
.user-row-actions .btn {
width: 100%;
}
.user-actions-stack {
flex-direction: column;
}
.user-meta-grid {
grid-template-columns: 1fr;
gap: 0.65rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .admin-topbar {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .admin-sidebar {
background: #21252e;
border-right-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
background: #2d3748;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
background: #e2e8f0;
color: #1a1d23;
}
:root[data-bs-theme="dark"] .user-meta-value {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-section {
background-color: #21252e;
}
</style>

View File

@@ -142,119 +142,7 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Login.css"></style>
.login-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 460px;
}
.login-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-provider-btn {
min-height: 48px;
border-radius: 0.65rem;
}
.oauth-divider {
display: flex;
align-items: center;
color: #6c757d;
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid #dee2e6;
}
.oauth-divider span {
padding: 0 0.75rem;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.login-page {
padding: 0.85rem;
}
.login-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>

View File

@@ -322,74 +322,7 @@ watch(
); );
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/PublicSpace.css"></style>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.public-body {
flex: 1;
overflow: hidden;
position: relative;
}
.public-sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
.note-item {
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid transparent;
transition: background 0.15s;
}
.note-item:hover {
background: #e9ecef;
}
.note-item.active {
background: #dbe4ff;
border-color: #748ffc;
color: #364fc7;
}
.note-item.is-featured {
background: #fff4e6;
border-color: #ffd8a8;
}
.note-item.is-featured:hover {
background: #ffe8cc;
}
@media (max-width: 768px) {
.public-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.public-sidebar {
position: fixed;
left: 0;
bottom: 0;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
.public-sidebar.open {
transform: translateX(0);
}
}
</style>

View File

@@ -110,96 +110,7 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Register.css"></style>
.register-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 560px;
}
.register-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.register-page {
padding: 0.85rem;
}
.register-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>

View File

@@ -13,12 +13,13 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false); const notesLoading = ref(false);
const categories = ref([]); const categories = ref([]);
const categoryTree = ref([]); const categoryTree = ref([]);
const taskLists = ref([]);
const tasks = ref([]); const tasks = ref([]);
const taskStatuses = ref([]); const taskStatuses = ref([]);
const noteLinkedTasks = ref([]); const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => { const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -227,6 +228,39 @@ export const useSpaceStore = defineStore("space", () => {
} }
}; };
const fetchTaskLists = async (spaceId) => {
if (!spaceId) {
taskLists.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
taskLists.value = response.data || [];
return taskLists.value;
} catch (error) {
console.error("Error fetching task lists:", error);
taskLists.value = [];
return [];
}
};
const createTaskList = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const updateTaskList = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const deleteTaskList = async (spaceId, taskListId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
await fetchTaskLists(spaceId);
};
const createTaskStatus = async (spaceId, payload) => { const createTaskStatus = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskStatuses(spaceId);
@@ -258,8 +292,8 @@ export const useSpaceStore = defineStore("space", () => {
return []; return [];
} }
const params = {}; const params = {};
if (filters.categoryId) { if (filters.taskListId) {
params.categoryId = filters.categoryId; params.taskListId = filters.taskListId;
} }
if (filters.statusId) { if (filters.statusId) {
params.statusId = filters.statusId; params.statusId = filters.statusId;
@@ -344,6 +378,7 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading, notesLoading,
categories, categories,
categoryTree, categoryTree,
taskLists,
tasks, tasks,
taskStatuses, taskStatuses,
noteLinkedTasks, noteLinkedTasks,
@@ -363,6 +398,10 @@ export const useSpaceStore = defineStore("space", () => {
searchNotes, searchNotes,
clearSearchResults, clearSearchResults,
fetchTaskStatuses, fetchTaskStatuses,
fetchTaskLists,
createTaskList,
updateTaskList,
deleteTaskList,
createTaskStatus, createTaskStatus,
updateTaskStatus, updateTaskStatus,
deleteTaskStatus, deleteTaskStatus,