9 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
domrichardson
1b336299ee feat: task system
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m55s
2026-03-27 16:33:11 +00:00
75 changed files with 8921 additions and 2943 deletions

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ Required or commonly used:
- `JWT_SECRET`
- `ENCRYPTION_KEY`
- `FRONTEND_URL`
- `VITE_API_BASE_URL`
- `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME`
- `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`
- Backend port: `8080`
- Public frontend URL: `http://localhost`
- Browser API base URL for container builds: `http://localhost`
## 2. `backend/.env`
@@ -107,13 +105,12 @@ cp .env.example .env
### Frontend Variables In `frontend/.env.example`
- `VITE_API_BASE_URL`
- `VITE_ENV`
- `VITE_ENABLE_ANALYTICS`
### 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.

View File

@@ -23,6 +23,7 @@ space.<space_permission_key>.<action>
- space_permission_key is derived from the space name (normalized token)
- Example:
- space.product_docs.note.create
- space.product_docs.tasks.create
- space.product_docs.settings.member.manage
## Space-Scoped Actions Currently Enforced
@@ -49,6 +50,16 @@ space.<space_permission_key>.<action>
- note.edit
- note.delete
### Task Management
- tasks.create
- tasks.edit
- tasks.delete
### Task Status Management
- tasks.status.manage
## Wildcard Support
Permissions support wildcard matching with \*.
@@ -59,6 +70,8 @@ Examples:
- Grants all permissions for the product_docs space
- space.\*.note.create
- Grants note.create for all spaces
- space.\*.tasks.\*
- Grants all task permissions for all spaces
- `*`
- Grants all permissions globally

View File

@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
Check:
- 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`
### OAuth callback redirects to the wrong URL

View File

@@ -135,6 +135,7 @@ func main() {
db.MembershipRepo,
db.NoteRepo,
db.CategoryRepo,
db.TaskListRepo,
db.UserRepo,
permissionService,
)
@@ -151,11 +152,22 @@ func main() {
categoryService := services.NewCategoryService(
db.CategoryRepo,
db.TaskListRepo,
db.MembershipRepo,
db.NoteRepo,
permissionService,
)
taskService := services.NewTaskService(
db.TaskRepo,
db.TaskListRepo,
db.TaskStatusRepo,
db.NoteRepo,
db.CategoryRepo,
db.MembershipRepo,
permissionService,
)
adminService := services.NewAdminService(
db.UserRepo,
db.GroupRepo,
@@ -189,6 +201,7 @@ func main() {
spaceHandler := handlers.NewSpaceHandler(spaceService)
noteHandler := handlers.NewNoteHandler(noteService)
categoryHandler := handlers.NewCategoryHandler(categoryService)
taskHandler := handlers.NewTaskHandler(taskService)
adminHandler := handlers.NewAdminHandler(adminService)
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
settingsHandler := handlers.NewSettingsHandler(authService)
@@ -258,6 +271,30 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints (scoped to task list)
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")

View File

@@ -430,6 +430,7 @@ type CategoryTreeDTO struct {
*CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"`
TaskLists []*TaskListDTO `json:"task_lists"`
}
// NewCategoryDTO creates a DTO from a category entity
@@ -452,6 +453,183 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
}
}
// ========== TASK DTOs ==========
// CreateTaskRequest represents task creation input.
type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"`
TaskListID string `json:"task_list_id" validate:"required"`
StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"`
}
// UpdateTaskRequest represents task update input.
type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
TaskListID *string `json:"task_list_id,omitempty"`
StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"`
}
// TaskTransitionRequest allows moving task status by one step.
type TaskTransitionRequest struct {
Direction string `json:"direction" validate:"required,oneof=forward backward"`
}
// LinkTaskNoteRequest links/unlinks a note from a task.
type LinkTaskNoteRequest struct {
NoteID string `json:"note_id" validate:"required"`
}
// TaskDTO represents a task in API responses.
type TaskDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
Title string `json:"title"`
Description string `json:"description"`
TaskListID string `json:"task_list_id"`
StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"`
NoteLinks []string `json:"note_links"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// TaskWithStatusDTO includes status details and child tasks for detail views.
type TaskWithStatusDTO struct {
*TaskDTO
StatusName string `json:"status_name"`
StatusColor string `json:"status_color,omitempty"`
StatusOrder int `json:"status_order"`
Subtasks []*TaskDTO `json:"subtasks"`
}
// CreateTaskStatusRequest represents task status creation input.
type CreateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// UpdateTaskStatusRequest represents task status updates.
type UpdateTaskStatusRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Color string `json:"color,omitempty" validate:"max=20"`
}
// ReorderTaskStatusesRequest represents a full ordered status ID list.
type ReorderTaskStatusesRequest struct {
OrderedStatusIDs []string `json:"ordered_status_ids"`
}
// TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct {
ID string `json:"id"`
TaskListID string `json:"task_list_id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
Order int `json:"order"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CreateTaskListRequest represents task list creation input.
type CreateTaskListRequest struct {
Name string `json:"name" validate:"required,min=1,max=120"`
Description string `json:"description" validate:"max=500"`
CategoryID *string `json:"category_id,omitempty"`
}
// UpdateTaskListRequest represents task list update input.
type UpdateTaskListRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// TaskListDTO represents a task list in API responses.
type TaskListDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO {
var parentTaskID *string
if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex()
parentTaskID = &id
}
noteLinks := make([]string, 0, len(task.NoteLinks))
for _, noteID := range task.NoteLinks {
noteLinks = append(noteLinks, noteID.Hex())
}
return &TaskDTO{
ID: task.ID.Hex(),
SpaceID: task.SpaceID.Hex(),
Title: task.Title,
Description: task.Description,
TaskListID: task.TaskListID.Hex(),
StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID,
Depth: task.Depth,
NoteLinks: noteLinks,
CreatedBy: task.CreatedBy.Hex(),
UpdatedBy: task.UpdatedBy.Hex(),
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskListDTO creates a DTO from a task list entity.
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
var categoryID *string
if taskList.CategoryID != nil {
id := taskList.CategoryID.Hex()
categoryID = &id
}
return &TaskListDTO{
ID: taskList.ID.Hex(),
SpaceID: taskList.SpaceID.Hex(),
CategoryID: categoryID,
Name: taskList.Name,
Description: taskList.Description,
CreatedBy: taskList.CreatedBy.Hex(),
UpdatedBy: taskList.UpdatedBy.Hex(),
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{
ID: status.ID.Hex(),
TaskListID: status.TaskListID.Hex(),
Name: status.Name,
Color: status.Color,
Order: status.Order,
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ========== ERROR DTOs ==========
// ErrorResponse represents an error response

View File

@@ -15,6 +15,7 @@ import (
// CategoryService handles category operations
type CategoryService struct {
categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
permissionService *PermissionService
@@ -23,12 +24,14 @@ type CategoryService struct {
// NewCategoryService creates a new category service
func NewCategoryService(
categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
permissionService *PermissionService,
) *CategoryService {
return &CategoryService{
categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
permissionService: permissionService,
@@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit
}
}
// Get task lists in this category
taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, taskList := range taskLists {
tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList))
}
}
return tree, nil
}

View File

@@ -17,6 +17,7 @@ type SpaceService struct {
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
userRepo repositories.UserRepository
permissionService *PermissionService
}
@@ -27,6 +28,7 @@ func NewSpaceService(
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
userRepo repositories.UserRepository,
permissionService *PermissionService,
) *SpaceService {
@@ -35,6 +37,7 @@ func NewSpaceService(
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
userRepo: userRepo,
permissionService: permissionService,
}
@@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
package entities
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const MaxTaskDepth = 2
// Task represents a task and supports up to 3 nesting levels (0,1,2).
type Task struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"`
Description string `bson:"description"`
TaskListID bson.ObjectID `bson:"task_list_id"`
StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"`
NoteLinks []bson.ObjectID `bson:"note_links"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"`
TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"`
Color string `bson:"color,omitempty"`
Order int `bson:"order"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
// TaskList groups tasks under a named list that can be attached to a category.
type TaskList struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}

View File

@@ -216,3 +216,37 @@ type NoteRevisionRepository interface {
// GetRevisionByID retrieves a specific revision
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
}
// TaskRepository defines task operations
type TaskRepository interface {
CreateTask(ctx context.Context, task *entities.Task) error
GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error)
ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error)
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) 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
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
}
// TaskListRepository defines task list operations.
type TaskListRepository interface {
CreateTaskList(ctx context.Context, list *entities.TaskList) error
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// TaskStatusRepository defines task status operations
type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
}

View File

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

View File

@@ -0,0 +1,276 @@
package database
import (
"context"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// TaskRepository implements task data access.
type TaskRepository struct {
collection *mongo.Collection
}
// NewTaskRepository creates a new task repository.
func NewTaskRepository(db *mongo.Database) *TaskRepository {
return &TaskRepository{collection: db.Collection("tasks")}
}
func (r *TaskRepository) CreateTask(ctx context.Context, task *entities.Task) error {
task.ID = bson.NewObjectID()
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
if task.NoteLinks == nil {
task.NoteLinks = []bson.ObjectID{}
}
_, err := r.collection.InsertOne(ctx, task)
return err
}
func (r *TaskRepository) GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) {
var task entities.Task
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&task)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task not found")
}
return nil, err
}
return &task, nil
}
func (r *TaskRepository) ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) {
query := bson.M{"space_id": spaceID}
for k, v := range filters {
query[k] = v
}
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
cursor, err := r.collection.Find(ctx, query, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) {
cursor, err := r.collection.Find(ctx, bson.M{
"space_id": spaceID,
"$or": []bson.M{
{"title": bson.M{"$regex": query, "$options": "i"}},
{"description": bson.M{"$regex": query, "$options": "i"}},
},
}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(30))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var tasks []*entities.Task
if err := cursor.All(ctx, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (r *TaskRepository) UpdateTask(ctx context.Context, task *entities.Task) error {
task.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": task.ID}, task)
return err
}
func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
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 {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskRepository) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) {
return r.collection.CountDocuments(ctx, bson.M{"parent_task_id": parentTaskID})
}
func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
})
return err
}
// TaskListRepository implements task list data access.
type TaskListRepository struct {
collection *mongo.Collection
}
// NewTaskListRepository creates a new task list repository.
func NewTaskListRepository(db *mongo.Database) *TaskListRepository {
return &TaskListRepository{collection: db.Collection("task_lists")}
}
func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error {
list.ID = bson.NewObjectID()
list.CreatedAt = time.Now()
list.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, list)
return err
}
func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) {
var list entities.TaskList
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task list not found")
}
return nil, err
}
return &list, nil
}
func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error {
list.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list)
return err
}
func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
})
return err
}
// TaskStatusRepository implements task status data access.
type TaskStatusRepository struct {
collection *mongo.Collection
}
// NewTaskStatusRepository creates a new task status repository.
func NewTaskStatusRepository(db *mongo.Database) *TaskStatusRepository {
return &TaskStatusRepository{collection: db.Collection("task_statuses")}
}
func (r *TaskStatusRepository) CreateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.ID = bson.NewObjectID()
status.CreatedAt = time.Now()
status.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, status)
return err
}
func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) {
var status entities.TaskStatus
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&status)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task status not found")
}
return nil, err
}
return &status, nil
}
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var statuses []*entities.TaskStatus
if err := cursor.All(ctx, &statuses); err != nil {
return nil, err
}
return statuses, nil
}
func (r *TaskStatusRepository) UpdateStatus(ctx context.Context, status *entities.TaskStatus) error {
status.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": status.ID}, status)
return err
}
func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true),
},
{
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true),
},
})
return err
}

View File

@@ -0,0 +1,511 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
"github.com/gorilla/mux"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskHandler handles task and task status endpoints.
type TaskHandler struct {
taskService *services.TaskService
}
// NewTaskHandler creates a task handler.
func NewTaskHandler(taskService *services.TaskService) *TaskHandler {
return &TaskHandler{taskService: taskService}
}
func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) {
userID, err := getUserObjectID(r)
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
if err != nil {
return bson.NilObjectID, bson.NilObjectID, err
}
return userID, spaceID, nil
}
func (h *TaskHandler) CreateTask(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.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.CreateTask(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(task)
}
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
taskListFilter := &taskListID
statusFilter := &statusID
parentFilter := &parentTaskID
if taskListID == "" {
taskListFilter = nil
}
if statusID == "" {
statusFilter = nil
}
if parentTaskID == "" {
parentFilter = nil
}
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lists)
}
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
query := r.URL.Query().Get("q")
tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.TaskTransitionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
var req dto.LinkTaskNoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(req.NoteID)
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
if err != nil {
http.Error(w, "invalid task id", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
if err != nil {
http.Error(w, "invalid note id", http.StatusBadRequest)
return
}
tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (h *TaskHandler) ListStatuses(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
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}
func (h *TaskHandler) CreateStatus(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.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, 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(status)
}
func (h *TaskHandler) UpdateStatus(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
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (h *TaskHandler) DeleteStatus(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
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) ReorderStatuses(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.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statuses)
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,70 @@
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--text-color: #333;
--bg-color: #f8f9fa;
--border-color: #dee2e6;
--color-primary: #667eea;
--color-primary-strong: #4f46a5;
--color-text: #333333;
--color-text-muted: #6c757d;
--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"] {
--text-color: #e2e8f0;
--bg-color: #1a1d23;
--border-color: #3a3f4b;
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--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 {
background-color: #1a1d23;
color: #e2e8f0;
background-color: var(--color-bg);
color: var(--color-text);
}
[data-bs-theme="dark"] .sidebar {
background-color: #21252e !important;
border-color: #3a3f4b !important;
background-color: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
[data-bs-theme="dark"] .toolbar {
background-color: #21252e;
border-color: #3a3f4b !important;
background-color: var(--color-surface);
border-color: var(--color-border) !important;
}
[data-bs-theme="dark"] .main-content {
background-color: #1a1d23;
background-color: var(--color-bg);
}
[data-bs-theme="dark"] .markdown-body table {
background: #21252e;
background: var(--color-surface);
}
[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 {
@@ -49,8 +77,8 @@
}
[data-bs-theme="dark"] .markdown-body :not(pre) > code {
background: #2d3748;
color: #e2e8f0;
background: var(--color-surface-muted);
color: var(--color-text);
}
[data-bs-theme="dark"] .markdown-body pre code {
@@ -59,20 +87,20 @@
}
[data-bs-theme="dark"] .markdown-body pre {
background: #2d3748;
color: #e2e8f0;
background: var(--color-code-bg);
color: var(--color-code-text);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-track {
background: #2d3748;
background: var(--color-scroll-track);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
background: #4a5568;
background: var(--color-scroll-thumb);
}
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: #718096;
background: var(--color-scroll-thumb-hover);
}
* {
@@ -99,7 +127,7 @@ body,
margin: 1rem 0;
border-collapse: collapse;
border-spacing: 0;
background: #fff;
background: var(--color-surface);
}
.markdown-body th,
@@ -126,7 +154,7 @@ body,
.markdown-body blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #748ffc;
border-left: 4px solid var(--color-info);
background: #f8f9ff;
color: #334155;
}
@@ -139,8 +167,8 @@ body,
margin: 1rem 0;
padding: 1rem;
border-radius: 0.75rem;
background: #353943;
color: #f9fafb;
background: var(--color-code-bg);
color: var(--color-code-text);
overflow-x: auto;
}
@@ -155,7 +183,7 @@ body,
font-size: 0.95em;
padding: 0.1rem 0.3rem;
border-radius: 0.35rem;
background: #f1f3f5;
background: var(--color-surface-muted);
}
/* Scrollbar styling */
@@ -165,14 +193,14 @@ body,
}
::-webkit-scrollbar-track {
background: #f1f1f1;
background: var(--color-scroll-track);
}
::-webkit-scrollbar-thumb {
background: #888;
background: var(--color-scroll-thumb);
border-radius: 4px;
}
::-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 = {
name: props.group?.name || "",
description: props.group?.description || "",
permissionsText: (props.group?.permissions || []).join("\n"),
permissionsText: (props.group?.permissions || []).join("/n"),
};
};
@@ -93,33 +93,7 @@ const handleSubmit = () => {
};
</script>
<style scoped>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
<style scoped src="../assets/styles/scoped/components/AdminGroupModal.css"></style>
.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 v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
<div>
<div class="fw-semibold text-danger">Danger Zone</div>
<div class="small text-muted">Permanently delete this provider configuration.</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider
</button>
</div>
</div>
<DangerZonePanel
class="mt-4"
title-id="danger-zone-title"
title="Danger Zone"
description="Permanently delete this provider configuration. This action cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button>
</DangerZonePanel>
</div>
</div>
</div>
@@ -92,6 +92,7 @@
<script setup>
import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({
mode: {
@@ -178,28 +179,4 @@ const handleSubmit = () => {
};
</script>
<style scoped>
.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>
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>

View File

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

View File

@@ -83,29 +83,7 @@ const handleSubmit = () => {
};
</script>
<style scoped>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
<style scoped src="../assets/styles/scoped/components/AdminUserModal.css"></style>
.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 v-for="category in categories" :key="category.id" class="category-item">
<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>
</span>
<span v-else class="expand-icon"> </span>
@@ -20,6 +20,11 @@
</div>
<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
v-for="note in sortedNotes(category.notes)"
:key="note.id"
@@ -41,6 +46,7 @@
:on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory"
:on-select-task-list="onSelectTaskList"
:can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories"
@@ -80,6 +86,10 @@ const props = defineProps({
type: Function,
required: true,
},
onSelectTaskList: {
type: Function,
required: true,
},
canCreateCategories: {
type: Boolean,
default: false,
@@ -141,187 +151,4 @@ const handleDeleteCategory = (category) => {
};
</script>
<style scoped>
.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>
<style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>

View File

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

View File

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

View File

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

View File

@@ -49,4 +49,5 @@ const handleCreate = () => {
};
</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>
<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>
<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>
</button>
</div>
@@ -87,11 +87,14 @@
<!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div>
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({
spaceId: {
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
const newFolderName = ref("");
const fileInputRef = ref(null);
const newFolderInputRef = ref(null);
const showDeleteConfirmModal = ref(false);
const pendingDeleteItem = ref(null);
const deletingItem = ref(false);
const breadcrumbs = computed(() => {
if (!currentPrefix.value) return [];
@@ -215,9 +221,37 @@ const createFolder = async () => {
}
};
const deleteItem = async (obj) => {
const label = displayName(obj);
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return;
const requestDeleteItem = (obj) => {
if (!obj) {
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 = "";
try {
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 loadFiles();
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
} catch (e) {
error.value = e.response?.data || "Delete failed";
} finally {
deletingItem.value = false;
}
};
@@ -284,67 +322,4 @@ watch(showNewFolderInput, async (v) => {
});
</script>
<style scoped>
.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>
<style scoped src="../assets/styles/scoped/components/FileExplorer.css"></style>

View File

@@ -187,95 +187,7 @@ const createProvider = async () => {
onMounted(loadProviders);
</script>
<style scoped>
.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;
}
<style scoped src="../assets/styles/scoped/components/ManageAuthProvidersModal.css"></style>
.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

@@ -13,6 +13,16 @@
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
Files
</button>
<button
v-if="spaceId"
class="btn btn-sm"
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
@click="toggleTaskPicker"
>
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
Tasks
</button>
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
</div>
@@ -25,19 +35,52 @@
</div>
<div class="row">
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
<div :class="editorColumnClass">
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
<div v-if="showTaskMention" class="task-mention-panel">
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
<span>{{ task.title }}</span>
<small>Depth {{ task.depth + 1 }}</small>
</button>
</div>
</div>
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
<div class="preview-pane border rounded p-3">
<div :class="previewColumnClass">
<div class="preview-pane border rounded p-3" @click="onPreviewClick">
<div class="markdown-body" v-html="renderedMarkdown"></div>
</div>
</div>
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
</div>
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
<div class="task-picker border rounded">
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
<span class="small fw-semibold">Space Tasks</span>
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
<div class="task-picker-search p-2 border-bottom">
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
</div>
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
Loading tasks...
</div>
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
<div v-else class="task-picker-list">
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
<span class="task-picker-title">{{ task.title }}</span>
<small>{{ task.picker_status_name }}</small>
</button>
</div>
</div>
</div>
</div>
<div class="mt-3">
@@ -82,24 +125,33 @@
<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>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
<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">
<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.">
<button class="btn btn-danger" type="button" @click="requestDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note
</button>
</section>
</DangerZonePanel>
</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>
<script setup>
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({
note: {
@@ -120,8 +172,9 @@ const props = defineProps({
},
});
const emit = defineEmits(["save", "delete", "cancel"]);
const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
const settingsStore = useSettingsStore();
const spaceStore = useSpaceStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
@@ -135,12 +188,111 @@ const notePassword = ref("");
const saveTimeout = ref(null);
const saveState = ref("saved");
const saveStateTimeout = ref(null);
const taskMentionQuery = ref("");
const taskMentionResults = ref([]);
const showTaskMention = ref(false);
const linkedTasks = ref([]);
const showTaskPicker = ref(false);
const taskPickerQuery = ref("");
const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
const editorColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4";
}
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
});
const previewColumnClass = computed(() => {
if (hasTwoAuxPanels.value) {
return "col-12 col-xl-4 mt-3 mt-xl-0";
}
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
});
const fileExplorerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskPickerColumnClass = computed(() => {
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
});
const taskStatusNameById = computed(() => {
const map = new Map();
for (const status of spaceStore.taskStatuses || []) {
if (status?.id) {
map.set(status.id, status.name || "");
}
}
return map;
});
const taskPickerItems = computed(() => {
const query = taskPickerQuery.value.trim().toLowerCase();
const allTasks = [...(spaceStore.tasks || [])]
.map((task) => ({
...task,
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
}))
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
if (!query) {
return allTasks;
}
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
});
const renderedMarkdown = computed(() => {
const html = renderMarkdown(editingNote.value.content || "");
const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of linkedTasks.value || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const saveStatusLabel = computed(() => {
switch (saveState.value) {
case "dirty":
@@ -155,12 +307,21 @@ const saveStatusLabel = computed(() => {
watch(
() => props.note,
(newNote) => {
async (newNote) => {
editingNote.value = { ...newNote };
tagsInput.value = newNote.tags?.join(", ") || "";
passwordAction.value = "keep";
notePassword.value = "";
saveState.value = "saved";
if (props.spaceId && newNote?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
} catch {
linkedTasks.value = [];
}
} else {
linkedTasks.value = [];
}
},
);
@@ -211,21 +372,33 @@ const saveNote = () => {
notePassword.value = "";
}
markSavedSoon();
// Auto-link any @task(Title) mentions present in the saved content
syncTaskMentionLinks(note.content || "");
};
const autoSave = () => {
saveState.value = "dirty";
clearTimeout(saveTimeout.value);
saveTimeout.value = setTimeout(saveNote, 3000);
detectTaskMention();
};
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
};
const confirmDelete = () => {
if (!props.canDelete) {
return;
}
if (confirm("Are you sure you want to delete this note?")) {
emit("delete", editingNote.value.id);
if (!editingNote.value?.id) {
return;
}
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
};
/** Insert markdown snippet at the textarea cursor position. */
@@ -249,6 +422,152 @@ const insertAtCursor = (snippet) => {
});
};
const detectTaskMention = async () => {
const content = editingNote.value.content || "";
const match = content.match(/@task\s+([^\n]{1,40})$/i);
if (!match || !props.spaceId) {
showTaskMention.value = false;
taskMentionResults.value = [];
taskMentionQuery.value = "";
return;
}
const query = match[1].trim();
taskMentionQuery.value = query;
if (!query) {
showTaskMention.value = false;
taskMentionResults.value = [];
return;
}
try {
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
showTaskMention.value = taskMentionResults.value.length > 0;
} catch {
taskMentionResults.value = [];
showTaskMention.value = false;
}
};
const replaceTaskMentionText = (title) => {
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
};
const selectMentionTask = async (task) => {
replaceTaskMentionText(task.title);
showTaskMention.value = false;
taskMentionResults.value = [];
if (!props.spaceId || !editingNote.value.id) {
return;
}
try {
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
alert("Unable to link task to this note.");
}
autoSave();
};
const insertTaskMention = (task) => {
if (!task?.title) {
return;
}
insertAtCursor(`@task(${task.title})`);
};
const refreshTaskPicker = async () => {
if (!props.spaceId) {
return;
}
taskPickerLoading.value = true;
try {
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
} finally {
taskPickerLoading.value = false;
}
};
const toggleTaskPicker = async () => {
showTaskPicker.value = !showTaskPicker.value;
if (showTaskPicker.value && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
};
/**
* Parse all @task(Title) mentions in content and ensure each is linked.
* Called after every real save so new mentions are linked automatically.
*/
const syncTaskMentionLinks = async (content) => {
if (!props.spaceId || !editingNote.value.id) {
return;
}
const mentionTitles = new Set();
const rx = /@task\(([^)]+)\)/gi;
let m;
while ((m = rx.exec(content)) !== null) {
const title = (m[1] || "").trim();
if (title) {
mentionTitles.add(title.toLowerCase());
}
}
if (!mentionTitles.size) {
return;
}
let current;
try {
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
return;
}
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
if (!toLink.length) {
linkedTasks.value = current;
return;
}
await Promise.all(
toLink.map(async (title) => {
try {
const results = await spaceStore.searchTasks(props.spaceId, title);
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
if (exact) {
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
}
} catch {
// best-effort — skip silently
}
}),
);
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
// ignore
}
};
const onPreviewClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
onBeforeUnmount(() => {
clearTimeout(saveTimeout.value);
clearTimeout(saveStateTimeout.value);
@@ -257,120 +576,17 @@ onBeforeUnmount(() => {
onMounted(async () => {
await settingsStore.loadFeatureFlags();
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
if (props.spaceId && editingNote.value?.id) {
try {
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
} catch {
linkedTasks.value = [];
}
}
if (props.spaceId && !spaceStore.tasks.length) {
await refreshTaskPicker();
}
});
</script>
<style scoped>
.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;
}
/* 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;
}
</style>
<style scoped src="../assets/styles/scoped/components/NoteEditor.css"></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

@@ -24,7 +24,7 @@
</div>
</header>
<div class="markdown-body" v-html="renderedMarkdown"></div>
<div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
</article>
</template>
@@ -46,13 +46,61 @@ const props = defineProps({
type: String,
default: "",
},
linkedTasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["open-linked-task"]);
const renderedMarkdown = computed(() => {
const html = renderMarkdown(props.note.content || "");
const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
return DOMPurify.sanitize(html);
});
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
const taskByTitle = computed(() => {
const map = new Map();
for (const task of props.linkedTasks || []) {
const key = normalizeTaskTitle(task.title);
if (!key || map.has(key)) {
continue;
}
map.set(key, task);
}
return map;
});
const enrichTaskMentions = (content) => {
if (!content) {
return "";
}
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
const title = (rawTitle || "").trim();
if (!title) {
return full;
}
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
if (!linkedTask?.id) {
return full;
}
const statusName = (linkedTask.status_name || "Unknown").trim();
const safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const safeStatusName = statusName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
const statusColor = (linkedTask.status_color || "").trim();
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
});
};
const categoryLabel = computed(() => {
const categoryId = props.note.category_id;
if (!categoryId) {
@@ -79,144 +127,29 @@ const categoryLabel = computed(() => {
});
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
const onMarkdownClick = (event) => {
const anchor = event.target?.closest?.("a");
if (!anchor) {
return;
}
const href = anchor.getAttribute("href") || "";
if (!href.startsWith("#task:")) {
return;
}
event.preventDefault();
const taskId = href.slice("#task:".length);
if (!taskId) {
return;
}
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
if (matchedTask) {
emit("open-linked-task", matchedTask);
}
};
</script>
<style scoped>
.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(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;
}
</style>
<style scoped src="../assets/styles/scoped/components/NoteViewer.css"></style>

View File

@@ -3,23 +3,23 @@
<header class="search-results-header">
<h2>Search Results</h2>
<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>
<div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<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 v-else-if="totalResults === 0" class="empty-state">
<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>
</div>
<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">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
@@ -32,14 +32,14 @@
<script setup>
import { computed } from "vue";
import NoteList from "./NoteList.vue";
import WorkspaceList from "./WorkspaceList.vue";
const props = defineProps({
query: {
type: String,
default: "",
},
notes: {
items: {
type: Array,
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 normalizedPage = computed(() => {
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
return Math.min(props.currentPage, totalPages.value);
});
const paginatedNotes = computed(() => {
const paginatedItems = computed(() => {
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) => {
@@ -82,103 +82,4 @@ const goToPage = (page) => {
};
</script>
<style scoped>
.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>
<style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>

View File

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

View File

@@ -0,0 +1,298 @@
<template>
<section class="task-board">
<div class="task-board-header">
<div class="task-title-wrap">
<h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div>
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
</div>
<div class="task-filters">
<select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id">
{{ status.name }}
</option>
</select>
<select v-model="filterParent" class="form-select" @change="emitFilters">
<option value="">Any parent</option>
<option value="root">Top-level only</option>
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
{{ task.title }}
</option>
</select>
</div>
<div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
<div class="status-group-title-wrap">
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
<span class="status-group-title">{{ section.status.name }}</span>
</div>
<span class="status-group-count">{{ section.parentTasks.length }}</span>
</header>
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', parentTask)"
@keydown.enter="emit('select-task', parentTask)"
@keydown.space.prevent="emit('select-task', parentTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ parentTask.title }}</strong>
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: parentTask.status_id === status.id }"
@click="onTaskStatusChange(parentTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(parentTask.id)">
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', childTask)"
@keydown.enter="emit('select-task', childTask)"
@keydown.space.prevent="emit('select-task', childTask)"
>
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</span>
<span class="task-main">
<strong>{{ childTask.title }}</strong>
<small class="text-muted">{{ childTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: childTask.status_id === status.id }"
@click="onTaskStatusChange(childTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div v-if="isExpanded(childTask.id)">
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
<div
class="task-row"
role="button"
tabindex="0"
@click="emit('select-task', grandchildTask)"
@keydown.enter="emit('select-task', grandchildTask)"
@keydown.space.prevent="emit('select-task', grandchildTask)"
>
<span class="tree-toggle"></span>
<span class="task-main">
<strong>{{ grandchildTask.title }}</strong>
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
</span>
<div class="task-status-menu" @click.stop>
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
</button>
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-option"
:class="{ selected: grandchildTask.status_id === status.id }"
@click="onTaskStatusChange(grandchildTask, status.id)"
>
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
<span class="status-option-label">{{ status.name }}</span>
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
tasks: {
type: Array,
default: () => [],
},
statuses: {
type: Array,
default: () => [],
},
selectedTaskList: {
type: Object,
default: null,
},
});
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
const filterStatus = ref("");
const filterParent = ref("");
const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref("");
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => {
const map = new Map();
for (const task of props.tasks) {
map.set(task.id, task);
}
return map;
});
const tasksByParentId = computed(() => {
const map = new Map();
for (const task of props.tasks) {
if (!task.parent_task_id) {
continue;
}
const existing = map.get(task.parent_task_id) || [];
existing.push(task);
map.set(task.parent_task_id, existing);
}
for (const [key, children] of map) {
map.set(
key,
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
);
}
return map;
});
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
const statusSections = computed(() =>
props.statuses.map((status) => ({
status,
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
})),
);
const emitFilters = () => {
emit("filter-change", {
statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null,
});
};
const statusHeaderStyle = (status) => {
const color = status.color || "#7c8596";
return {
borderColor: color,
};
};
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
const statusDotStyle = (statusId) => ({
backgroundColor: statusColor(statusId),
});
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
const toggleStatusMenu = (taskId) => {
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
};
const closeStatusMenu = () => {
openStatusMenuTaskId.value = "";
};
const onDocumentClick = () => {
closeStatusMenu();
};
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
const hasChildren = (task) => childrenFor(task.id).length > 0;
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
const toggleExpanded = (task) => {
if (!hasChildren(task)) {
return;
}
expandedTaskIds.value = {
...expandedTaskIds.value,
[task.id]: !expandedTaskIds.value[task.id],
};
};
const onTaskStatusChange = (task, statusId) => {
if (!task?.id || !statusId || task.status_id === statusId) {
closeStatusMenu();
return;
}
emit("update-task-status", {
taskId: task.id,
currentStatusId: task.status_id,
targetStatusId: statusId,
});
closeStatusMenu();
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
</script>
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>

View File

@@ -0,0 +1,137 @@
<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-xl modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 col-lg-7">
<label class="form-label">Title</label>
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
<label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option>
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
</select>
</div>
<div class="col-12 col-lg-5">
<label class="form-label">Status</label>
<select v-model="localTask.status_id" class="form-select">
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
</select>
<div class="status-progress mt-3">
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
<span>{{ status.name }}</span>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
</div>
<div class="mt-4">
<h6>Subtasks</h6>
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
<span>{{ subtask.title }}</span>
<small>L{{ subtask.depth + 1 }}</small>
</button>
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show"></div>
</teleport>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
task: {
type: Object,
default: () => ({}),
},
statuses: {
type: Array,
default: () => [],
},
parentTaskOptions: {
type: Array,
default: () => [],
},
subtasks: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
const localTask = ref({});
watch(
() => props.task,
(value) => {
localTask.value = {
title: "",
description: "",
task_list_id: "",
status_id: props.statuses[0]?.id || "",
parent_task_id: "",
note_links: [],
...value,
task_list_id: value?.task_list_id || "",
parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [],
};
},
{ immediate: true },
);
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
const isReached = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return status.order <= current;
};
const stepClass = (status) => {
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
return {
current: status.order === current,
done: status.order < current,
};
};
const saveTask = () => {
emit("save-task", {
...localTask.value,
task_list_id: localTask.value.task_list_id || null,
parent_task_id: localTask.value.parent_task_id || null,
});
};
</script>
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></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 "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App);

View File

@@ -73,7 +73,7 @@
<div class="user-row-actions">
<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-danger" @click="deleteUser(u)">Delete</button>
<button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
</div>
</div>
</div>
@@ -105,7 +105,7 @@
</div>
<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-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>
@@ -286,7 +286,16 @@
:deleting="deletingProviderModal"
@close="closeProviderModal"
@submit="submitProviderModal"
@delete="deleteProviderFromModal"
@delete="requestDeleteProvider"
/>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template>
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
import AdminGroupModal from "../components/AdminGroupModal.vue";
import AdminUserModal from "../components/AdminUserModal.vue";
import AdminProviderModal from "../components/AdminProviderModal.vue";
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
const router = useRouter();
const activeTab = ref("users");
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
const selectedProvider = ref(null);
const submittingProviderModal = 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 savingFeatureFlags = ref(false);
@@ -365,6 +381,47 @@ const clearMessages = () => {
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) => {
if (!iso) return "";
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) => {
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
if (!user?.id) {
return;
}
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
if (group.is_system) {
return;
}
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
if (!group?.id) {
return;
}
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
await Promise.all([loadGroups(), loadUsers()]);
} catch (e) {
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 () => {
loadingSpaces.value = true;
clearMessages();
@@ -631,12 +713,21 @@ const loadProviders = async () => {
}
};
const deleteProviderFromModal = async (provider) => {
const requestDeleteProvider = (provider) => {
if (!provider?.id) {
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;
}
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
closeProviderModal();
} catch (e) {
error.value = e.response?.data || "Failed to delete provider.";
throw e;
} finally {
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 () => {
loadingFeatureFlags.value = true;
clearMessages();
@@ -718,237 +840,4 @@ onMounted(async () => {
});
</script>
<style scoped>
.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>
<style scoped src="../assets/styles/scoped/pages/Admin.css"></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>
<style scoped>
.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%);
}
<style scoped src="../assets/styles/scoped/pages/Login.css"></style>
.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>
<style scoped>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
<style scoped src="../assets/styles/scoped/pages/PublicSpace.css"></style>
.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>
<style scoped>
.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%);
}
<style scoped src="../assets/styles/scoped/pages/Register.css"></style>
.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: "/",
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 },
},
{
path: "/search",
name: "Search",
component: () => import("../pages/Home.vue"),
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{

View File

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

View File

@@ -13,9 +13,13 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false);
const categories = ref([]);
const categoryTree = ref([]);
const taskLists = ref([]);
const tasks = ref([]);
const taskStatuses = ref([]);
const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
};
const fetchSpaces = async () => {
@@ -208,6 +212,163 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = [];
};
const fetchTaskStatuses = async (spaceId, taskListId) => {
if (!spaceId || !taskListId) {
taskStatuses.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
taskStatuses.value = response.data || [];
return taskStatuses.value;
} catch (error) {
console.error("Error fetching task statuses:", error);
taskStatuses.value = [];
return [];
}
};
const fetchTaskLists = async (spaceId) => {
if (!spaceId) {
taskLists.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
taskLists.value = response.data || [];
return taskLists.value;
} catch (error) {
console.error("Error fetching task lists:", error);
taskLists.value = [];
return [];
}
};
const createTaskList = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const updateTaskList = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
await fetchTaskLists(spaceId);
return response.data;
};
const deleteTaskList = async (spaceId, taskListId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
await fetchTaskLists(spaceId);
};
const createTaskStatus = async (spaceId, taskListId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
await fetchTaskStatuses(spaceId, taskListId);
return response.data;
};
const updateTaskStatus = async (spaceId, 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,
});
taskStatuses.value = response.data || [];
return taskStatuses.value;
};
const fetchTasks = async (spaceId, filters = {}) => {
if (!spaceId) {
tasks.value = [];
return [];
}
const params = {};
if (filters.taskListId) {
params.taskListId = filters.taskListId;
}
if (filters.statusId) {
params.statusId = filters.statusId;
}
if (typeof filters.parentTaskId === "string") {
params.parentTaskId = filters.parentTaskId;
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params });
tasks.value = response.data || [];
return tasks.value;
} catch (error) {
console.error("Error fetching tasks:", error);
tasks.value = [];
return [];
}
};
const searchTasks = async (spaceId, query) => {
if (!spaceId || !query?.trim()) {
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/search`, { params: { q: query } });
return response.data || [];
};
const getTask = async (spaceId, taskId) => {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
return response.data;
};
const createTask = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks`, payload);
await fetchTasks(spaceId);
return response.data;
};
const updateTask = async (spaceId, taskId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/tasks/${taskId}`, payload);
await fetchTasks(spaceId);
return response.data;
};
const deleteTask = async (spaceId, taskId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
await fetchTasks(spaceId);
};
const transitionTask = async (spaceId, taskId, direction) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/transition`, { direction });
await fetchTasks(spaceId);
return response.data;
};
const fetchTasksForNote = async (spaceId, noteId) => {
if (!spaceId || !noteId) {
noteLinkedTasks.value = [];
return [];
}
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}/tasks`);
noteLinkedTasks.value = response.data || [];
return noteLinkedTasks.value;
};
const linkTaskToNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes`, { note_id: noteId });
return response.data;
};
const unlinkTaskFromNote = async (spaceId, taskId, noteId) => {
const response = await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes/${noteId}`);
return response.data;
};
return {
spaces,
currentSpace,
@@ -217,6 +378,10 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading,
categories,
categoryTree,
taskLists,
tasks,
taskStatuses,
noteLinkedTasks,
fetchSpaces,
selectSpace,
fetchNotes,
@@ -232,5 +397,24 @@ export const useSpaceStore = defineStore("space", () => {
deleteNote,
searchNotes,
clearSearchResults,
fetchTaskStatuses,
fetchTaskLists,
createTaskList,
updateTaskList,
deleteTaskList,
createTaskStatus,
updateTaskStatus,
deleteTaskStatus,
reorderTaskStatuses,
fetchTasks,
searchTasks,
getTask,
createTask,
updateTask,
deleteTask,
transitionTask,
fetchTasksForNote,
linkTaskToNote,
unlinkTaskFromNote,
};
});