8 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
@@ -530,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
// TaskStatusDTO represents a task status in API responses. // TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct { type TaskStatusDTO struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"space_id"` TaskListID string `json:"task_list_id"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Order int `json:"order"` Order int `json:"order"`
@@ -538,14 +539,35 @@ type TaskStatusDTO struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// NewTaskDTO creates a DTO from a task entity. // CreateTaskListRequest represents task list creation input.
func NewTaskDTO(task *entities.Task) *TaskDTO { type CreateTaskListRequest struct {
var categoryID *string Name string `json:"name" validate:"required,min=1,max=120"`
if task.CategoryID != nil { Description string `json:"description" validate:"max=500"`
id := task.CategoryID.Hex() CategoryID *string `json:"category_id,omitempty"`
categoryID = &id
} }
// UpdateTaskListRequest represents task list update input.
type UpdateTaskListRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// TaskListDTO represents a task list in API responses.
type TaskListDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO {
var 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,11 +596,32 @@ 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{
ID: status.ID.Hex(), ID: status.ID.Hex(),
SpaceID: status.SpaceID.Hex(), TaskListID: status.TaskListID.Hex(),
Name: status.Name, Name: status.Name,
Color: status.Color, Color: status.Color,
Order: status.Order, Order: status.Order,

View File

@@ -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,
@@ -43,8 +46,8 @@ func NewTaskService(
} }
} }
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -63,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
for idx, status := range defaults { for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{ if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: status.name, Name: status.name,
Color: status.color, Color: status.color,
Order: idx, Order: idx,
@@ -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
} }
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
return nil return nil
} }
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) { func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
return status, nil return status, nil
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
return depth, nil return depth, nil
} }
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) { func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID { if currentStatusID == requestedStatusID {
return true, nil return true, nil
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -205,18 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
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 +214,18 @@ 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
}
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,17 +252,25 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil { if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
return nil, validateErr return nil, validateErr
} }
statusID = parsedStatusID statusID = parsedStatusID
} }
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,
@@ -291,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
status, err := s.validateStatus(ctx, spaceID, task.StatusID) status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -318,22 +326,19 @@ 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
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
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)
@@ -395,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil { if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found") return nil, errors.New("note not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID}) tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Collect statuses per task list
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
byID, ok := statusCache[taskListID]
if !ok {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil {
return nil
}
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, st := range statuses {
byID[st.ID] = st
}
statusCache[taskListID] = byID
}
return byID[statusID]
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks)) result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
status := statusByID[task.StatusID] status := getStatus(task.TaskListID, task.StatusID)
if status == nil { if status == nil {
continue continue
} }
@@ -453,19 +468,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 +503,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
} }
} }
@@ -494,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid status") return nil, errors.New("invalid status")
} }
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil { if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
return nil, err return nil, err
} }
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID) adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -567,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -670,14 +692,155 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n
return dto.NewTaskDTO(task), nil return dto.NewTaskDTO(task), nil
} }
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil { lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID)
if err != nil {
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) 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
}
if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
return err
}
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
}
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -688,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
return result, nil return result, nil
} }
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -699,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
status := &entities.TaskStatus{ status := &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color), Color: strings.TrimSpace(req.Color),
Order: len(statuses), Order: len(statuses),
@@ -719,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -732,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
} }
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("task status not found") return nil, errors.New("task status not found")
} }
@@ -747,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error { func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err return err
} }
@@ -759,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return errors.New("insufficient permissions") return errors.New("insufficient permissions")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -779,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return err return err
} }
return s.normalizeStatusOrder(ctx, spaceID) return s.normalizeStatusOrder(ctx, taskListID)
} }
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -793,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -816,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
status := statusByID[statusID] status := statusByID[statusID]
if status == nil { if status == nil {
return nil, errors.New("status id does not belong to this space") return nil, errors.New("status id does not belong to this task list")
} }
if _, exists := seen[statusID]; exists { if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids") return nil, errors.New("duplicate status id in ordered_status_ids")
@@ -847,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
} }
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -859,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
return result, nil return result, nil
} }
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -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"`
@@ -25,13 +25,26 @@ type Task struct {
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskStatus defines the ordered linear status progression for a space. // TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct { type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"` ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"` TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"` Name string `bson:"name"`
Color string `bson:"color,omitempty"` Color string `bson:"color,omitempty"`
Order int `bson:"order"` Order int `bson:"order"`
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,15 +225,28 @@ 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
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
} }

View File

@@ -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,94 @@ 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: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
})
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
@@ -145,8 +231,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
return &status, nil return &status, nil
} }
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) { func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}})) cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -170,14 +256,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
return err return err
} }
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error { func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
}) })

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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,267 @@
.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;
}
.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"] .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,383 @@
.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;
}
@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

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

View File

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

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

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

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

View File

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

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

View File

@@ -0,0 +1,317 @@
<template>
<teleport to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Task List</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<!-- Task List Details -->
<div class="mb-4">
<label class="form-label" for="editTaskListName">Name</label>
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
<option :value="null">No category</option>
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
</select>
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
</div>
<hr />
<!-- Status Progression -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<hr />
<!-- Danger Zone -->
<DangerZonePanel
v-if="canDeleteTaskList"
title-id="edit-task-list-danger-zone"
title="Danger Zone"
description="Delete this task list, all associated tasks, and statuses permanently."
>
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Task List
</button>
</DangerZonePanel>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
<!-- Status Create/Edit Sub-Modal -->
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="editStatusName">Status Name</label>
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
<div class="status-color-row">
<input id="editStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<DangerZonePanel
v-if="statusMode === 'edit'"
class="mt-4"
title-id="edit-status-danger-zone"
title="Danger Zone"
description="Deleting this status is permanent and cannot be undone."
>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</DangerZonePanel>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show" style="z-index: 1055"></div>
</teleport>
</template>
<script setup>
import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
taskList: {
type: Object,
required: true,
},
statuses: {
type: Array,
default: () => [],
},
categoryOptions: {
type: Array,
default: () => [],
},
canDeleteTaskList: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
const listForm = ref({ name: "", category_id: null });
watch(
() => props.taskList,
(tl) => {
listForm.value = {
name: tl?.name || "",
category_id: tl?.category_id || null,
};
},
{ immediate: true },
);
const saveListDetails = () => {
const name = listForm.value.name?.trim();
if (!name) {
return;
}
emit("update-task-list", {
name,
category_id: listForm.value.category_id || null,
});
};
// Status drag-and-drop reorder
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const onStatusDragStart = (id) => {
draggedStatusId.value = id;
};
const onStatusDragOver = (id) => {
dragOverStatusId.value = id;
};
const onStatusDragLeave = (id) => {
if (dragOverStatusId.value === id) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((s) => s.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
// Status create/edit modal
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const statusForm = ref({ name: "", color: "#7c8596" });
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = { name: status.name || "", color: status.color || "#7c8596" };
showStatusModal.value = true;
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = { name: "", color: "#7c8596" };
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else if (editingStatusId.value) {
emit("rename-status", { id: editingStatusId.value, name, color });
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script>
<style scoped>
.status-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
cursor: grab;
}
.status-item.is-drag-over {
border-color: var(--bs-primary);
background: rgba(var(--bs-primary-rgb), 0.08);
}
.drag-handle {
cursor: grab;
opacity: 0.5;
font-size: 1.1rem;
line-height: 1;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-name {
flex: 1;
font-size: 0.9rem;
}
.status-actions {
margin-left: auto;
}
.status-color-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.status-color-row .form-control-color {
width: 40px;
padding: 0.25rem;
flex-shrink: 0;
}
</style>

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

@@ -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), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -211,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = []; searchResults.value = [];
}; };
const fetchTaskStatuses = async (spaceId) => { const fetchTaskStatuses = async (spaceId, taskListId) => {
if (!spaceId) { if (!spaceId || !taskListId) {
taskStatuses.value = []; taskStatuses.value = [];
return []; return [];
} }
try { try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`); const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
return taskStatuses.value; return taskStatuses.value;
} catch (error) { } catch (error) {
@@ -227,25 +228,58 @@ export const useSpaceStore = defineStore("space", () => {
} }
}; };
const createTaskStatus = async (spaceId, payload) => { const fetchTaskLists = async (spaceId) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); if (!spaceId) {
await fetchTaskStatuses(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; return response.data;
}; };
const updateTaskStatus = async (spaceId, statusId, payload) => { const updateTaskList = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload); const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskLists(spaceId);
return response.data; return response.data;
}; };
const deleteTaskStatus = async (spaceId, statusId) => { const deleteTaskList = async (spaceId, taskListId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`); await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
await fetchTaskStatuses(spaceId); await fetchTaskLists(spaceId);
}; };
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => { const createTaskStatus = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, { const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
await fetchTaskStatuses(spaceId, taskListId);
return response.data;
};
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId, taskListId);
return response.data;
};
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
await fetchTaskStatuses(spaceId, taskListId);
};
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
ordered_status_ids: orderedStatusIds, ordered_status_ids: orderedStatusIds,
}); });
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
@@ -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,