9 Commits

Author SHA1 Message Date
domrichardson ead8219f3b new frontend
Build and Push App Image / build-and-push (push) Successful in 3m38s
2026-06-17 12:08:20 +01:00
domrichardson b690b00016 feat: updated task list index
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
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
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
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
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
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
112 changed files with 16555 additions and 4324 deletions
-2
View File
@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
FRONTEND_URL="http://localhost" FRONTEND_URL="http://localhost"
VITE_API_BASE_URL="http://localhost"
# Default Admin # Default Admin
DEFAULT_ADMIN_EMAIL=admin@notely.local DEFAULT_ADMIN_EMAIL=admin@notely.local
DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_USERNAME=admin
-2
View File
@@ -46,8 +46,6 @@ jobs:
context: . context: .
file: ./devops/docker/Dockerfile file: ./devops/docker/Dockerfile
push: true push: true
build-args: |
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
tags: | tags: |
${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }} ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}
+4
View File
@@ -43,3 +43,7 @@ secret*
*.a *.a
*.so *.so
.go/ .go/
frontend_new/out
backend/public
frontend_new/.next
+1 -4
View File
@@ -21,7 +21,6 @@ Required or commonly used:
- `JWT_SECRET` - `JWT_SECRET`
- `ENCRYPTION_KEY` - `ENCRYPTION_KEY`
- `FRONTEND_URL` - `FRONTEND_URL`
- `VITE_API_BASE_URL`
- `DEFAULT_ADMIN_EMAIL` - `DEFAULT_ADMIN_EMAIL`
- `DEFAULT_ADMIN_USERNAME` - `DEFAULT_ADMIN_USERNAME`
- `DEFAULT_ADMIN_PASSWORD` - `DEFAULT_ADMIN_PASSWORD`
@@ -41,7 +40,6 @@ Optional backend runtime values that Docker Compose will also pass through if pr
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin` - MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
- Backend port: `8080` - Backend port: `8080`
- Public frontend URL: `http://localhost` - Public frontend URL: `http://localhost`
- Browser API base URL for container builds: `http://localhost`
## 2. `backend/.env` ## 2. `backend/.env`
@@ -107,13 +105,12 @@ cp .env.example .env
### Frontend Variables In `frontend/.env.example` ### Frontend Variables In `frontend/.env.example`
- `VITE_API_BASE_URL`
- `VITE_ENV` - `VITE_ENV`
- `VITE_ENABLE_ANALYTICS` - `VITE_ENABLE_ANALYTICS`
### Variables Currently Relevant To The Frontend App ### Variables Currently Relevant To The Frontend App
- `VITE_API_BASE_URL`: used by the API client - API requests are sent to the current browser origin (same-origin runtime behavior)
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them. The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
+1 -1
View File
@@ -133,7 +133,7 @@ Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis
Check: Check:
- backend is running on port `8080` - backend is running on port `8080`
- frontend `VITE_API_BASE_URL` - frontend and API are reachable through the same host/origin
- Vite proxy settings in `frontend/vite.config.js` - Vite proxy settings in `frontend/vite.config.js`
### OAuth callback redirects to the wrong URL ### OAuth callback redirects to the wrong URL
+122 -39
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -135,6 +136,7 @@ func main() {
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.UserRepo, db.UserRepo,
permissionService, permissionService,
) )
@@ -151,6 +153,7 @@ func main() {
categoryService := services.NewCategoryService( categoryService := services.NewCategoryService(
db.CategoryRepo, db.CategoryRepo,
db.TaskListRepo,
db.MembershipRepo, db.MembershipRepo,
db.NoteRepo, db.NoteRepo,
permissionService, permissionService,
@@ -158,6 +161,7 @@ func main() {
taskService := services.NewTaskService( taskService := services.NewTaskService(
db.TaskRepo, db.TaskRepo,
db.TaskListRepo,
db.TaskStatusRepo, db.TaskStatusRepo,
db.NoteRepo, db.NoteRepo,
db.CategoryRepo, db.CategoryRepo,
@@ -269,6 +273,11 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
// Task endpoints // Task endpoints
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST") api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET") api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
@@ -280,12 +289,12 @@ func main() {
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET") api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
// Task status endpoints // Task status endpoints (scoped to task list)
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.ListStatuses).Methods("GET") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
api.HandleFunc("/spaces/{spaceId}/task-statuses", taskHandler.CreateStatus).Methods("POST") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
api.HandleFunc("/spaces/{spaceId}/task-statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
api.HandleFunc("/spaces/{spaceId}/task-statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
// File explorer endpoints (space-scoped) // File explorer endpoints (space-scoped)
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET") api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
@@ -350,39 +359,9 @@ func main() {
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT") admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE") admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
// Serve static files (frontend) for all other routes // Serve static files (NextJS frontend) for all other routes.
// This must be after all API route handlers to allow API routes to take precedence // Must come after all API route handlers.
router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.PathPrefix("/").HandlerFunc(serveNextJS)
// List of static file extensions to serve directly
staticExts := map[string]bool{
".js": true, ".css": true, ".svg": true, ".png": true,
".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
}
filePath := "./public" + r.URL.Path
if r.URL.Path == "/" {
filePath = "./public/index.html"
}
// Check if it's a static file (has an extension in staticExts)
isStatic := false
for ext := range staticExts {
if len(r.URL.Path) > len(ext) {
if r.URL.Path[len(r.URL.Path)-len(ext):] == ext {
isStatic = true
break
}
}
}
// If it doesn't look like a static file, serve index.html (SPA routing)
if !isStatic {
filePath = "./public/index.html"
}
http.ServeFile(w, r, filePath)
})
// Start server // Start server
server := &http.Server{ server := &http.Server{
@@ -521,3 +500,107 @@ func ensureDefaultAdminUser(
log.Printf("default admin user synchronized from environment: %s", adminEmail) log.Printf("default admin user synchronized from environment: %s", adminEmail)
return nil return nil
} }
// serveNextJS serves the NextJS static export from ./public.
// It handles /_next/ assets directly, tries {path}/index.html for page routes,
// and falls back to dynamic route pattern matching (NextJS [param] folders).
func serveNextJS(w http.ResponseWriter, r *http.Request) {
const publicDir = "./public"
urlPath := r.URL.Path
// ── /_next/ and other static assets ─────────────────────────────────────
if strings.HasPrefix(urlPath, "/_next/") {
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
return
}
staticExts := []string{
".js", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico",
".woff", ".woff2", ".ttf", ".eot", ".json", ".map", ".txt", ".xml",
}
for _, ext := range staticExts {
if strings.HasSuffix(urlPath, ext) {
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
return
}
}
// ── HTML page resolution ─────────────────────────────────────────────────
// NextJS static export with trailingSlash:true produces {path}/index.html
cleanPath := strings.TrimRight(urlPath, "/")
if cleanPath == "" {
cleanPath = "/"
}
// 1. Try exact {path}/index.html
candidate := filepath.Join(publicDir, filepath.FromSlash(cleanPath), "index.html")
if _, err := os.Stat(candidate); err == nil {
http.ServeFile(w, r, candidate)
return
}
// 2. Try {path}.html
candidate = filepath.Join(publicDir, filepath.FromSlash(cleanPath)+".html")
if _, err := os.Stat(candidate); err == nil {
http.ServeFile(w, r, candidate)
return
}
// 3. Walk path segments and try NextJS dynamic [param] folders
segments := strings.Split(strings.Trim(cleanPath, "/"), "/")
if result := findNextJSPage(publicDir, segments); result != "" {
http.ServeFile(w, r, result)
return
}
// 4. Fallback: root index.html
http.ServeFile(w, r, filepath.Join(publicDir, "index.html"))
}
// findNextJSPage recursively searches the public directory for an HTML file
// that matches the given URL segments, replacing unknown segments with any
// NextJS dynamic directory ([param] named folders).
func findNextJSPage(publicDir string, segments []string) string {
return searchNextJSSegments(publicDir, segments, 0, publicDir)
}
func searchNextJSSegments(publicDir string, segments []string, idx int, currentDir string) string {
if idx == len(segments) {
candidate := filepath.Join(currentDir, "index.html")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
return ""
}
seg := segments[idx]
// Try the exact segment directory
exactDir := filepath.Join(currentDir, seg)
if result := searchNextJSSegments(publicDir, segments, idx+1, exactDir); result != "" {
return result
}
// Try NextJS dynamic segment directories.
// Matches both [param] (source convention) and __param__ (static export convention).
entries, err := os.ReadDir(currentDir)
if err != nil {
return ""
}
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() {
continue
}
isBracket := len(name) > 2 && name[0] == '[' && name[len(name)-1] == ']'
isPlaceholder := len(name) >= 4 && strings.HasPrefix(name, "__") && strings.HasSuffix(name, "__")
if isBracket || isPlaceholder {
dynamicDir := filepath.Join(currentDir, name)
if result := searchNextJSSegments(publicDir, segments, idx+1, dynamicDir); result != "" {
return result
}
}
}
return ""
}
+55 -12
View File
@@ -430,6 +430,7 @@ type CategoryTreeDTO struct {
*CategoryDTO *CategoryDTO
Subcategories []*CategoryTreeDTO `json:"subcategories"` Subcategories []*CategoryTreeDTO `json:"subcategories"`
Notes []*NoteListItemDTO `json:"notes"` Notes []*NoteListItemDTO `json:"notes"`
TaskLists []*TaskListDTO `json:"task_lists"`
} }
// NewCategoryDTO creates a DTO from a category entity // NewCategoryDTO creates a DTO from a category entity
@@ -458,7 +459,7 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
type CreateTaskRequest struct { type CreateTaskRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"` Title string `json:"title" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=2000"` Description string `json:"description" validate:"max=2000"`
CategoryID *string `json:"category_id,omitempty"` TaskListID string `json:"task_list_id" validate:"required"`
StatusID string `json:"status_id" validate:"required"` StatusID string `json:"status_id" validate:"required"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links"` NoteLinks []string `json:"note_links"`
@@ -468,7 +469,7 @@ type CreateTaskRequest struct {
type UpdateTaskRequest struct { type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"` TaskListID *string `json:"task_list_id,omitempty"`
StatusID *string `json:"status_id,omitempty"` StatusID *string `json:"status_id,omitempty"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
NoteLinks []string `json:"note_links,omitempty"` NoteLinks []string `json:"note_links,omitempty"`
@@ -490,7 +491,7 @@ type TaskDTO struct {
SpaceID string `json:"space_id"` SpaceID string `json:"space_id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
CategoryID *string `json:"category_id,omitempty"` TaskListID string `json:"task_list_id"`
StatusID string `json:"status_id"` StatusID string `json:"status_id"`
ParentTaskID *string `json:"parent_task_id,omitempty"` ParentTaskID *string `json:"parent_task_id,omitempty"`
Depth int `json:"depth"` Depth int `json:"depth"`
@@ -530,7 +531,7 @@ type ReorderTaskStatusesRequest struct {
// TaskStatusDTO represents a task status in API responses. // TaskStatusDTO represents a task status in API responses.
type TaskStatusDTO struct { type TaskStatusDTO struct {
ID string `json:"id"` ID string `json:"id"`
SpaceID string `json:"space_id"` TaskListID string `json:"task_list_id"`
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Order int `json:"order"` Order int `json:"order"`
@@ -538,14 +539,35 @@ type TaskStatusDTO struct {
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// CreateTaskListRequest represents task list creation input.
type CreateTaskListRequest struct {
Name string `json:"name" validate:"required,min=1,max=120"`
Description string `json:"description" validate:"max=500"`
CategoryID *string `json:"category_id,omitempty"`
}
// UpdateTaskListRequest represents task list update input.
type UpdateTaskListRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
}
// TaskListDTO represents a task list in API responses.
type TaskListDTO struct {
ID string `json:"id"`
SpaceID string `json:"space_id"`
CategoryID *string `json:"category_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// NewTaskDTO creates a DTO from a task entity. // NewTaskDTO creates a DTO from a task entity.
func NewTaskDTO(task *entities.Task) *TaskDTO { func NewTaskDTO(task *entities.Task) *TaskDTO {
var categoryID *string
if task.CategoryID != nil {
id := task.CategoryID.Hex()
categoryID = &id
}
var parentTaskID *string var parentTaskID *string
if task.ParentTaskID != nil { if task.ParentTaskID != nil {
id := task.ParentTaskID.Hex() id := task.ParentTaskID.Hex()
@@ -562,7 +584,7 @@ func NewTaskDTO(task *entities.Task) *TaskDTO {
SpaceID: task.SpaceID.Hex(), SpaceID: task.SpaceID.Hex(),
Title: task.Title, Title: task.Title,
Description: task.Description, Description: task.Description,
CategoryID: categoryID, TaskListID: task.TaskListID.Hex(),
StatusID: task.StatusID.Hex(), StatusID: task.StatusID.Hex(),
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: task.Depth, Depth: task.Depth,
@@ -574,11 +596,32 @@ func NewTaskDTO(task *entities.Task) *TaskDTO {
} }
} }
// NewTaskListDTO creates a DTO from a task list entity.
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
var categoryID *string
if taskList.CategoryID != nil {
id := taskList.CategoryID.Hex()
categoryID = &id
}
return &TaskListDTO{
ID: taskList.ID.Hex(),
SpaceID: taskList.SpaceID.Hex(),
CategoryID: categoryID,
Name: taskList.Name,
Description: taskList.Description,
CreatedBy: taskList.CreatedBy.Hex(),
UpdatedBy: taskList.UpdatedBy.Hex(),
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// NewTaskStatusDTO creates a DTO from a task status entity. // NewTaskStatusDTO creates a DTO from a task status entity.
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO { func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
return &TaskStatusDTO{ return &TaskStatusDTO{
ID: status.ID.Hex(), ID: status.ID.Hex(),
SpaceID: status.SpaceID.Hex(), TaskListID: status.TaskListID.Hex(),
Name: status.Name, Name: status.Name,
Color: status.Color, Color: status.Color,
Order: status.Order, Order: status.Order,
@@ -15,6 +15,7 @@ import (
// CategoryService handles category operations // CategoryService handles category operations
type CategoryService struct { type CategoryService struct {
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
permissionService *PermissionService permissionService *PermissionService
@@ -23,12 +24,14 @@ type CategoryService struct {
// NewCategoryService creates a new category service // NewCategoryService creates a new category service
func NewCategoryService( func NewCategoryService(
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *CategoryService { ) *CategoryService {
return &CategoryService{ return &CategoryService{
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
permissionService: permissionService, permissionService: permissionService,
@@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit
} }
} }
// Get task lists in this category
taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, taskList := range taskLists {
tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList))
}
}
return tree, nil return tree, nil
} }
@@ -17,6 +17,7 @@ type SpaceService struct {
membershipRepo repositories.MembershipRepository membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
taskListRepo repositories.TaskListRepository
userRepo repositories.UserRepository userRepo repositories.UserRepository
permissionService *PermissionService permissionService *PermissionService
} }
@@ -27,6 +28,7 @@ func NewSpaceService(
membershipRepo repositories.MembershipRepository, membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
taskListRepo repositories.TaskListRepository,
userRepo repositories.UserRepository, userRepo repositories.UserRepository,
permissionService *PermissionService, permissionService *PermissionService,
) *SpaceService { ) *SpaceService {
@@ -35,6 +37,7 @@ func NewSpaceService(
membershipRepo: membershipRepo, membershipRepo: membershipRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
taskListRepo: taskListRepo,
userRepo: userRepo, userRepo: userRepo,
permissionService: permissionService, permissionService: permissionService,
} }
@@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err return err
} }
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err return err
} }
@@ -17,6 +17,7 @@ import (
// TaskService handles task and task status operations. // TaskService handles task and task status operations.
type TaskService struct { type TaskService struct {
taskRepo repositories.TaskRepository taskRepo repositories.TaskRepository
taskListRepo repositories.TaskListRepository
taskStatusRepo repositories.TaskStatusRepository taskStatusRepo repositories.TaskStatusRepository
noteRepo repositories.NoteRepository noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository categoryRepo repositories.CategoryRepository
@@ -27,6 +28,7 @@ type TaskService struct {
// NewTaskService creates a task service. // NewTaskService creates a task service.
func NewTaskService( func NewTaskService(
taskRepo repositories.TaskRepository, taskRepo repositories.TaskRepository,
taskListRepo repositories.TaskListRepository,
taskStatusRepo repositories.TaskStatusRepository, taskStatusRepo repositories.TaskStatusRepository,
noteRepo repositories.NoteRepository, noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository, categoryRepo repositories.CategoryRepository,
@@ -35,6 +37,7 @@ func NewTaskService(
) *TaskService { ) *TaskService {
return &TaskService{ return &TaskService{
taskRepo: taskRepo, taskRepo: taskRepo,
taskListRepo: taskListRepo,
taskStatusRepo: taskStatusRepo, taskStatusRepo: taskStatusRepo,
noteRepo: noteRepo, noteRepo: noteRepo,
categoryRepo: categoryRepo, categoryRepo: categoryRepo,
@@ -43,8 +46,8 @@ func NewTaskService(
} }
} }
func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) ensureDefaultStatuses(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -63,7 +66,7 @@ func (s *TaskService) ensureDefaultStatuses(ctx context.Context, spaceID bson.Ob
for idx, status := range defaults { for idx, status := range defaults {
if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{ if err := s.taskStatusRepo.CreateStatus(ctx, &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: status.name, Name: status.name,
Color: status.color, Color: status.color,
Order: idx, Order: idx,
@@ -121,13 +124,10 @@ func toObjectIDs(hexIDs []string) ([]bson.ObjectID, error) {
return result, nil return result, nil
} }
func (s *TaskService) validateCategory(ctx context.Context, spaceID bson.ObjectID, categoryID *bson.ObjectID) error { func (s *TaskService) validateTaskList(ctx context.Context, spaceID, taskListID bson.ObjectID) error {
if categoryID == nil { taskList, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
return nil if err != nil || taskList.SpaceID != spaceID {
} return errors.New("invalid task list")
category, err := s.categoryRepo.GetCategoryByID(ctx, *categoryID)
if err != nil || category.SpaceID != spaceID {
return errors.New("invalid category")
} }
return nil return nil
} }
@@ -142,9 +142,9 @@ func (s *TaskService) validateNoteLinks(ctx context.Context, spaceID bson.Object
return nil return nil
} }
func (s *TaskService) validateStatus(ctx context.Context, spaceID, statusID bson.ObjectID) (*entities.TaskStatus, error) { func (s *TaskService) validateStatus(ctx context.Context, taskListID, statusID bson.ObjectID) (*entities.TaskStatus, error) {
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
return status, nil return status, nil
@@ -165,11 +165,11 @@ func (s *TaskService) resolveDepthAndParent(ctx context.Context, spaceID bson.Ob
return depth, nil return depth, nil
} }
func (s *TaskService) isAdjacentStatusMove(ctx context.Context, spaceID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) { func (s *TaskService) isAdjacentStatusMove(ctx context.Context, taskListID, currentStatusID, requestedStatusID bson.ObjectID) (bool, error) {
if currentStatusID == requestedStatusID { if currentStatusID == requestedStatusID {
return true, nil return true, nil
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -205,18 +205,6 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
categoryID, err := toObjectIDPtr(req.CategoryID)
if err != nil {
return nil, errors.New("invalid category")
}
if err := s.validateCategory(ctx, spaceID, categoryID); err != nil {
return nil, err
}
parentTaskID, err := toObjectIDPtr(req.ParentTaskID) parentTaskID, err := toObjectIDPtr(req.ParentTaskID)
if err != nil { if err != nil {
return nil, errors.New("invalid parent task") return nil, errors.New("invalid parent task")
@@ -226,6 +214,18 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
taskListID, err := bson.ObjectIDFromHex(strings.TrimSpace(req.TaskListID))
if err != nil {
return nil, errors.New("invalid task list")
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
noteLinks, err := toObjectIDs(req.NoteLinks) noteLinks, err := toObjectIDs(req.NoteLinks)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -234,7 +234,7 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -252,17 +252,25 @@ func (s *TaskService) CreateTask(ctx context.Context, spaceID, userID bson.Objec
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid task status") return nil, errors.New("invalid task status")
} }
if _, validateErr := s.validateStatus(ctx, spaceID, parsedStatusID); validateErr != nil { if _, validateErr := s.validateStatus(ctx, taskListID, parsedStatusID); validateErr != nil {
return nil, validateErr return nil, validateErr
} }
statusID = parsedStatusID statusID = parsedStatusID
} }
if parentTaskID != nil {
parent, parentErr := s.taskRepo.GetTaskByID(ctx, *parentTaskID)
if parentErr != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
taskListID = parent.TaskListID
}
task := &entities.Task{ task := &entities.Task{
SpaceID: spaceID, SpaceID: spaceID,
Title: strings.TrimSpace(req.Title), Title: strings.TrimSpace(req.Title),
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
CategoryID: categoryID, TaskListID: taskListID,
StatusID: statusID, StatusID: statusID,
ParentTaskID: parentTaskID, ParentTaskID: parentTaskID,
Depth: depth, Depth: depth,
@@ -291,7 +299,7 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
status, err := s.validateStatus(ctx, spaceID, task.StatusID) status, err := s.validateStatus(ctx, task.TaskListID, task.StatusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -318,22 +326,19 @@ func (s *TaskService) GetTaskByID(ctx context.Context, spaceID, taskID, userID b
func (s *TaskService) ListTasks( func (s *TaskService) ListTasks(
ctx context.Context, ctx context.Context,
spaceID, userID bson.ObjectID, spaceID, userID bson.ObjectID,
categoryID, statusID, parentTaskID *string, taskListID, statusID, parentTaskID *string,
) ([]*dto.TaskDTO, error) { ) ([]*dto.TaskDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil {
return nil, err
}
filters := map[string]any{} filters := map[string]any{}
if categoryID != nil && strings.TrimSpace(*categoryID) != "" { if taskListID != nil && strings.TrimSpace(*taskListID) != "" {
id, err := bson.ObjectIDFromHex(*categoryID) id, err := bson.ObjectIDFromHex(*taskListID)
if err != nil { if err != nil {
return nil, errors.New("invalid category filter") return nil, errors.New("invalid task list filter")
} }
filters["category_id"] = id filters["task_list_id"] = id
} }
if statusID != nil && strings.TrimSpace(*statusID) != "" { if statusID != nil && strings.TrimSpace(*statusID) != "" {
id, err := bson.ObjectIDFromHex(*statusID) id, err := bson.ObjectIDFromHex(*statusID)
@@ -395,23 +400,33 @@ func (s *TaskService) ListTasksLinkedToNote(ctx context.Context, spaceID, noteID
if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil { if _, err := s.noteRepo.GetNoteByID(ctx, noteID); err != nil {
return nil, errors.New("note not found") return nil, errors.New("note not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID)
if err != nil {
return nil, err
}
statusByID := map[bson.ObjectID]*entities.TaskStatus{}
for _, status := range statuses {
statusByID[status.ID] = status
}
tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID}) tasks, err := s.taskRepo.ListTasks(ctx, spaceID, map[string]any{"note_links": noteID})
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Collect statuses per task list
statusCache := map[bson.ObjectID]map[bson.ObjectID]*entities.TaskStatus{}
getStatus := func(taskListID, statusID bson.ObjectID) *entities.TaskStatus {
byID, ok := statusCache[taskListID]
if !ok {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil {
return nil
}
byID = make(map[bson.ObjectID]*entities.TaskStatus, len(statuses))
for _, st := range statuses {
byID[st.ID] = st
}
statusCache[taskListID] = byID
}
return byID[statusID]
}
result := make([]*dto.TaskWithStatusDTO, 0, len(tasks)) result := make([]*dto.TaskWithStatusDTO, 0, len(tasks))
for _, task := range tasks { for _, task := range tasks {
status := statusByID[task.StatusID] status := getStatus(task.TaskListID, task.StatusID)
if status == nil { if status == nil {
continue continue
} }
@@ -453,19 +468,21 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
task.Description = strings.TrimSpace(*req.Description) task.Description = strings.TrimSpace(*req.Description)
} }
if req.CategoryID != nil { if req.TaskListID != nil {
if strings.TrimSpace(*req.CategoryID) == "" { if strings.TrimSpace(*req.TaskListID) == "" {
task.CategoryID = nil return nil, errors.New("task list is required")
} else { }
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID) taskListID, parseErr := bson.ObjectIDFromHex(*req.TaskListID)
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid category") return nil, errors.New("invalid task list")
} }
task.CategoryID = &categoryID if task.ParentTaskID != nil {
return nil, errors.New("subtasks inherit task list from parent")
} }
if err := s.validateCategory(ctx, spaceID, task.CategoryID); err != nil { if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err return nil, err
} }
task.TaskListID = taskListID
} }
if req.ParentTaskID != nil { if req.ParentTaskID != nil {
@@ -486,6 +503,11 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
} }
task.ParentTaskID = &parentID task.ParentTaskID = &parentID
task.Depth = depth task.Depth = depth
parentTask, parentErr := s.taskRepo.GetTaskByID(ctx, parentID)
if parentErr != nil || parentTask.SpaceID != spaceID {
return nil, errors.New("invalid parent task")
}
task.TaskListID = parentTask.TaskListID
} }
} }
@@ -494,10 +516,10 @@ func (s *TaskService) UpdateTask(ctx context.Context, spaceID, taskID, userID bs
if parseErr != nil { if parseErr != nil {
return nil, errors.New("invalid status") return nil, errors.New("invalid status")
} }
if _, err := s.validateStatus(ctx, spaceID, statusID); err != nil { if _, err := s.validateStatus(ctx, task.TaskListID, statusID); err != nil {
return nil, err return nil, err
} }
adjacent, err := s.isAdjacentStatusMove(ctx, spaceID, task.StatusID, statusID) adjacent, err := s.isAdjacentStatusMove(ctx, task.TaskListID, task.StatusID, statusID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -567,7 +589,7 @@ func (s *TaskService) TransitionTaskStatus(ctx context.Context, spaceID, taskID,
return nil, errors.New("task not found") return nil, errors.New("task not found")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, task.TaskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -670,14 +692,155 @@ func (s *TaskService) UnlinkNoteFromTask(ctx context.Context, spaceID, taskID, n
return dto.NewTaskDTO(task), nil return dto.NewTaskDTO(task), nil
} }
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ListTaskLists(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
if err := s.ensureDefaultStatuses(ctx, spaceID); err != nil { lists, err := s.taskListRepo.ListTaskLists(ctx, spaceID)
if err != nil {
return nil, err return nil, err
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) result := make([]*dto.TaskListDTO, 0, len(lists))
for _, list := range lists {
result = append(result, dto.NewTaskListDTO(list))
}
return result, nil
}
func (s *TaskService) CreateTaskList(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.create")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
var categoryID *bson.ObjectID
if req.CategoryID != nil && strings.TrimSpace(*req.CategoryID) != "" {
id, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, id)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
categoryID = &id
}
list := &entities.TaskList{
SpaceID: spaceID,
CategoryID: categoryID,
Name: name,
Description: strings.TrimSpace(req.Description),
CreatedBy: userID,
UpdatedBy: userID,
}
if err := s.taskListRepo.CreateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) UpdateTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.UpdateTaskListRequest) (*dto.TaskListDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return nil, errors.New("task list not found")
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return nil, errors.New("task list name is required")
}
list.Name = name
}
if req.Description != nil {
list.Description = strings.TrimSpace(*req.Description)
}
if req.CategoryID != nil {
if strings.TrimSpace(*req.CategoryID) == "" {
list.CategoryID = nil
} else {
categoryID, parseErr := bson.ObjectIDFromHex(*req.CategoryID)
if parseErr != nil {
return nil, errors.New("invalid category")
}
category, categoryErr := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if categoryErr != nil || category.SpaceID != spaceID {
return nil, errors.New("invalid category")
}
list.CategoryID = &categoryID
}
}
list.UpdatedBy = userID
if err := s.taskListRepo.UpdateTaskList(ctx, list); err != nil {
return nil, err
}
return dto.NewTaskListDTO(list), nil
}
func (s *TaskService) DeleteTaskList(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err
}
hasPermission, err := s.hasTaskPermission(ctx, userID, spaceID, "tasks.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("insufficient permissions")
}
list, err := s.taskListRepo.GetTaskListByID(ctx, taskListID)
if err != nil || list.SpaceID != spaceID {
return errors.New("task list not found")
}
if err := s.taskRepo.DeleteTasksByTaskListID(ctx, taskListID); err != nil {
return err
}
if err := s.taskStatusRepo.DeleteStatusesByTaskListID(ctx, taskListID); err != nil {
return err
}
return s.taskListRepo.DeleteTaskList(ctx, taskListID)
}
func (s *TaskService) ListStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err
}
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
if err := s.ensureDefaultStatuses(ctx, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -688,7 +851,7 @@ func (s *TaskService) ListStatuses(ctx context.Context, spaceID, userID bson.Obj
return result, nil return result, nil
} }
func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) CreateStatus(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, req *dto.CreateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -699,13 +862,16 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
status := &entities.TaskStatus{ status := &entities.TaskStatus{
SpaceID: spaceID, TaskListID: taskListID,
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
Color: strings.TrimSpace(req.Color), Color: strings.TrimSpace(req.Color),
Order: len(statuses), Order: len(statuses),
@@ -719,7 +885,7 @@ func (s *TaskService) CreateStatus(ctx context.Context, spaceID, userID bson.Obj
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) { func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID, req *dto.UpdateTaskStatusRequest) (*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -732,7 +898,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
} }
status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID) status, err := s.taskStatusRepo.GetStatusByID(ctx, statusID)
if err != nil || status.SpaceID != spaceID { if err != nil || status.TaskListID != taskListID {
return nil, errors.New("task status not found") return nil, errors.New("task status not found")
} }
@@ -747,7 +913,7 @@ func (s *TaskService) UpdateStatus(ctx context.Context, spaceID, statusID, userI
return dto.NewTaskStatusDTO(status), nil return dto.NewTaskStatusDTO(status), nil
} }
func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userID bson.ObjectID) error { func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, taskListID, statusID, userID bson.ObjectID) error {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return err return err
} }
@@ -759,7 +925,7 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return errors.New("insufficient permissions") return errors.New("insufficient permissions")
} }
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
@@ -779,10 +945,10 @@ func (s *TaskService) DeleteStatus(ctx context.Context, spaceID, statusID, userI
return err return err
} }
return s.normalizeStatusOrder(ctx, spaceID) return s.normalizeStatusOrder(ctx, taskListID)
} }
func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) { func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, taskListID, userID bson.ObjectID, orderedStatusIDs []string) ([]*dto.TaskStatusDTO, error) {
if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil { if err := s.requireSpaceAccess(ctx, userID, spaceID); err != nil {
return nil, err return nil, err
} }
@@ -793,8 +959,11 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
if !hasPermission { if !hasPermission {
return nil, errors.New("insufficient permissions") return nil, errors.New("insufficient permissions")
} }
if err := s.validateTaskList(ctx, spaceID, taskListID); err != nil {
return nil, err
}
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -816,7 +985,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
status := statusByID[statusID] status := statusByID[statusID]
if status == nil { if status == nil {
return nil, errors.New("status id does not belong to this space") return nil, errors.New("status id does not belong to this task list")
} }
if _, exists := seen[statusID]; exists { if _, exists := seen[statusID]; exists {
return nil, errors.New("duplicate status id in ordered_status_ids") return nil, errors.New("duplicate status id in ordered_status_ids")
@@ -847,7 +1016,7 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
} }
} }
updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) updatedStatuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -859,8 +1028,8 @@ func (s *TaskService) ReorderStatuses(ctx context.Context, spaceID, userID bson.
return result, nil return result, nil
} }
func (s *TaskService) normalizeStatusOrder(ctx context.Context, spaceID bson.ObjectID) error { func (s *TaskService) normalizeStatusOrder(ctx context.Context, taskListID bson.ObjectID) error {
statuses, err := s.taskStatusRepo.ListStatuses(ctx, spaceID) statuses, err := s.taskStatusRepo.ListStatuses(ctx, taskListID)
if err != nil { if err != nil {
return err return err
} }
+16 -3
View File
@@ -14,7 +14,7 @@ type Task struct {
SpaceID bson.ObjectID `bson:"space_id"` SpaceID bson.ObjectID `bson:"space_id"`
Title string `bson:"title"` Title string `bson:"title"`
Description string `bson:"description"` Description string `bson:"description"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"` TaskListID bson.ObjectID `bson:"task_list_id"`
StatusID bson.ObjectID `bson:"status_id"` StatusID bson.ObjectID `bson:"status_id"`
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"` ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
Depth int `bson:"depth"` Depth int `bson:"depth"`
@@ -25,13 +25,26 @@ type Task struct {
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskStatus defines the ordered linear status progression for a space. // TaskStatus defines the ordered linear status progression for a task list.
type TaskStatus struct { type TaskStatus struct {
ID bson.ObjectID `bson:"_id,omitempty"` ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"` TaskListID bson.ObjectID `bson:"task_list_id"`
Name string `bson:"name"` Name string `bson:"name"`
Color string `bson:"color,omitempty"` Color string `bson:"color,omitempty"`
Order int `bson:"order"` Order int `bson:"order"`
CreatedAt time.Time `bson:"created_at"` CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
} }
// TaskList groups tasks under a named list that can be attached to a category.
type TaskList struct {
ID bson.ObjectID `bson:"_id,omitempty"`
SpaceID bson.ObjectID `bson:"space_id"`
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
Name string `bson:"name"`
Description string `bson:"description,omitempty"`
CreatedBy bson.ObjectID `bson:"created_by"`
UpdatedBy bson.ObjectID `bson:"updated_by"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}
@@ -225,15 +225,28 @@ type TaskRepository interface {
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
UpdateTask(ctx context.Context, task *entities.Task) error UpdateTask(ctx context.Context, task *entities.Task) error
DeleteTask(ctx context.Context, id bson.ObjectID) error DeleteTask(ctx context.Context, id bson.ObjectID) error
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
} }
// TaskListRepository defines task list operations.
type TaskListRepository interface {
CreateTaskList(ctx context.Context, list *entities.TaskList) error
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
}
// TaskStatusRepository defines task status operations // TaskStatusRepository defines task status operations
type TaskStatusRepository interface { type TaskStatusRepository interface {
CreateStatus(ctx context.Context, status *entities.TaskStatus) error CreateStatus(ctx context.Context, status *entities.TaskStatus) error
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
DeleteStatus(ctx context.Context, id bson.ObjectID) error DeleteStatus(ctx context.Context, id bson.ObjectID) error
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
} }
@@ -16,6 +16,7 @@ type Database struct {
MembershipRepo *MembershipRepository MembershipRepo *MembershipRepository
NoteRepo *NoteRepository NoteRepo *NoteRepository
CategoryRepo *CategoryRepository CategoryRepo *CategoryRepository
TaskListRepo *TaskListRepository
TaskRepo *TaskRepository TaskRepo *TaskRepository
TaskStatusRepo *TaskStatusRepository TaskStatusRepo *TaskStatusRepository
RevisionRepo *NoteRevisionRepository RevisionRepo *NoteRevisionRepository
@@ -49,6 +50,7 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
MembershipRepo: NewMembershipRepository(db), MembershipRepo: NewMembershipRepository(db),
NoteRepo: NewNoteRepository(db), NoteRepo: NewNoteRepository(db),
CategoryRepo: NewCategoryRepository(db), CategoryRepo: NewCategoryRepository(db),
TaskListRepo: NewTaskListRepository(db),
TaskRepo: NewTaskRepository(db), TaskRepo: NewTaskRepository(db),
TaskStatusRepo: NewTaskStatusRepository(db), TaskStatusRepo: NewTaskStatusRepository(db),
RevisionRepo: NewNoteRevisionRepository(db), RevisionRepo: NewNoteRevisionRepository(db),
@@ -87,6 +89,9 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
return err
}
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil { if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
return err return err
} }
@@ -95,6 +95,11 @@ func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error
return err return err
} }
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err return err
@@ -108,13 +113,94 @@ func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}}, {Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
}) })
return err return err
} }
// TaskListRepository implements task list data access.
type TaskListRepository struct {
collection *mongo.Collection
}
// NewTaskListRepository creates a new task list repository.
func NewTaskListRepository(db *mongo.Database) *TaskListRepository {
return &TaskListRepository{collection: db.Collection("task_lists")}
}
func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error {
list.ID = bson.NewObjectID()
list.CreatedAt = time.Now()
list.UpdatedAt = time.Now()
_, err := r.collection.InsertOne(ctx, list)
return err
}
func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) {
var list entities.TaskList
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, errors.New("task list not found")
}
return nil, err
}
return &list, nil
}
func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var lists []*entities.TaskList
if err := cursor.All(ctx, &lists); err != nil {
return nil, err
}
return lists, nil
}
func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error {
list.UpdatedAt = time.Now()
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list)
return err
}
func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
return err
}
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
})
return err
}
// TaskStatusRepository implements task status data access. // TaskStatusRepository implements task status data access.
type TaskStatusRepository struct { type TaskStatusRepository struct {
collection *mongo.Collection collection *mongo.Collection
@@ -145,8 +231,8 @@ func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.Object
return &status, nil return &status, nil
} }
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskStatus, error) { func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}})) cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -170,14 +256,19 @@ func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectI
return err return err
} }
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
return err
}
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error { func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "name", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
{ {
Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "order", Value: 1}}, Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
}) })
@@ -64,15 +64,15 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
categoryID := strings.TrimSpace(r.URL.Query().Get("categoryId")) taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
statusID := strings.TrimSpace(r.URL.Query().Get("statusId")) statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId")) parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
categoryFilter := &categoryID taskListFilter := &taskListID
statusFilter := &statusID statusFilter := &statusID
parentFilter := &parentTaskID parentFilter := &parentTaskID
if categoryID == "" { if taskListID == "" {
categoryFilter = nil taskListFilter = nil
} }
if statusID == "" { if statusID == "" {
statusFilter = nil statusFilter = nil
@@ -81,7 +81,7 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
parentFilter = nil parentFilter = nil
} }
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, categoryFilter, statusFilter, parentFilter) tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -91,6 +91,94 @@ func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(tasks) json.NewEncoder(w).Encode(tasks)
} }
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(lists)
}
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
var req dto.CreateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.UpdateTaskListRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list)
}
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r)
if err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) { func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
userID, spaceID, err := parseIDsFromRequest(r) userID, spaceID, err := parseIDsFromRequest(r)
if err != nil { if err != nil {
@@ -292,8 +380,13 @@ func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, userID) statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -309,6 +402,11 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.CreateTaskStatusRequest var req dto.CreateTaskStatusRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -316,7 +414,7 @@ func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.CreateStatus(r.Context(), spaceID, userID, &req) status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -333,6 +431,11 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
@@ -345,7 +448,7 @@ func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, statusID, userID, &req) status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -361,13 +464,18 @@ func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"]) statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
if err != nil { if err != nil {
http.Error(w, "invalid status id", http.StatusBadRequest) http.Error(w, "invalid status id", http.StatusBadRequest)
return return
} }
if err := h.taskService.DeleteStatus(r.Context(), spaceID, statusID, userID); err != nil { if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
@@ -380,6 +488,11 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest) http.Error(w, "invalid request", http.StatusBadRequest)
return return
} }
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
if err != nil {
http.Error(w, "invalid task list id", http.StatusBadRequest)
return
}
var req dto.ReorderTaskStatusesRequest var req dto.ReorderTaskStatusesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -387,7 +500,7 @@ func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
return return
} }
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, userID, req.OrderedStatusIDs) statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
+4 -7
View File
@@ -1,15 +1,12 @@
# Frontend build stage # Frontend build stage
FROM node:25-alpine AS frontend-builder FROM node:25-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend_new
ARG VITE_API_BASE_URL COPY frontend_new/package*.json ./
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
COPY frontend/package*.json ./
RUN npm install RUN npm install
COPY frontend/ . COPY frontend_new/ .
RUN npm run build RUN npm run build
# Backend build stage # Backend build stage
@@ -35,7 +32,7 @@ RUN apk --no-cache add ca-certificates
WORKDIR /root/ WORKDIR /root/
COPY --from=backend-builder /app/server . COPY --from=backend-builder /app/server .
COPY --from=frontend-builder /frontend/dist ./public COPY --from=frontend-builder /frontend_new/out ./public
EXPOSE 8080 EXPOSE 8080
-2
View File
@@ -36,8 +36,6 @@ services:
build: build:
context: . context: .
dockerfile: ./devops/docker/Dockerfile dockerfile: ./devops/docker/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
container_name: notely-app container_name: notely-app
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
-3
View File
@@ -1,8 +1,5 @@
# Frontend Environment Example # Frontend Environment Example
# API Base URL (Backend server)
VITE_API_BASE_URL=http://localhost:8080
# Environment # Environment
VITE_ENV=development VITE_ENV=development
+5 -1509
View File
File diff suppressed because it is too large Load Diff
+60 -32
View File
@@ -1,42 +1,70 @@
:root { :root {
--primary-color: #667eea; --color-primary: #667eea;
--secondary-color: #764ba2; --color-primary-strong: #4f46a5;
--text-color: #333; --color-text: #333333;
--bg-color: #f8f9fa; --color-text-muted: #6c757d;
--border-color: #dee2e6; --color-bg: #f8f9fa;
--color-surface: #ffffff;
--color-surface-muted: #f1f3f5;
--color-border: #dee2e6;
--color-info: #748ffc;
--color-code-bg: #353943;
--color-code-text: #f9fafb;
--color-scroll-track: #f1f1f1;
--color-scroll-thumb: #888888;
--color-scroll-thumb-hover: #555555;
--primary-color: var(--color-primary);
--secondary-color: var(--color-primary-strong);
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
} }
[data-bs-theme="dark"] { [data-bs-theme="dark"] {
--text-color: #e2e8f0; --color-text: #e2e8f0;
--bg-color: #1a1d23; --color-text-muted: #94a3b8;
--border-color: #3a3f4b; --color-bg: #1a1d23;
--color-surface: #21252e;
--color-surface-muted: #2d3748;
--color-border: #3a3f4b;
--color-info: #7aa2f7;
--color-code-bg: #2d3748;
--color-code-text: #e2e8f0;
--color-scroll-track: #2d3748;
--color-scroll-thumb: #4a5568;
--color-scroll-thumb-hover: #718096;
--text-color: var(--color-text);
--bg-color: var(--color-bg);
--border-color: var(--color-border);
} }
[data-bs-theme="dark"] body { [data-bs-theme="dark"] body {
background-color: #1a1d23; background-color: var(--color-bg);
color: #e2e8f0; color: var(--color-text);
} }
[data-bs-theme="dark"] .sidebar { [data-bs-theme="dark"] .sidebar {
background-color: #21252e !important; background-color: var(--color-surface) !important;
border-color: #3a3f4b !important; border-color: var(--color-border) !important;
} }
[data-bs-theme="dark"] .toolbar { [data-bs-theme="dark"] .toolbar {
background-color: #21252e; background-color: var(--color-surface);
border-color: #3a3f4b !important; border-color: var(--color-border) !important;
} }
[data-bs-theme="dark"] .main-content { [data-bs-theme="dark"] .main-content {
background-color: #1a1d23; background-color: var(--color-bg);
} }
[data-bs-theme="dark"] .markdown-body table { [data-bs-theme="dark"] .markdown-body table {
background: #21252e; background: var(--color-surface);
} }
[data-bs-theme="dark"] .markdown-body th { [data-bs-theme="dark"] .markdown-body th {
background: #2a2f3a; background: var(--color-surface-muted);
} }
[data-bs-theme="dark"] .markdown-body tr:nth-child(even) td { [data-bs-theme="dark"] .markdown-body tr:nth-child(even) td {
@@ -49,8 +77,8 @@
} }
[data-bs-theme="dark"] .markdown-body :not(pre) > code { [data-bs-theme="dark"] .markdown-body :not(pre) > code {
background: #2d3748; background: var(--color-surface-muted);
color: #e2e8f0; color: var(--color-text);
} }
[data-bs-theme="dark"] .markdown-body pre code { [data-bs-theme="dark"] .markdown-body pre code {
@@ -59,20 +87,20 @@
} }
[data-bs-theme="dark"] .markdown-body pre { [data-bs-theme="dark"] .markdown-body pre {
background: #2d3748; background: var(--color-code-bg);
color: #e2e8f0; color: var(--color-code-text);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-track { [data-bs-theme="dark"] ::-webkit-scrollbar-track {
background: #2d3748; background: var(--color-scroll-track);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb { [data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
background: #4a5568; background: var(--color-scroll-thumb);
} }
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover { [data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: #718096; background: var(--color-scroll-thumb-hover);
} }
* { * {
@@ -99,7 +127,7 @@ body,
margin: 1rem 0; margin: 1rem 0;
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
background: #fff; background: var(--color-surface);
} }
.markdown-body th, .markdown-body th,
@@ -126,7 +154,7 @@ body,
.markdown-body blockquote { .markdown-body blockquote {
margin: 1rem 0; margin: 1rem 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-left: 4px solid #748ffc; border-left: 4px solid var(--color-info);
background: #f8f9ff; background: #f8f9ff;
color: #334155; color: #334155;
} }
@@ -139,8 +167,8 @@ body,
margin: 1rem 0; margin: 1rem 0;
padding: 1rem; padding: 1rem;
border-radius: 0.75rem; border-radius: 0.75rem;
background: #353943; background: var(--color-code-bg);
color: #f9fafb; color: var(--color-code-text);
overflow-x: auto; overflow-x: auto;
} }
@@ -155,7 +183,7 @@ body,
font-size: 0.95em; font-size: 0.95em;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
border-radius: 0.35rem; border-radius: 0.35rem;
background: #f1f3f5; background: var(--color-surface-muted);
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -165,14 +193,14 @@ body,
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--color-scroll-track);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: var(--color-scroll-thumb);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--color-scroll-thumb-hover);
} }
+254
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;
}
@@ -0,0 +1,5 @@
@import "./AdminModal.shared.css";
.permissions-textarea {
font-family: "Courier New", monospace;
}
@@ -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;
}
}
@@ -0,0 +1 @@
@import "./AdminModal.shared.css";
@@ -0,0 +1,2 @@
@import "./AdminModal.shared.css";
@@ -0,0 +1 @@
@import "./AdminModal.shared.css";
@@ -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;
}
@@ -0,0 +1,12 @@
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
@@ -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);
}
@@ -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);
}
@@ -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%);
}
@@ -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%);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
+2 -28
View File
@@ -78,7 +78,7 @@ const hydrateForm = () => {
form.value = { form.value = {
name: props.group?.name || "", name: props.group?.name || "",
description: props.group?.description || "", description: props.group?.description || "",
permissionsText: (props.group?.permissions || []).join("\n"), permissionsText: (props.group?.permissions || []).join("/n"),
}; };
}; };
@@ -93,33 +93,7 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminGroupModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
.permissions-textarea {
font-family: "Courier New", monospace;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>
+12 -35
View File
@@ -62,17 +62,17 @@
</div> </div>
<div v-if="mode === 'edit'" class="col-12"> <div v-if="mode === 'edit'" class="col-12">
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2"> <DangerZonePanel
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2"> class="mt-4"
<div> title-id="danger-zone-title"
<div class="fw-semibold text-danger">Danger Zone</div> title="Danger Zone"
<div class="small text-muted">Permanently delete this provider configuration.</div> description="Permanently delete this provider configuration. This action cannot be undone."
</div> >
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)"> <button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Provider
</button> </button>
</div> </DangerZonePanel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@
<script setup> <script setup>
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -178,28 +179,4 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.5rem;
}
}
</style>
+100 -39
View File
@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -85,24 +85,39 @@
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div> <div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade show admin-modal-backdrop"></div> <div class="modal-backdrop fade show admin-modal-backdrop"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -133,6 +148,20 @@ const error = ref("");
const success = ref(""); const success = ref("");
const newMember = ref({ user_id: "" }); const newMember = ref({ user_id: "" });
const deleting = ref(false); const deleting = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-"); const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
@@ -208,9 +237,20 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -236,10 +276,15 @@ watch(
{ immediate: true }, { immediate: true },
); );
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -247,35 +292,51 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>
+1 -23
View File
@@ -83,29 +83,7 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/AdminUserModal.css"></style>
.admin-modal {
z-index: 2000;
overflow-y: auto;
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.admin-modal-backdrop {
z-index: 1990;
}
.admin-modal .modal-dialog {
margin: 1rem auto;
}
@media (max-width: 767.98px) {
.admin-modal {
padding-top: max(0.75rem, env(safe-area-inset-top));
}
.admin-modal .modal-dialog {
margin: 0.75rem;
max-width: none;
}
}
</style>
+12 -185
View File
@@ -2,7 +2,7 @@
<div class="category-tree"> <div class="category-tree">
<div v-for="category in categories" :key="category.id" class="category-item"> <div v-for="category in categories" :key="category.id" class="category-item">
<div class="category-header" @click="handleCategoryClick(category)"> <div class="category-header" @click="handleCategoryClick(category)">
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length"> <span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i> <i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
</span> </span>
<span v-else class="expand-icon"> </span> <span v-else class="expand-icon"> </span>
@@ -20,6 +20,11 @@
</div> </div>
<div v-if="expandedCategories[category.id]" class="category-content"> <div v-if="expandedCategories[category.id]" class="category-content">
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
<span>{{ taskList.name }}</span>
</div>
<div <div
v-for="note in sortedNotes(category.notes)" v-for="note in sortedNotes(category.notes)"
:key="note.id" :key="note.id"
@@ -41,6 +46,7 @@
:on-add-subcategory="onAddSubcategory" :on-add-subcategory="onAddSubcategory"
:on-edit-category="onEditCategory" :on-edit-category="onEditCategory"
:on-delete-category="onDeleteCategory" :on-delete-category="onDeleteCategory"
:on-select-task-list="onSelectTaskList"
:can-create-categories="canCreateCategories" :can-create-categories="canCreateCategories"
:can-edit-categories="canEditCategories" :can-edit-categories="canEditCategories"
:can-delete-categories="canDeleteCategories" :can-delete-categories="canDeleteCategories"
@@ -80,6 +86,10 @@ const props = defineProps({
type: Function, type: Function,
required: true, required: true,
}, },
onSelectTaskList: {
type: Function,
required: true,
},
canCreateCategories: { canCreateCategories: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -141,187 +151,4 @@ const handleDeleteCategory = (category) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>
.category-item {
margin-bottom: 0.25rem;
}
.category-header {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 4px;
}
.category-header:hover {
background-color: #e9ecef;
}
.expand-icon {
width: 20px;
text-align: center;
font-size: 0.875rem;
}
.category-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-actions {
position: relative;
}
.menu-button {
border: 0;
background: transparent;
width: 28px;
height: 28px;
border-radius: 6px;
color: #495057;
}
.menu-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
.menu-dropdown {
position: absolute;
top: 30px;
right: 0;
min-width: 150px;
padding: 0.35rem;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
z-index: 5;
}
.menu-item {
display: block;
width: 100%;
border: 0;
background: transparent;
text-align: left;
padding: 0.45rem 0.6rem;
border-radius: 0.35rem;
}
.menu-item:hover {
background-color: #f1f3f5;
}
.menu-item.danger {
color: #c92a2a;
}
.category-content {
padding-left: 1rem;
}
.note-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 16px;
margin-bottom: 0.25rem;
}
.note-item:hover {
background-color: #e9ecef;
}
.note-item span {
flex-grow: 1;
}
.pin-icon {
color: #408aca;
font-size: 0.9em;
flex-shrink: 0;
}
.featured-icon {
color: #f08c00;
font-size: 0.9em;
flex-shrink: 0;
}
.note-item.is-pinned {
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.note-item.is-pinned:hover {
background: #c5e9ff;
}
.note-item.is-featured {
background: #fff9db;
border: 1px solid #ffd8a8;
}
.note-item.is-featured:hover {
background: #fff6c5;
}
.subcategories {
margin-top: 0.25rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .category-header:hover {
background-color: #2d3748;
}
:root[data-bs-theme="dark"] .menu-button {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .menu-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
:root[data-bs-theme="dark"] .menu-dropdown {
background: #2d3748;
border-color: #4a5568;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
:root[data-bs-theme="dark"] .menu-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .menu-item:hover {
background-color: #374151;
}
:root[data-bs-theme="dark"] .note-item:hover {
background-color: #2d3748;
}
:root[data-bs-theme="dark"] .note-item.is-pinned {
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
background: #1e4470;
}
:root[data-bs-theme="dark"] .note-item.is-featured {
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
background: #453710;
}
</style>
@@ -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>
@@ -90,4 +90,5 @@ const handleSubmit = () => {
}; };
</script> </script>
<style scoped></style>
+3 -12
View File
@@ -141,16 +141,7 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/CreateNoteModal.css"></style>
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
</style>
+2 -1
View File
@@ -49,4 +49,5 @@ const handleCreate = () => {
}; };
</script> </script>
<style scoped></style>
@@ -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>
@@ -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>
@@ -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>
+43 -68
View File
@@ -78,7 +78,7 @@
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i> <i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span> <span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span> <span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)"> <button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i> <i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -87,11 +87,14 @@
<!-- Hidden file input --> <!-- Hidden file input -->
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" /> <input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
</div> </div>
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick } from "vue"; import { ref, computed, watch, nextTick } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
spaceId: { spaceId: {
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
const newFolderName = ref(""); const newFolderName = ref("");
const fileInputRef = ref(null); const fileInputRef = ref(null);
const newFolderInputRef = ref(null); const newFolderInputRef = ref(null);
const showDeleteConfirmModal = ref(false);
const pendingDeleteItem = ref(null);
const deletingItem = ref(false);
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
if (!currentPrefix.value) return []; if (!currentPrefix.value) return [];
@@ -215,9 +221,37 @@ const createFolder = async () => {
} }
}; };
const deleteItem = async (obj) => { const requestDeleteItem = (obj) => {
const label = displayName(obj); if (!obj) {
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return; return;
}
pendingDeleteItem.value = obj;
showDeleteConfirmModal.value = true;
};
const closeDeleteConfirmModal = () => {
if (deletingItem.value) {
return;
}
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
};
const deleteConfirmMessage = computed(() => {
const obj = pendingDeleteItem.value;
const label = obj ? displayName(obj) : "this item";
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
});
const confirmDeleteItem = async () => {
const obj = pendingDeleteItem.value;
if (!obj) {
return;
}
deletingItem.value = true;
error.value = ""; error.value = "";
try { try {
if (obj.is_folder) { if (obj.is_folder) {
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } }); await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
} }
await loadFiles(); await loadFiles();
showDeleteConfirmModal.value = false;
pendingDeleteItem.value = null;
} catch (e) { } catch (e) {
error.value = e.response?.data || "Delete failed"; error.value = e.response?.data || "Delete failed";
} finally {
deletingItem.value = false;
} }
}; };
@@ -284,67 +322,4 @@ watch(showNewFolderInput, async (v) => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/FileExplorer.css"></style>
.file-explorer {
background: #fff;
overflow: hidden;
}
.file-explorer-header {
font-size: 0.8rem;
min-height: 36px;
}
.file-list {
max-height: 480px;
}
.file-item {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
color: #333;
line-height: 1.3;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f0f4ff;
}
.drag-active {
outline: 2px dashed #0d6efd;
outline-offset: -2px;
}
.btn-delete {
opacity: 0;
transition: opacity 0.1s;
}
.file-item:hover .btn-delete {
opacity: 1;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .file-explorer {
background: #21252e;
}
:root[data-bs-theme="dark"] .file-explorer-header {
background: #21252e;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .file-item {
border-bottom-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .file-item:hover {
background-color: #2d3748;
}
</style>
@@ -187,95 +187,7 @@ const createProvider = async () => {
onMounted(loadProviders); onMounted(loadProviders);
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/ManageAuthProvidersModal.css"></style>
.modal-backdrop-custom {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem;
}
.modal-panel {
width: min(920px, 100%);
max-height: min(92vh, 980px);
background: #fff;
border: 1px solid #dbe3ee;
border-radius: 14px;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.provider-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
}
.provider-modal-title {
margin: 0;
font-weight: 600;
}
.provider-modal-close {
flex-shrink: 0;
}
.provider-modal-body {
padding: 1rem 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.provider-section {
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f8fafc;
padding: 0.9rem;
}
.provider-list {
max-height: 220px;
overflow: auto;
}
@media (max-width: 768px) {
.modal-backdrop-custom {
padding: 0.75rem;
}
.provider-modal-header,
.provider-modal-body {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .modal-panel {
background: #21252e;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .provider-modal-header {
background: linear-gradient(180deg, #2a2f3a 0%, #21252e 100%);
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .provider-section {
background: #2a2f3a;
border-color: #3a3f4b;
}
</style>
+26 -307
View File
@@ -125,16 +125,22 @@
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" /> <input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
</div> </div>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title"> <DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3> <button class="btn btn-danger" type="button" @click="requestDelete">
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
<button class="btn btn-danger" type="button" @click="confirmDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i> <i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note Delete Note
</button> </button>
</section> </DangerZonePanel>
</div> </div>
</div> </div>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
title="Delete Note"
message="Are you sure you want to delete this note? This action cannot be undone."
@close="showDeleteConfirmModal = false"
@confirm="confirmDelete"
/>
</template> </template>
<script setup> <script setup>
@@ -144,6 +150,8 @@ import { useSettingsStore } from "../stores/settingsStore";
import { useSpaceStore } from "../stores/spaceStore"; import { useSpaceStore } from "../stores/spaceStore";
import { renderMarkdown } from "../utils/markdown.js"; import { renderMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue"; import FileExplorer from "./FileExplorer.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
import ConfirmActionModal from "./ConfirmActionModal.vue";
const props = defineProps({ const props = defineProps({
note: { note: {
@@ -187,6 +195,7 @@ const linkedTasks = ref([]);
const showTaskPicker = ref(false); const showTaskPicker = ref(false);
const taskPickerQuery = ref(""); const taskPickerQuery = ref("");
const taskPickerLoading = ref(false); const taskPickerLoading = ref(false);
const showDeleteConfirmModal = ref(false);
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value); const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value); const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
@@ -374,13 +383,22 @@ const autoSave = () => {
detectTaskMention(); detectTaskMention();
}; };
const requestDelete = () => {
if (!props.canDelete) {
return;
}
showDeleteConfirmModal.value = true;
};
const confirmDelete = () => { const confirmDelete = () => {
if (!props.canDelete) { if (!props.canDelete) {
return; return;
} }
if (confirm("Are you sure you want to delete this note?")) { if (!editingNote.value?.id) {
emit("delete", editingNote.value.id); return;
} }
showDeleteConfirmModal.value = false;
emit("delete", editingNote.value.id);
}; };
/** Insert markdown snippet at the textarea cursor position. */ /** Insert markdown snippet at the textarea cursor position. */
@@ -571,303 +589,4 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteEditor.css"></style>
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
}
.save-status {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
color: #6c757d;
}
.save-status.dirty {
color: #b26a00;
}
.save-status.saving {
color: #0d6efd;
}
.save-status.saved {
color: #2b8a3e;
}
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 600px;
resize: vertical;
}
.note-flags {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.flag-check {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 999px;
background: #f8f9fa;
margin: 0;
cursor: pointer;
}
.flag-check-input {
margin: 0;
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.flag-check-label {
line-height: 1;
user-select: none;
}
.preview-pane {
background-color: #f8f9fa;
overflow-y: auto;
max-height: 600px;
}
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
.task-mention-panel {
margin-top: 0.45rem;
border: 1px solid #dbe4f0;
border-radius: 10px;
background: #fbfdff;
padding: 0.5rem;
max-height: 220px;
overflow-y: auto;
}
.task-mention-option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
padding: 0.35rem 0.45rem;
border-radius: 6px;
text-align: left;
}
.task-mention-option:hover {
background: #eef3ff;
}
.task-picker {
background: #fff;
min-height: 300px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-picker-header {
background: #f8f9fa;
min-height: 34px;
}
.task-picker-search {
background: #fff;
}
.task-picker-list {
overflow-y: auto;
max-height: 520px;
padding: 0.25rem;
}
.task-picker-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 0;
background: transparent;
border-radius: 8px;
padding: 0.35rem 0.45rem;
text-align: left;
gap: 0.4rem;
color: #333;
}
.task-picker-item:hover {
background: #eef3ff;
}
.task-picker-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-picker-empty {
padding: 0.7rem;
}
.task-picker-item small {
font-size: 0.7rem;
color: #6b7280;
}
.task-picker .btn-link {
text-decoration: none;
}
.task-picker-item {
flex-wrap: wrap;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .editor-toolbar {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .flag-check {
background: #2d3748;
border-color: #4a5568;
}
:root[data-bs-theme="dark"] .preview-pane {
background-color: #21252e;
}
:root[data-bs-theme="dark"] .danger-zone {
background: #2d1a1a;
border-color: #7a3030;
}
:root[data-bs-theme="dark"] .danger-zone-title {
color: #fc8181;
}
:root[data-bs-theme="dark"] .danger-zone-copy {
color: #fca5a5;
}
:root[data-bs-theme="dark"] .task-mention-panel {
border-color: #3a4558;
background: #1f2733;
}
:root[data-bs-theme="dark"] .task-mention-option:hover {
background: #2b3646;
}
:root[data-bs-theme="dark"] .task-picker {
border-color: #3a3f4b !important;
background: #21252e;
}
:root[data-bs-theme="dark"] .task-picker-header {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search {
background: #21252e;
border-bottom-color: #3a3f4b !important;
}
:root[data-bs-theme="dark"] .task-picker-search .form-control {
background: #1f2430;
border-color: #3a3f4b;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .task-picker-item:hover {
background: #2d3748;
}
:root[data-bs-theme="dark"] .task-picker-item small {
color: #a8b4c7;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>
-272
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>
+1 -193
View File
@@ -152,196 +152,4 @@ const onMarkdownClick = (event) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/NoteViewer.css"></style>
.note-viewer {
max-width: 900px;
}
.note-meta {
padding-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
}
.meta-grid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: #eef2ff;
color: #364fc7;
font-size: 0.8rem;
}
.state-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pinned-chip {
color: #005f8f;
background: #dbf5ff;
border: 1px solid #a8d1ff;
}
.featured-chip {
color: #8d7619;
border: 1px solid #ffd8a8;
background: #fff9db;
}
.public-chip {
color: #0c5460;
border: 1px solid #a5d8ff;
background: #e7f5ff;
}
.private-chip {
color: #5f3dc4;
border: 1px solid #d0bfff;
background: #f3f0ff;
}
.protected-chip {
color: #7f5539;
border: 1px solid #e0c3a6;
background: #fff4e6;
}
.markdown-body :deep(.task-inline-link) {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
border: 1px solid #c7d8ff;
background: #eef4ff;
color: #2c4ea3;
font-weight: 600;
text-decoration: none;
}
.markdown-body :deep(.task-inline-title) {
line-height: 1;
}
.markdown-body :deep(.task-inline-status) {
line-height: 1;
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.16rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, #ffffff 40%);
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, #ffffff 82%);
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
}
.markdown-body :deep(.task-inline-link:hover) {
background: #dfeaff;
border-color: #aac4ff;
}
.markdown-body :deep(.task-inline-link i) {
font-size: 0.9rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.markdown-body :deep(p),
.markdown-body :deep(li) {
line-height: 1.7;
}
.markdown-body :deep(pre) {
padding: 1rem;
border-radius: 0.75rem;
overflow-x: auto;
}
.markdown-body :deep(code) {
font-family: "Courier New", monospace;
}
.markdown-body :deep(blockquote) {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid #748ffc;
background: #f8f9ff;
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .note-meta {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .tag-chip {
background: #1e2d5f;
color: #93b4ff;
}
:root[data-bs-theme="dark"] .pinned-chip {
color: #7dd3fc;
background: #1a3a5c;
border-color: #2d6a9f;
}
:root[data-bs-theme="dark"] .featured-chip {
color: #fbbf24;
background: #3a2e0a;
border-color: #7a5a0a;
}
:root[data-bs-theme="dark"] .public-chip {
color: #67e8f9;
background: #0c2a3a;
border-color: #1d6a7a;
}
:root[data-bs-theme="dark"] .private-chip {
color: #c4b5fd;
background: #2d1f5e;
border-color: #5b3f9a;
}
:root[data-bs-theme="dark"] .protected-chip {
color: #fdba74;
background: #3a1f0a;
border-color: #7a4f1a;
}
:root[data-bs-theme="dark"] .markdown-body :deep(blockquote) {
background: #1e2430;
color: #94a3b8;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
border-color: #35508b;
background: #1b2a4a;
color: #9ec0ff;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
background: #22345c;
border-color: #4566ad;
}
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
}
</style>
+11 -110
View File
@@ -3,23 +3,23 @@
<header class="search-results-header"> <header class="search-results-header">
<h2>Search Results</h2> <h2>Search Results</h2>
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p> <p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p> <p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
</header> </header>
<div v-if="!query" class="empty-state"> <div v-if="!query" class="empty-state">
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
<h3>Start your search</h3> <h3>Start your search</h3>
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p> <p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
</div> </div>
<div v-else-if="totalResults === 0" class="empty-state"> <div v-else-if="totalResults === 0" class="empty-state">
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i> <i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
<h3>No matching notes</h3> <h3>No matching results</h3>
<p>Try different keywords or a shorter phrase.</p> <p>Try different keywords or a shorter phrase.</p>
</div> </div>
<div v-else> <div v-else>
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" /> <WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages"> <nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button> <button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
@@ -32,14 +32,14 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import NoteList from "./NoteList.vue"; import WorkspaceList from "./WorkspaceList.vue";
const props = defineProps({ const props = defineProps({
query: { query: {
type: String, type: String,
default: "", default: "",
}, },
notes: { items: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
@@ -57,9 +57,9 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(["select-note", "page-change"]); const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
const totalResults = computed(() => props.notes.length); const totalResults = computed(() => props.items.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize))); const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
const normalizedPage = computed(() => { const normalizedPage = computed(() => {
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
return Math.min(props.currentPage, totalPages.value); return Math.min(props.currentPage, totalPages.value);
}); });
const paginatedNotes = computed(() => { const paginatedItems = computed(() => {
const start = (normalizedPage.value - 1) * props.pageSize; const start = (normalizedPage.value - 1) * props.pageSize;
return props.notes.slice(start, start + props.pageSize); return props.items.slice(start, start + props.pageSize);
}); });
const goToPage = (page) => { const goToPage = (page) => {
@@ -82,103 +82,4 @@ const goToPage = (page) => {
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>
.search-results-page {
max-width: 1200px;
margin: 0 auto;
}
.search-results-header {
margin-bottom: 1.5rem;
}
.search-results-header h2 {
margin: 0;
font-size: 1.5rem;
color: #223149;
}
.search-meta {
margin: 0.35rem 0 0;
color: #5b6f8b;
}
.pagination-bar {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.85rem;
}
.page-indicator {
color: #4f637d;
font-weight: 600;
}
.empty-state {
min-height: 48vh;
border: 1px dashed #cfdae9;
border-radius: 14px;
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state-icon {
font-size: 4.2rem;
color: #60789a;
margin-bottom: 0.6rem;
}
.empty-state h3 {
margin: 0;
color: #223149;
}
.empty-state p {
margin: 0.6rem 0 0;
color: #5b6f8b;
max-width: 500px;
}
@media (max-width: 768px) {
.pagination-bar {
flex-direction: column;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .search-results-header h2 {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .search-meta {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .page-indicator {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state {
border-color: #3a3f4b;
background: radial-gradient(circle at 20% 20%, #1a2035 0%, #1e2430 70%);
}
:root[data-bs-theme="dark"] .empty-state h3 {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .empty-state p {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .empty-state-icon {
color: #4a6fa5;
}
</style>
+99 -13
View File
@@ -1,5 +1,5 @@
<template> <template>
<teleport to="body"> <teleport v-if="!showDeleteConfirmModal" to="body">
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')"> <div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document"> <div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -76,13 +76,17 @@
<template v-if="canDeleteSpace"> <template v-if="canDeleteSpace">
<hr /> <hr />
<div class="border border-danger rounded p-3 mt-3"> <DangerZonePanel
<h6 class="text-danger mb-1">Danger Zone</h6> class="mt-4"
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p> title-id="danger-zone-title"
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace"> title="Danger Zone"
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
>
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
{{ deleting ? "Deleting..." : "Delete Space" }} {{ deleting ? "Deleting..." : "Delete Space" }}
</button> </button>
</div> </DangerZonePanel>
</template> </template>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div> <div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
@@ -93,12 +97,23 @@
</div> </div>
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop fade show"></div>
</teleport> </teleport>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import apiClient from "../services/apiClient"; import apiClient from "../services/apiClient";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import ConfirmActionModal from "./ConfirmActionModal.vue";
import DangerZonePanel from "./DangerZonePanel.vue";
const props = defineProps({ const props = defineProps({
space: { space: {
@@ -130,6 +145,20 @@ const success = ref("");
const memberForm = ref({ user_id: "" }); const memberForm = ref({ user_id: "" });
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view")); const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage")); const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "member") {
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
return `Remove member "${memberName}" from this space?`;
}
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
});
watch( watch(
() => props.space, () => props.space,
@@ -224,13 +253,24 @@ const addMember = async () => {
} }
}; };
const removeMember = async (member) => { const removeMember = (member) => {
if (!canManageMembers.value) { if (!canManageMembers.value) {
return; return;
} }
const memberName = member?.username || member?.user_id; if (!member?.user_id) {
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) { return;
}
deleteConfirmIntent.value = {
type: "member",
payload: member,
};
showDeleteConfirmModal.value = true;
};
const removeMemberConfirmed = async (member) => {
if (!member?.user_id) {
return; return;
} }
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
Promise.all([loadMembers(), loadUserOptions()]); Promise.all([loadMembers(), loadUserOptions()]);
} }
const deleteSpace = async () => { const requestDeleteSpace = () => {
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) { deleteConfirmIntent.value = {
return; type: "space",
} payload: props.space,
};
showDeleteConfirmModal.value = true;
};
const deleteSpaceConfirmed = async () => {
deleting.value = true; deleting.value = true;
clearMessages(); clearMessages();
try { try {
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
emit("deleted", props.space); emit("deleted", props.space);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete space."; error.value = e.response?.data || "Failed to delete space.";
throw e;
} finally { } finally {
deleting.value = false; deleting.value = false;
} }
}; };
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "member") {
await removeMemberConfirmed(payload);
} else if (type === "space") {
await deleteSpaceConfirmed();
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
</script> </script>
+6 -600
View File
@@ -5,19 +5,10 @@
<h4 class="mb-0">Tasks</h4> <h4 class="mb-0">Tasks</h4>
<p class="text-muted small mb-0">Track work with ordered statuses.</p> <p class="text-muted small mb-0">Track work with ordered statuses.</p>
</div> </div>
<button class="btn btn-primary" @click="emit('create-task')"> <button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
<i class="mdi mdi-checkbox-marked-circle-plus-outline me-1" aria-hidden="true"></i>
New Task
</button>
</div> </div>
<div class="task-filters"> <div class="task-filters">
<select v-model="filterCategory" class="form-select" @change="emitFilters">
<option value="">All categories</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</option>
</select>
<select v-model="filterStatus" class="form-select" @change="emitFilters"> <select v-model="filterStatus" class="form-select" @change="emitFilters">
<option value="">All statuses</option> <option value="">All statuses</option>
<option v-for="status in statuses" :key="status.id" :value="status.id"> <option v-for="status in statuses" :key="status.id" :value="status.id">
@@ -33,36 +24,6 @@
</select> </select>
</div> </div>
<div class="status-lane">
<div class="lane-header">
<strong>Status Progression</strong>
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
</div>
<div class="status-list">
<div
v-for="status in statuses"
:key="status.id"
class="status-item"
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
draggable="true"
@dragstart="onStatusDragStart(status.id)"
@dragover.prevent="onStatusDragOver(status.id)"
@dragleave="onStatusDragLeave(status.id)"
@drop.prevent="onStatusDrop(status.id)"
@dragend="onStatusDragEnd"
>
<span class="drag-handle" aria-hidden="true">
<i class="mdi mdi-drag-horizontal-variant"></i>
</span>
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
<span class="status-name">{{ status.name }}</span>
<div class="status-actions">
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
</div>
</div>
</div>
</div>
<div class="task-status-groups"> <div class="task-status-groups">
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div> <div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
@@ -194,42 +155,6 @@
</div> </div>
</section> </section>
</div> </div>
<teleport to="body">
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeStatusModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
</div>
<div class="modal-body">
<label class="form-label" for="taskStatusName">Status Name</label>
<input id="taskStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
<label class="form-label mt-3" for="taskStatusColor">Status Color</label>
<div class="status-color-row">
<input id="taskStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
</div>
<section v-if="statusMode === 'edit'" class="danger-zone mt-4" aria-labelledby="status-danger-zone-title">
<h6 id="status-danger-zone-title" class="danger-zone-title">Danger Zone</h6>
<p class="danger-zone-copy mb-2">Deleting this status is permanent and cannot be undone.</p>
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
<button type="button" class="btn btn-primary" @click="submitStatusForm">
{{ statusMode === "create" ? "Create" : "Save" }}
</button>
</div>
</div>
</div>
</div>
<div v-if="showStatusModal" class="modal-backdrop fade show"></div>
</teleport>
</section> </section>
</template> </template>
@@ -245,28 +170,18 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: { selectedTaskList: {
type: Array, type: Object,
default: () => [], default: null,
}, },
}); });
const emit = defineEmits(["create-task", "select-task", "filter-change", "reorder-status", "create-status", "rename-status", "delete-status", "update-task-status"]); const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
const filterCategory = ref("");
const filterStatus = ref(""); const filterStatus = ref("");
const filterParent = ref(""); const filterParent = ref("");
const showStatusModal = ref(false);
const statusMode = ref("create");
const editingStatusId = ref("");
const draggedStatusId = ref("");
const dragOverStatusId = ref("");
const expandedTaskIds = ref({}); const expandedTaskIds = ref({});
const openStatusMenuTaskId = ref(""); const openStatusMenuTaskId = ref("");
const statusForm = ref({
name: "",
color: "#7c8596",
});
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2)); const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
const tasksById = computed(() => { const tasksById = computed(() => {
@@ -307,7 +222,6 @@ const statusSections = computed(() =>
const emitFilters = () => { const emitFilters = () => {
emit("filter-change", { emit("filter-change", {
categoryId: filterCategory.value || null,
statusId: filterStatus.value || null, statusId: filterStatus.value || null,
parentTaskId: filterParent.value || null, parentTaskId: filterParent.value || null,
}); });
@@ -379,514 +293,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick); document.removeEventListener("click", onDocumentClick);
}); });
const onStatusDragStart = (statusId) => {
draggedStatusId.value = statusId;
};
const onStatusDragOver = (statusId) => {
dragOverStatusId.value = statusId;
};
const onStatusDragLeave = (statusId) => {
if (dragOverStatusId.value === statusId) {
dragOverStatusId.value = "";
}
};
const onStatusDrop = (targetStatusId) => {
if (!draggedStatusId.value || draggedStatusId.value === targetStatusId) {
onStatusDragEnd();
return;
}
const ordered = props.statuses.map((item) => item.id);
const fromIndex = ordered.indexOf(draggedStatusId.value);
const targetIndex = ordered.indexOf(targetStatusId);
if (fromIndex < 0 || targetIndex < 0) {
onStatusDragEnd();
return;
}
ordered.splice(fromIndex, 1);
const insertIndex = ordered.indexOf(targetStatusId);
ordered.splice(insertIndex, 0, draggedStatusId.value);
emit("reorder-status", ordered);
onStatusDragEnd();
};
const onStatusDragEnd = () => {
draggedStatusId.value = "";
dragOverStatusId.value = "";
};
const closeStatusModal = () => {
showStatusModal.value = false;
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
};
const openCreateStatusModal = () => {
statusMode.value = "create";
editingStatusId.value = "";
statusForm.value = {
name: "",
color: "#7c8596",
};
showStatusModal.value = true;
};
const openEditStatusModal = (status) => {
statusMode.value = "edit";
editingStatusId.value = status.id;
statusForm.value = {
name: status.name || "",
color: status.color || "#7c8596",
};
showStatusModal.value = true;
};
const submitStatusForm = () => {
const name = statusForm.value.name?.trim();
if (!name) {
return;
}
const color = statusForm.value.color?.trim() || "";
if (statusMode.value === "create") {
emit("create-status", { name, color });
} else {
if (!editingStatusId.value) {
return;
}
emit("rename-status", {
id: editingStatusId.value,
name,
color,
});
}
closeStatusModal();
};
const deleteStatusFromModal = () => {
if (statusMode.value !== "edit" || !editingStatusId.value) {
return;
}
emit("delete-status", {
id: editingStatusId.value,
name: statusForm.value.name?.trim() || "",
color: statusForm.value.color?.trim() || "",
});
closeStatusModal();
};
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
.task-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-board-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.task-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.status-lane {
border: 1px solid #d9e2ec;
border-radius: 12px;
padding: 0.75rem;
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
}
.lane-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.45rem;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e4e9f0;
cursor: grab;
}
.status-item.is-drag-over {
border-color: #7aa2f7;
background: #eef3ff;
}
.drag-handle {
color: #74839a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-name {
flex: 1;
font-weight: 600;
}
.status-actions {
display: inline-flex;
gap: 0.35rem;
}
.task-status-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-group {
border: 1px solid #dbe4f0;
border-radius: 12px;
overflow: visible;
background: #fff;
}
.status-group-header {
display: flex;
align-items: center;
justify-content: space-between;
border-left: 6px solid transparent;
background: #f8fbff;
border-bottom: 1px solid #edf2f8;
padding: 0.65rem 0.85rem;
}
.status-group-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.status-group-title {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.status-group-dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.status-group-count {
color: #5f6f87;
font-weight: 600;
}
.status-empty {
padding: 0.75rem 0.85rem;
color: #7a8799;
font-size: 0.9rem;
}
.task-tree-row {
border-bottom: 1px solid #edf2f8;
}
.task-tree-row:last-child {
border-bottom: 0;
}
.task-tree-row.level-1 .task-row {
padding-left: 2.1rem;
}
.task-tree-row.level-2 .task-row {
padding-left: 3.5rem;
}
.task-row {
width: 100%;
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 0.65rem;
align-items: center;
border: 0;
background: #fff;
text-align: left;
padding: 0.7rem 0.85rem;
}
.task-row:hover {
background: #f4f8ff;
}
.status-group-header {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.status-group > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.tree-toggle {
width: 1.25rem;
color: #5f6f87;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.task-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.task-main strong,
.task-main small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status-menu {
position: relative;
display: inline-flex;
}
.status-trigger {
width: 28px;
height: 28px;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.status-trigger:hover {
background: #eef3f9;
}
.status-trigger-dot {
width: 14px;
height: 14px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
border-radius: 999px;
}
.status-popup {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 190px;
background: #151a22;
border: 1px solid #2a3343;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
padding: 0.35rem;
z-index: 40;
}
.status-option {
width: 100%;
border: 0;
border-radius: 8px;
background: transparent;
color: #e8edf5;
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.5rem;
text-align: left;
}
.status-option:hover,
.status-option.selected {
background: rgba(255, 255, 255, 0.09);
}
.status-option-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid;
background: transparent;
}
.status-option-label {
font-size: 0.86rem;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
}
.status-option-check {
color: #e8edf5;
font-size: 0.95rem;
}
.empty-state {
padding: 1rem;
color: #6c757d;
}
.status-color-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem;
align-items: center;
}
.danger-zone {
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
padding: 0.75rem;
}
.danger-zone-title {
color: #9f1c1c;
margin: 0;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.task-filters {
grid-template-columns: 1fr;
}
.task-row {
grid-template-columns: 24px 1fr;
}
.status-popup {
right: -0.2rem;
min-width: 170px;
}
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .status-lane {
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-item {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-item.is-drag-over {
border-color: #7aa2f7;
background: #1e2d4a;
}
:root[data-bs-theme="dark"] .drag-handle {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .status-group {
background: #1e2230;
border-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-header {
background: #232840;
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .status-group-title {
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .status-group-count {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-empty {
color: #5f6f87;
}
:root[data-bs-theme="dark"] .task-tree-row {
border-bottom-color: #2e3444;
}
:root[data-bs-theme="dark"] .task-row {
background: #1e2230;
color: #c8d3e6;
}
:root[data-bs-theme="dark"] .task-row:hover {
background: #252d40;
}
:root[data-bs-theme="dark"] .tree-toggle {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .task-main small {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .status-trigger:hover {
background: #2e3448;
}
:root[data-bs-theme="dark"] .status-trigger-dot {
border-color: #1e2230;
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
}
:root[data-bs-theme="dark"] .empty-state {
color: #7a8fa8;
}
</style>
+4 -74
View File
@@ -16,12 +16,6 @@
<label class="form-label mt-3">Description</label> <label class="form-label mt-3">Description</label>
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea> <textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
<label class="form-label mt-3">Category</label>
<select v-model="localTask.category_id" class="form-select">
<option value="">Uncategorized</option>
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">{{ category.label }}</option>
</select>
<label class="form-label mt-3">Parent Task</label> <label class="form-label mt-3">Parent Task</label>
<select v-model="localTask.parent_task_id" class="form-select"> <select v-model="localTask.parent_task_id" class="form-select">
<option value="">No parent (top level)</option> <option value="">No parent (top level)</option>
@@ -83,10 +77,6 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
categoryOptions: {
type: Array,
default: () => [],
},
parentTaskOptions: { parentTaskOptions: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -107,12 +97,12 @@ watch(
localTask.value = { localTask.value = {
title: "", title: "",
description: "", description: "",
category_id: "", task_list_id: "",
status_id: props.statuses[0]?.id || "", status_id: props.statuses[0]?.id || "",
parent_task_id: "", parent_task_id: "",
note_links: [], note_links: [],
...value, ...value,
category_id: value?.category_id || "", task_list_id: value?.task_list_id || "",
parent_task_id: value?.parent_task_id || "", parent_task_id: value?.parent_task_id || "",
note_links: value?.note_links || [], note_links: value?.note_links || [],
}; };
@@ -138,70 +128,10 @@ const stepClass = (status) => {
const saveTask = () => { const saveTask = () => {
emit("save-task", { emit("save-task", {
...localTask.value, ...localTask.value,
category_id: localTask.value.category_id || null, task_list_id: localTask.value.task_list_id || null,
parent_task_id: localTask.value.parent_task_id || null, parent_task_id: localTask.value.parent_task_id || null,
}); });
}; };
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>
.status-progress {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.progress-step {
display: flex;
align-items: center;
gap: 0.45rem;
color: #627086;
}
.progress-step.current {
color: #0f172a;
font-weight: 700;
}
.progress-step.done {
color: #1f7a4d;
}
.dot {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid;
}
.subtask-row {
width: 100%;
margin-top: 0.35rem;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #f8fbff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 0.5rem;
}
/* ── Dark mode ── */
:root[data-bs-theme="dark"] .progress-step {
color: #7a8fa8;
}
:root[data-bs-theme="dark"] .progress-step.current {
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .progress-step.done {
color: #4ade80;
}
:root[data-bs-theme="dark"] .subtask-row {
background: #252b38;
border-color: #3a3f4b;
color: #c8d3e6;
}
</style>
+80
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>
@@ -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>
@@ -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>
+1
View File
@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css"; import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css"; import "highlight.js/styles/github-dark.min.css";
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import "./assets/styles/shared/danger-zone.css";
const app = createApp(App); const app = createApp(App);
+130 -241
View File
@@ -73,7 +73,7 @@
<div class="user-row-actions"> <div class="user-row-actions">
<div class="d-flex gap-2 user-actions-stack"> <div class="d-flex gap-2 user-actions-stack">
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button> <button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -105,7 +105,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button> <button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button> <button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +286,16 @@
:deleting="deletingProviderModal" :deleting="deletingProviderModal"
@close="closeProviderModal" @close="closeProviderModal"
@submit="submitProviderModal" @submit="submitProviderModal"
@delete="deleteProviderFromModal" @delete="requestDeleteProvider"
/>
<ConfirmActionModal
:visible="showDeleteConfirmModal"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:busy="deleteConfirmBusy"
@close="closeDeleteConfirmModal"
@confirm="confirmDeleteAction"
/> />
</template> </template>
@@ -298,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
import AdminGroupModal from "../components/AdminGroupModal.vue"; import AdminGroupModal from "../components/AdminGroupModal.vue";
import AdminUserModal from "../components/AdminUserModal.vue"; import AdminUserModal from "../components/AdminUserModal.vue";
import AdminProviderModal from "../components/AdminProviderModal.vue"; import AdminProviderModal from "../components/AdminProviderModal.vue";
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
const router = useRouter(); const router = useRouter();
const activeTab = ref("users"); const activeTab = ref("users");
@@ -344,6 +354,12 @@ const providerModalMode = ref("create");
const selectedProvider = ref(null); const selectedProvider = ref(null);
const submittingProviderModal = ref(false); const submittingProviderModal = ref(false);
const deletingProviderModal = ref(false); const deletingProviderModal = ref(false);
const showDeleteConfirmModal = ref(false);
const deleteConfirmBusy = ref(false);
const deleteConfirmIntent = ref({
type: "",
payload: null,
});
const loadingFeatureFlags = ref(false); const loadingFeatureFlags = ref(false);
const savingFeatureFlags = ref(false); const savingFeatureFlags = ref(false);
@@ -365,6 +381,47 @@ const clearMessages = () => {
successMessage.value = ""; successMessage.value = "";
}; };
const deleteConfirmTitle = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
return "Delete User";
}
if (deleteConfirmIntent.value.type === "group") {
return "Delete Group";
}
if (deleteConfirmIntent.value.type === "provider") {
return "Delete Identity Provider";
}
return "Confirm Deletion";
});
const deleteConfirmMessage = computed(() => {
if (deleteConfirmIntent.value.type === "user") {
const username = deleteConfirmIntent.value.payload?.username || "this user";
return `Delete user "${username}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "group") {
const name = deleteConfirmIntent.value.payload?.name || "this group";
return `Delete group "${name}"? This action cannot be undone.`;
}
if (deleteConfirmIntent.value.type === "provider") {
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
return `Delete identity provider "${name}"? This action cannot be undone.`;
}
return "Are you sure you want to continue?";
});
const closeDeleteConfirmModal = () => {
if (deleteConfirmBusy.value) {
return;
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
};
const formatDate = (iso) => { const formatDate = (iso) => {
if (!iso) return ""; if (!iso) return "";
return new Date(iso).toLocaleDateString(); return new Date(iso).toLocaleDateString();
@@ -438,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
} }
}; };
const requestDeleteUser = (user) => {
if (!user?.id) {
return;
}
deleteConfirmIntent.value = {
type: "user",
payload: user,
};
showDeleteConfirmModal.value = true;
};
const deleteUser = async (user) => { const deleteUser = async (user) => {
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) { if (!user?.id) {
return; return;
} }
@@ -530,7 +599,7 @@ const deleteGroup = async (group) => {
if (group.is_system) { if (group.is_system) {
return; return;
} }
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) { if (!group?.id) {
return; return;
} }
@@ -541,9 +610,22 @@ const deleteGroup = async (group) => {
await Promise.all([loadGroups(), loadUsers()]); await Promise.all([loadGroups(), loadUsers()]);
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete group."; error.value = e.response?.data || "Failed to delete group.";
throw e;
} }
}; };
const requestDeleteGroup = (group) => {
if (!group?.id || group.is_system) {
return;
}
deleteConfirmIntent.value = {
type: "group",
payload: group,
};
showDeleteConfirmModal.value = true;
};
const loadSpaces = async () => { const loadSpaces = async () => {
loadingSpaces.value = true; loadingSpaces.value = true;
clearMessages(); clearMessages();
@@ -631,12 +713,21 @@ const loadProviders = async () => {
} }
}; };
const deleteProviderFromModal = async (provider) => { const requestDeleteProvider = (provider) => {
if (!provider?.id) { if (!provider?.id) {
return; return;
} }
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) { closeProviderModal();
deleteConfirmIntent.value = {
type: "provider",
payload: { ...provider },
};
showDeleteConfirmModal.value = true;
};
const deleteProviderFromModal = async (provider) => {
if (!provider?.id) {
return; return;
} }
@@ -649,11 +740,42 @@ const deleteProviderFromModal = async (provider) => {
closeProviderModal(); closeProviderModal();
} catch (e) { } catch (e) {
error.value = e.response?.data || "Failed to delete provider."; error.value = e.response?.data || "Failed to delete provider.";
throw e;
} finally { } finally {
deletingProviderModal.value = false; deletingProviderModal.value = false;
} }
}; };
const confirmDeleteAction = async () => {
if (deleteConfirmBusy.value) {
return;
}
const { type, payload } = deleteConfirmIntent.value;
if (!type || !payload) {
return;
}
deleteConfirmBusy.value = true;
try {
if (type === "user") {
await deleteUser(payload);
} else if (type === "group") {
await deleteGroup(payload);
} else if (type === "provider") {
await deleteProviderFromModal(payload);
}
showDeleteConfirmModal.value = false;
deleteConfirmIntent.value = {
type: "",
payload: null,
};
} finally {
deleteConfirmBusy.value = false;
}
};
const loadFeatureFlags = async () => { const loadFeatureFlags = async () => {
loadingFeatureFlags.value = true; loadingFeatureFlags.value = true;
clearMessages(); clearMessages();
@@ -718,237 +840,4 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Admin.css"></style>
.admin-page {
width: 100%;
max-width: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.admin-topbar {
flex-wrap: wrap;
padding: 1rem;
border-bottom: 1px solid #dee2e6;
}
.admin-shell {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
overflow: hidden;
}
.admin-sidebar {
width: 280px;
flex-shrink: 0;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-nav .nav-link {
border-radius: 0.6rem;
color: #495057;
font-weight: 500;
}
.admin-nav .nav-link:hover {
background: #eef2f7;
color: #212529;
}
.admin-nav .nav-link.active {
background: #212529;
color: #fff;
}
.admin-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 1rem;
}
.admin-section {
border-radius: 12px;
}
.users-list .list-group-item {
padding: 1rem;
}
.user-row {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.user-row-main {
flex: 1;
min-width: 0;
}
.user-row-actions {
flex-shrink: 0;
}
.user-actions-stack {
flex-wrap: wrap;
justify-content: flex-end;
}
.user-name-line {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.user-name {
font-size: 1.1rem;
}
.user-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem 1.25rem;
}
.user-meta-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6c757d;
margin-bottom: 0.1rem;
}
.user-meta-value {
color: #495057;
overflow-wrap: anywhere;
}
.user-meta-item-groups {
grid-column: span 1;
}
@media (max-width: 991.98px) {
.user-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-meta-item-groups {
grid-column: 1 / -1;
}
}
@media (max-width: 767.98px) {
.admin-shell {
display: block;
min-height: auto;
}
.admin-topbar {
padding: 0.75rem;
}
.admin-content {
padding: 0.75rem;
}
.admin-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1400;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(82vw, 320px);
z-index: 1410;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-right: 1px solid #dee2e6;
}
.admin-sidebar-inner {
padding: 0.75rem;
}
.admin-sidebar.open {
transform: translateX(0);
}
.user-row {
flex-direction: column;
align-items: stretch;
}
.user-row-actions {
width: 100%;
}
.user-row-actions .btn {
width: 100%;
}
.user-actions-stack {
flex-direction: column;
}
.user-meta-grid {
grid-template-columns: 1fr;
gap: 0.65rem;
}
}
/* Dark mode overrides */
:root[data-bs-theme="dark"] .admin-topbar {
border-bottom-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .admin-sidebar {
background: #21252e;
border-right-color: #3a3f4b;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
background: #2d3748;
color: #e2e8f0;
}
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
background: #e2e8f0;
color: #1a1d23;
}
:root[data-bs-theme="dark"] .user-meta-value {
color: #94a3b8;
}
:root[data-bs-theme="dark"] .admin-section {
background-color: #21252e;
}
</style>
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
<template>
<div class="home-page">
<router-view />
</div>
</template>
<script setup></script>
+1 -113
View File
@@ -142,119 +142,7 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Login.css"></style>
.login-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 460px;
}
.login-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-provider-btn {
min-height: 48px;
border-radius: 0.65rem;
}
.oauth-divider {
display: flex;
align-items: center;
color: #6c757d;
font-size: 0.9rem;
}
.oauth-divider::before,
.oauth-divider::after {
content: "";
flex: 1;
border-bottom: 1px solid #dee2e6;
}
.oauth-divider span {
padding: 0 0.75rem;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.login-page {
padding: 0.85rem;
}
.login-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>
+1 -68
View File
@@ -322,74 +322,7 @@ watch(
); );
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/PublicSpace.css"></style>
.public-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.public-body {
flex: 1;
overflow: hidden;
position: relative;
}
.public-sidebar {
width: 280px;
overflow-y: auto;
flex-shrink: 0;
}
.note-item {
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid transparent;
transition: background 0.15s;
}
.note-item:hover {
background: #e9ecef;
}
.note-item.active {
background: #dbe4ff;
border-color: #748ffc;
color: #364fc7;
}
.note-item.is-featured {
background: #fff4e6;
border-color: #ffd8a8;
}
.note-item.is-featured:hover {
background: #ffe8cc;
}
@media (max-width: 768px) {
.public-sidebar-backdrop {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1090;
}
.public-sidebar {
position: fixed;
left: 0;
bottom: 0;
z-index: 1095;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
.public-sidebar.open {
transform: translateX(0);
}
}
</style>
+1 -90
View File
@@ -110,96 +110,7 @@ onMounted(async () => {
}); });
</script> </script>
<style scoped> <style scoped src="../assets/styles/scoped/pages/Register.css"></style>
.register-page {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding: 1.25rem;
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
}
.auth-container {
width: 100%;
max-width: 560px;
}
.register-card {
background: #fff;
padding: 2rem;
border-radius: 18px;
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
width: 100%;
}
.brand-block {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(53, 84, 209, 0.12);
color: #2f4ac1;
font-size: 1.35rem;
}
.brand-title {
margin: 0;
font-size: 2.05rem;
font-weight: 700;
letter-spacing: 0.01em;
color: #2f3237;
}
.auth-title {
text-align: center;
font-size: 2.1rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #2f3237;
}
.form-control {
border-radius: 0.65rem;
min-height: 48px;
border-color: #d6dbe4;
}
.auth-submit {
min-height: 48px;
font-weight: 600;
}
.auth-switch-link {
color: #4b5565;
}
@media (max-width: 576px) {
.register-page {
padding: 0.85rem;
}
.register-card {
border-radius: 14px;
padding: 1.35rem;
}
.brand-title {
font-size: 1.8rem;
}
.auth-title {
font-size: 1.85rem;
}
}
</style>
+14 -2
View File
@@ -18,13 +18,25 @@ const routes = [
{ {
path: "/", path: "/",
name: "Home", name: "Home",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/n/:noteId?",
name: "DashboardNote",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true },
},
{
path: "/dashboard/s/:spaceId/t/:taskListId",
name: "DashboardTaskList",
component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: "/search", path: "/search",
name: "Search", name: "Search",
component: () => import("../pages/Home.vue"), component: () => import("../pages/Dashboard.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
+3 -1
View File
@@ -1,8 +1,10 @@
import axios from "axios"; import axios from "axios";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
const runtimeOrigin = typeof window !== "undefined" ? window.location.origin : "";
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", baseURL: runtimeOrigin,
withCredentials: true, withCredentials: true,
}); });
+56 -17
View File
@@ -13,12 +13,13 @@ export const useSpaceStore = defineStore("space", () => {
const notesLoading = ref(false); const notesLoading = ref(false);
const categories = ref([]); const categories = ref([]);
const categoryTree = ref([]); const categoryTree = ref([]);
const taskLists = ref([]);
const tasks = ref([]); const tasks = ref([]);
const taskStatuses = ref([]); const taskStatuses = ref([]);
const noteLinkedTasks = ref([]); const noteLinkedTasks = ref([]);
const refreshSpaceData = async (spaceId) => { const refreshSpaceData = async (spaceId) => {
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskStatuses(spaceId), fetchTasks(spaceId)]); await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
}; };
const fetchSpaces = async () => { const fetchSpaces = async () => {
@@ -211,13 +212,13 @@ export const useSpaceStore = defineStore("space", () => {
searchResults.value = []; searchResults.value = [];
}; };
const fetchTaskStatuses = async (spaceId) => { const fetchTaskStatuses = async (spaceId, taskListId) => {
if (!spaceId) { if (!spaceId || !taskListId) {
taskStatuses.value = []; taskStatuses.value = [];
return []; return [];
} }
try { try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-statuses`); const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
return taskStatuses.value; return taskStatuses.value;
} catch (error) { } catch (error) {
@@ -227,25 +228,58 @@ export const useSpaceStore = defineStore("space", () => {
} }
}; };
const createTaskStatus = async (spaceId, payload) => { const fetchTaskLists = async (spaceId) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-statuses`, payload); if (!spaceId) {
await fetchTaskStatuses(spaceId); taskLists.value = [];
return [];
}
try {
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
taskLists.value = response.data || [];
return taskLists.value;
} catch (error) {
console.error("Error fetching task lists:", error);
taskLists.value = [];
return [];
}
};
const createTaskList = async (spaceId, payload) => {
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
await fetchTaskLists(spaceId);
return response.data; return response.data;
}; };
const updateTaskStatus = async (spaceId, statusId, payload) => { const updateTaskList = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`, payload); const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
await fetchTaskStatuses(spaceId); await fetchTaskLists(spaceId);
return response.data; return response.data;
}; };
const deleteTaskStatus = async (spaceId, statusId) => { const deleteTaskList = async (spaceId, taskListId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-statuses/${statusId}`); await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
await fetchTaskStatuses(spaceId); await fetchTaskLists(spaceId);
}; };
const reorderTaskStatuses = async (spaceId, orderedStatusIds) => { const createTaskStatus = async (spaceId, taskListId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-statuses/reorder`, { const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
await fetchTaskStatuses(spaceId, taskListId);
return response.data;
};
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
await fetchTaskStatuses(spaceId, taskListId);
return response.data;
};
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
await fetchTaskStatuses(spaceId, taskListId);
};
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
ordered_status_ids: orderedStatusIds, ordered_status_ids: orderedStatusIds,
}); });
taskStatuses.value = response.data || []; taskStatuses.value = response.data || [];
@@ -258,8 +292,8 @@ export const useSpaceStore = defineStore("space", () => {
return []; return [];
} }
const params = {}; const params = {};
if (filters.categoryId) { if (filters.taskListId) {
params.categoryId = filters.categoryId; params.taskListId = filters.taskListId;
} }
if (filters.statusId) { if (filters.statusId) {
params.statusId = filters.statusId; params.statusId = filters.statusId;
@@ -344,6 +378,7 @@ export const useSpaceStore = defineStore("space", () => {
notesLoading, notesLoading,
categories, categories,
categoryTree, categoryTree,
taskLists,
tasks, tasks,
taskStatuses, taskStatuses,
noteLinkedTasks, noteLinkedTasks,
@@ -363,6 +398,10 @@ export const useSpaceStore = defineStore("space", () => {
searchNotes, searchNotes,
clearSearchResults, clearSearchResults,
fetchTaskStatuses, fetchTaskStatuses,
fetchTaskLists,
createTaskList,
updateTaskList,
deleteTaskList,
createTaskStatus, createTaskStatus,
updateTaskStatus, updateTaskStatus,
deleteTaskStatus, deleteTaskStatus,
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
distDir: "out",
images: {
unoptimized: true,
},
};
export default nextConfig;
+2281
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "noteapp-frontend-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@tiptap/extension-link": "^3.24.0",
"@tiptap/extension-mention": "^3.24.0",
"@tiptap/extension-placeholder": "^3.24.0",
"@tiptap/extension-table": "^3.24.0",
"@tiptap/extension-table-cell": "^3.24.0",
"@tiptap/extension-table-header": "^3.24.0",
"@tiptap/extension-table-row": "^3.24.0",
"@tiptap/extension-task-item": "^3.24.0",
"@tiptap/extension-task-list": "^3.24.0",
"@tiptap/pm": "^3.24.0",
"@tiptap/react": "^3.24.0",
"@tiptap/starter-kit": "^3.24.0",
"@tiptap/suggestion": "^3.24.0",
"axios": "^1.7.0",
"bootstrap": "^5.3.0",
"dompurify": "^3.0.0",
"highlight.js": "^11.11.1",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"next": "16.2.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tippy.js": "^6.3.7",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0"
}
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import Navbar from "@/components/Navbar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const hasPermission = useAuthStore((s) => s.hasPermission);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
ensureInitialized().then(() => {
const state = useAuthStore.getState();
if (!state.user) {
router.replace("/login");
} else if (!state.hasPermission("admin.access") && !state.hasPermission("*")) {
router.replace("/dashboard");
} else {
setAuthChecked(true);
}
});
}, []);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
return (
<div className="app-container">
<nav>
<Navbar />
</nav>
<div className="app-main d-flex flex-column" style={{ overflow: "hidden" }}>
{children}
</div>
</div>
);
}
+887
View File
@@ -0,0 +1,887 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import apiClient from "@/lib/apiClient";
import AdminUserModal from "@/components/AdminUserModal";
import AdminGroupModal from "@/components/AdminGroupModal";
import AdminSpaceModal from "@/components/AdminSpaceModal";
import AdminProviderModal from "@/components/AdminProviderModal";
import ConfirmActionModal from "@/components/ConfirmActionModal";
type AdminTab = "users" | "groups" | "spaces" | "providers" | "featureFlags";
interface AdminUser {
id: string;
username: string;
email: string;
is_active: boolean;
created_at: string;
group_ids?: string[];
}
interface AdminGroup {
id: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
}
interface AdminSpace {
id: string;
name: string;
description: string;
icon?: string;
is_public: boolean;
}
interface AuthProvider {
id: string;
name: string;
type?: string;
client_id?: string;
authorization_url?: string;
token_url?: string;
userinfo_url?: string;
id_token_claim?: string;
scopes?: string[];
is_active: boolean;
}
interface FeatureFlags {
registration_enabled: boolean;
provider_login_enabled: boolean;
public_sharing_enabled: boolean;
file_explorer_enabled: boolean;
s3_endpoint: string;
s3_bucket: string;
s3_region: string;
s3_access_key: string;
s3_secret_key: string;
s3_secret_key_set: boolean;
}
const TABS: Array<{ id: AdminTab; label: string }> = [
{ id: "users", label: "Users" },
{ id: "groups", label: "Permission Groups" },
{ id: "spaces", label: "Spaces" },
{ id: "providers", label: "Identity Providers" },
{ id: "featureFlags", label: "Feature Flags" },
];
const defaultFlags = (): FeatureFlags => ({
registration_enabled: true,
provider_login_enabled: true,
public_sharing_enabled: true,
file_explorer_enabled: false,
s3_endpoint: "",
s3_bucket: "",
s3_region: "",
s3_access_key: "",
s3_secret_key: "",
s3_secret_key_set: false,
});
export default function AdminPage() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<AdminTab>("users");
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
// Users
const [users, setUsers] = useState<AdminUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [showUserModal, setShowUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [submittingUser, setSubmittingUser] = useState(false);
// Groups
const [groups, setGroups] = useState<AdminGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
const [showGroupModal, setShowGroupModal] = useState(false);
const [groupModalMode, setGroupModalMode] = useState<"create" | "edit">("create");
const [selectedGroup, setSelectedGroup] = useState<AdminGroup | null>(null);
const [submittingGroup, setSubmittingGroup] = useState(false);
// Spaces
const [spaces, setSpaces] = useState<AdminSpace[]>([]);
const [loadingSpaces, setLoadingSpaces] = useState(false);
const [showSpaceModal, setShowSpaceModal] = useState(false);
const [selectedSpace, setSelectedSpace] = useState<AdminSpace | null>(null);
// Providers
const [providers, setProviders] = useState<AuthProvider[]>([]);
const [loadingProviders, setLoadingProviders] = useState(false);
const [showProviderModal, setShowProviderModal] = useState(false);
const [providerModalMode, setProviderModalMode] = useState<"create" | "edit">("create");
const [selectedProvider, setSelectedProvider] = useState<AuthProvider | null>(null);
const [submittingProvider, setSubmittingProvider] = useState(false);
// Delete confirm
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmBusy, setConfirmBusy] = useState(false);
const [confirmIntent, setConfirmIntent] = useState<{
type: "user" | "group" | "provider";
payload: AdminUser | AdminGroup | AuthProvider | null;
}>({ type: "user", payload: null });
// Feature flags
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>(defaultFlags());
const [loadingFeatureFlags, setLoadingFeatureFlags] = useState(false);
const [savingFlags, setSavingFlags] = useState(false);
const flash = (msg: string) => {
setSuccessMessage(msg);
setTimeout(() => setSuccessMessage(""), 3500);
};
const clearError = () => setError("");
// ── Loaders ──────────────────────────────────────────────────────────────────
const loadUsers = useCallback(async () => {
setLoadingUsers(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/users");
setUsers(Array.isArray(res.data) ? res.data : res.data?.users || []);
} catch {
setError("Failed to load users.");
} finally {
setLoadingUsers(false);
}
}, []);
const loadGroups = useCallback(async () => {
setLoadingGroups(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/groups");
setGroups(Array.isArray(res.data) ? res.data : res.data?.groups || []);
} catch {
setError("Failed to load groups.");
} finally {
setLoadingGroups(false);
}
}, []);
const loadSpaces = useCallback(async () => {
setLoadingSpaces(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/spaces");
setSpaces(Array.isArray(res.data) ? res.data : res.data?.spaces || []);
} catch {
setError("Failed to load spaces.");
} finally {
setLoadingSpaces(false);
}
}, []);
const loadProviders = useCallback(async () => {
setLoadingProviders(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/auth/providers");
setProviders(res.data?.providers || []);
} catch {
setError("Failed to load providers.");
} finally {
setLoadingProviders(false);
}
}, []);
const loadFeatureFlagsData = useCallback(async () => {
setLoadingFeatureFlags(true);
clearError();
try {
const res = await apiClient.get("/api/v1/admin/feature-flags");
setFeatureFlags({
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
});
} catch {
setError("Failed to load feature flags.");
} finally {
setLoadingFeatureFlags(false);
}
}, []);
useEffect(() => {
loadUsers();
loadGroups();
loadSpaces();
loadProviders();
loadFeatureFlagsData();
}, [loadUsers, loadGroups, loadSpaces, loadProviders, loadFeatureFlagsData]);
const selectTab = (tab: AdminTab) => {
setActiveTab(tab);
setShowMobileSidebar(false);
setError("");
};
// ── User CRUD ─────────────────────────────────────────────────────────────────
const openEditUser = (u: AdminUser) => {
setSelectedUser({ ...u });
setShowUserModal(true);
};
const submitEditUser = async ({ group_ids }: { group_ids: string[] }) => {
if (!selectedUser) return;
setSubmittingUser(true);
clearError();
try {
const res = await apiClient.put(`/api/v1/admin/users/${selectedUser.id}/groups`, { group_ids });
setUsers((prev) => prev.map((u) => (u.id === selectedUser.id ? { ...u, ...res.data } : u)));
flash("User updated.");
setShowUserModal(false);
setSelectedUser(null);
} catch {
setError("Failed to update user groups.");
} finally {
setSubmittingUser(false);
}
};
const requestDeleteUser = (u: AdminUser) => {
setConfirmIntent({ type: "user", payload: u });
setConfirmVisible(true);
};
// ── Group CRUD ────────────────────────────────────────────────────────────────
const openCreateGroup = () => {
setGroupModalMode("create");
setSelectedGroup(null);
setShowGroupModal(true);
};
const openEditGroup = (g: AdminGroup) => {
setGroupModalMode("edit");
setSelectedGroup({ ...g });
setShowGroupModal(true);
};
const submitGroupModal = async (data: { name: string; description: string; permissions: string[] }) => {
setSubmittingGroup(true);
clearError();
try {
if (groupModalMode === "create") {
await apiClient.post("/api/v1/admin/groups", data);
flash("Group created.");
} else {
await apiClient.put(`/api/v1/admin/groups/${selectedGroup!.id}`, data);
flash("Group updated.");
}
setShowGroupModal(false);
setSelectedGroup(null);
await Promise.all([loadGroups(), loadUsers()]);
} catch {
setError(`Failed to ${groupModalMode === "create" ? "create" : "update"} group.`);
} finally {
setSubmittingGroup(false);
}
};
const requestDeleteGroup = (g: AdminGroup) => {
if (g.is_system) return;
setConfirmIntent({ type: "group", payload: g });
setConfirmVisible(true);
};
// ── Space CRUD ────────────────────────────────────────────────────────────────
const openEditSpace = (s: AdminSpace) => {
setSelectedSpace({ ...s });
setShowSpaceModal(true);
};
const onSpaceSaved = (updated: AdminSpace) => {
setSpaces((prev) => prev.map((s) => (s.id === updated.id ? { ...s, ...updated } : s)));
setSelectedSpace(updated);
flash("Space updated.");
};
const onSpaceDeleted = (deleted: AdminSpace) => {
setSpaces((prev) => prev.filter((s) => s.id !== deleted.id));
setShowSpaceModal(false);
setSelectedSpace(null);
flash(`Space "${deleted.name}" deleted.`);
};
// ── Provider CRUD ─────────────────────────────────────────────────────────────
const openCreateProvider = () => {
setProviderModalMode("create");
setSelectedProvider(null);
setShowProviderModal(true);
};
const openEditProvider = (p: AuthProvider) => {
setProviderModalMode("edit");
setSelectedProvider({ ...p });
setShowProviderModal(true);
};
const submitProviderModal = async (formData: object) => {
setSubmittingProvider(true);
clearError();
try {
if (providerModalMode === "create") {
await apiClient.post("/api/v1/admin/auth/providers", formData);
flash("Provider added.");
} else {
await apiClient.put(`/api/v1/admin/auth/providers/${selectedProvider!.id}`, formData);
flash("Provider updated.");
}
setShowProviderModal(false);
setSelectedProvider(null);
await loadProviders();
} catch {
setError(`Failed to ${providerModalMode === "create" ? "add" : "update"} provider.`);
} finally {
setSubmittingProvider(false);
}
};
const requestDeleteProvider = (p: AuthProvider) => {
setShowProviderModal(false);
setConfirmIntent({ type: "provider", payload: { ...p } });
setConfirmVisible(true);
};
// ── Confirm delete ────────────────────────────────────────────────────────────
const confirmDelete = async () => {
if (confirmBusy) return;
setConfirmBusy(true);
clearError();
try {
const { type, payload } = confirmIntent;
if (type === "user") {
const u = payload as AdminUser;
await apiClient.delete(`/api/v1/admin/users/${u.id}`);
setUsers((prev) => prev.filter((x) => x.id !== u.id));
flash(`User "${u.username}" deleted.`);
} else if (type === "group") {
const g = payload as AdminGroup;
await apiClient.delete(`/api/v1/admin/groups/${g.id}`);
flash(`Group "${g.name}" deleted.`);
await Promise.all([loadGroups(), loadUsers()]);
} else if (type === "provider") {
const p = payload as AuthProvider;
await apiClient.delete(`/api/v1/admin/auth/providers/${p.id}`);
setProviders((prev) => prev.filter((x) => x.id !== p.id));
flash(`Provider "${p.name}" deleted.`);
}
setConfirmVisible(false);
setConfirmIntent({ type: "user", payload: null });
} catch {
setError("Delete failed.");
} finally {
setConfirmBusy(false);
}
};
const closeConfirm = () => {
if (confirmBusy) return;
setConfirmVisible(false);
};
// ── Feature Flags ─────────────────────────────────────────────────────────────
const saveFeatureFlags = async () => {
setSavingFlags(true);
clearError();
try {
const res = await apiClient.put("/api/v1/admin/feature-flags", {
registration_enabled: featureFlags.registration_enabled,
provider_login_enabled: featureFlags.provider_login_enabled,
public_sharing_enabled: featureFlags.public_sharing_enabled,
file_explorer_enabled: featureFlags.file_explorer_enabled,
s3_endpoint: featureFlags.s3_endpoint,
s3_bucket: featureFlags.s3_bucket,
s3_region: featureFlags.s3_region,
s3_access_key: featureFlags.s3_access_key,
s3_secret_key: featureFlags.s3_secret_key,
});
setFeatureFlags({
registration_enabled: !!res.data.registration_enabled,
provider_login_enabled: !!res.data.provider_login_enabled,
public_sharing_enabled: !!res.data.public_sharing_enabled,
file_explorer_enabled: !!res.data.file_explorer_enabled,
s3_endpoint: res.data.s3_endpoint || "",
s3_bucket: res.data.s3_bucket || "",
s3_region: res.data.s3_region || "",
s3_access_key: res.data.s3_access_key || "",
s3_secret_key: "",
s3_secret_key_set: !!res.data.s3_secret_key_set,
});
flash("Feature flags saved.");
} catch {
setError("Failed to save feature flags.");
} finally {
setSavingFlags(false);
}
};
// ── Helpers ───────────────────────────────────────────────────────────────────
const formatDate = (d: string) => (d ? new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "—");
const getUserGroupSummary = (u: AdminUser) => {
const ids = u.group_ids || [];
if (!ids.length) return "No groups";
const names = ids.map((id) => groups.find((g) => g.id === id)?.name).filter(Boolean);
return names.length ? names.join(", ") : "No groups";
};
const confirmTitle = confirmIntent.type === "user" ? "Delete User" : confirmIntent.type === "group" ? "Delete Group" : "Delete Identity Provider";
const confirmMessage =
confirmIntent.type === "user"
? `Delete user "${(confirmIntent.payload as AdminUser)?.username}"? This cannot be undone.`
: confirmIntent.type === "group"
? `Delete group "${(confirmIntent.payload as AdminGroup)?.name}"? This cannot be undone.`
: `Delete identity provider "${(confirmIntent.payload as AuthProvider)?.name}"? This cannot be undone.`;
// ── Render ────────────────────────────────────────────────────────────────────
return (
<div className="admin-page">
<div className="admin-topbar d-flex justify-content-between align-items-center">
<button className="btn btn-outline-secondary d-md-none" type="button" aria-label="Open admin navigation" onClick={() => setShowMobileSidebar(true)}>
<i className="mdi mdi-menu" aria-hidden="true" />
</button>
<div className="d-flex align-items-start gap-2">
<div>
<h2 className="mb-1">Admin Panel</h2>
<p className="text-muted mb-0">Manage users, groups, spaces, and identity providers.</p>
</div>
</div>
<button className="btn btn-outline-secondary" onClick={() => router.push("/dashboard")}>
Back to Notes
</button>
</div>
{error && <div className="alert alert-danger mx-3 mt-2">{error}</div>}
{successMessage && <div className="alert alert-success mx-3 mt-2">{successMessage}</div>}
<div className="admin-shell">
{showMobileSidebar && <div className="admin-sidebar-backdrop" onClick={() => setShowMobileSidebar(false)} />}
<aside className={`admin-sidebar${showMobileSidebar ? " open" : ""}`}>
<div className="admin-sidebar-inner">
<div className="d-flex justify-content-between align-items-center px-2 py-1 d-md-none">
<h6 className="mb-0">Admin Sections</h6>
<button type="button" className="btn-close" aria-label="Close" onClick={() => setShowMobileSidebar(false)} />
</div>
<nav className="nav nav-pills flex-column gap-1 admin-nav mt-2">
{TABS.map((tab) => (
<button key={tab.id} className={`nav-link text-start${activeTab === tab.id ? " active" : ""}`} onClick={() => selectTab(tab.id)}>
{tab.label}
</button>
))}
</nav>
</div>
</aside>
<main className="admin-content">
{/* ── Users ── */}
{activeTab === "users" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">All Users</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingUsers} onClick={loadUsers}>
Refresh
</button>
</div>
{loadingUsers ? (
<div className="text-muted small">Loading users</div>
) : users.length === 0 ? (
<div className="border rounded p-3 text-muted">No users found.</div>
) : (
<div className="list-group users-list">
{users.map((u) => (
<div key={u.id} className="list-group-item user-row">
<div className="user-row-main">
<div className="user-name-line">
<span className="fw-semibold user-name">{u.username}</span>
<span className={`badge ${u.is_active ? "text-bg-success" : "text-bg-secondary"}`}>{u.is_active ? "Active" : "Inactive"}</span>
</div>
<div className="user-meta-grid">
<div className="user-meta-item">
<div className="user-meta-label">Email</div>
<div className="user-meta-value">{u.email}</div>
</div>
<div className="user-meta-item">
<div className="user-meta-label">Joined</div>
<div className="user-meta-value">{formatDate(u.created_at)}</div>
</div>
<div className="user-meta-item">
<div className="user-meta-label">Groups</div>
<div className="user-meta-value">{getUserGroupSummary(u)}</div>
</div>
</div>
</div>
<div className="user-row-actions">
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditUser(u)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => requestDeleteUser(u)}>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Groups ── */}
{activeTab === "groups" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Permission Groups</h5>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-secondary" disabled={loadingGroups} onClick={loadGroups}>
Refresh
</button>
<button className="btn btn-sm btn-primary" onClick={openCreateGroup}>
Create Group
</button>
</div>
</div>
{loadingGroups ? (
<div className="text-muted small">Loading groups</div>
) : groups.length === 0 ? (
<div className="border rounded p-3 text-muted">No groups created yet.</div>
) : (
<div className="list-group">
{groups.map((g) => (
<div key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold d-flex align-items-center gap-2">
<span>{g.name}</span>
{g.is_system && <span className="badge text-bg-dark">System</span>}
</div>
<div className="small text-muted">{g.description || "No description"}</div>
<div className="small text-muted">
{(g.permissions || []).length} permission{(g.permissions || []).length === 1 ? "" : "s"}
</div>
</div>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditGroup(g)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" disabled={g.is_system} onClick={() => requestDeleteGroup(g)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Spaces ── */}
{activeTab === "spaces" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">All Spaces</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingSpaces} onClick={loadSpaces}>
Refresh
</button>
</div>
{loadingSpaces ? (
<div className="text-muted small">Loading spaces</div>
) : spaces.length === 0 ? (
<div className="border rounded p-3 text-muted">No spaces found.</div>
) : (
<div className="list-group">
{spaces.map((s) => (
<div key={s.id} className="list-group-item d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold">{s.name}</div>
<div className="small text-muted">{s.description || "No description"}</div>
</div>
<div className="d-flex align-items-center gap-2">
<span className={`badge ${s.is_public ? "text-bg-success" : "text-bg-secondary"}`}>{s.is_public ? "Public" : "Private"}</span>
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditSpace(s)}>
Edit Space
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Providers ── */}
{activeTab === "providers" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Identity Providers</h5>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-secondary" disabled={loadingProviders} onClick={loadProviders}>
Refresh
</button>
<button className="btn btn-sm btn-primary" onClick={openCreateProvider}>
<i className="mdi mdi-plus me-1" aria-hidden="true" />
Add Provider
</button>
</div>
</div>
{loadingProviders ? (
<div className="text-muted small">Loading providers</div>
) : providers.length === 0 ? (
<div className="border rounded p-3 text-muted">No providers configured yet.</div>
) : (
<div className="list-group">
{providers.map((p) => (
<div key={p.id} className="list-group-item d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
<i className={`mdi ${p.is_active ? "mdi-check-circle text-success" : "mdi-close-circle text-secondary"}`} aria-hidden="true" />
<span className="fw-semibold">{p.name}</span>
</div>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditProvider(p)}>
Edit
</button>
</div>
))}
</div>
)}
</div>
</section>
)}
{/* ── Feature Flags ── */}
{activeTab === "featureFlags" && (
<section className="admin-section card border-0 shadow-sm">
<div className="card-body">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">Application Feature Flags</h5>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingFeatureFlags} onClick={loadFeatureFlagsData}>
Refresh
</button>
</div>
{loadingFeatureFlags ? (
<div className="text-muted small">Loading feature flags</div>
) : (
<div className="d-grid gap-3">
<FlagItem
id="flag-registration"
title="Enable User Registration"
description="Controls whether new users can sign up from the register page."
checked={featureFlags.registration_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, registration_enabled: v }))}
/>
<FlagItem
id="flag-provider-login"
title="Enable Provider Login"
description="Controls OAuth/OIDC sign-in buttons and provider login endpoints."
checked={featureFlags.provider_login_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, provider_login_enabled: v }))}
/>
<FlagItem
id="flag-public-sharing"
title="Enable Public Sharing"
description="Reserved for public content controls and future sharing gates."
checked={featureFlags.public_sharing_enabled}
onChange={(v) => setFeatureFlags((f) => ({ ...f, public_sharing_enabled: v }))}
/>
{/* File Explorer with S3 config */}
<div className="feature-flag-item border rounded p-3">
<div className={`d-flex justify-content-between align-items-center${featureFlags.file_explorer_enabled ? " mb-3" : ""}`}>
<div>
<div className="fw-semibold">Enable File Explorer</div>
<div className="small text-muted">Allow users to browse and insert files from an S3 bucket directly into notes.</div>
</div>
<div className="form-check form-switch m-0">
<input
id="flag-file-explorer"
className="form-check-input"
type="checkbox"
checked={featureFlags.file_explorer_enabled}
onChange={(e) => setFeatureFlags((f) => ({ ...f, file_explorer_enabled: e.target.checked }))}
/>
</div>
</div>
{featureFlags.file_explorer_enabled && (
<div className="row g-2 mt-1">
<div className="col-md-6">
<label className="form-label small mb-1">S3 Endpoint URL</label>
<input
type="url"
className="form-control form-control-sm"
placeholder="https://s3.amazonaws.com or custom endpoint"
value={featureFlags.s3_endpoint}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_endpoint: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label small mb-1">Bucket Name</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="my-bucket"
value={featureFlags.s3_bucket}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_bucket: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Region</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="us-east-1"
value={featureFlags.s3_region}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_region: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Access Key</label>
<input
type="text"
className="form-control form-control-sm"
autoComplete="off"
value={featureFlags.s3_access_key}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_access_key: e.target.value }))}
/>
</div>
<div className="col-md-4">
<label className="form-label small mb-1">Secret Key</label>
<input
type="password"
className="form-control form-control-sm"
placeholder={featureFlags.s3_secret_key_set ? "Leave blank to keep current secret" : "Enter secret key"}
autoComplete="new-password"
value={featureFlags.s3_secret_key}
onChange={(e) => setFeatureFlags((f) => ({ ...f, s3_secret_key: e.target.value }))}
/>
{featureFlags.s3_secret_key_set && !featureFlags.s3_secret_key && (
<div className="small text-success mt-1">
<i className="mdi mdi-check-circle-outline" aria-hidden="true" /> Secret key is set
</div>
)}
</div>
</div>
)}
</div>
<div className="d-flex justify-content-end">
<button className="btn btn-primary" onClick={saveFeatureFlags} disabled={savingFlags}>
{savingFlags ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Saving
</>
) : (
"Save Changes"
)}
</button>
</div>
</div>
)}
</div>
</section>
)}
</main>
</div>
{/* Modals */}
{showUserModal && selectedUser && (
<AdminUserModal
user={selectedUser}
groups={groups}
submitting={submittingUser}
onClose={() => {
setShowUserModal(false);
setSelectedUser(null);
}}
onSubmit={submitEditUser}
/>
)}
{showGroupModal && (
<AdminGroupModal
mode={groupModalMode}
group={selectedGroup}
submitting={submittingGroup}
onClose={() => {
setShowGroupModal(false);
setSelectedGroup(null);
}}
onSubmit={submitGroupModal}
/>
)}
{showSpaceModal && selectedSpace && (
<AdminSpaceModal
space={selectedSpace}
users={users}
onClose={() => {
setShowSpaceModal(false);
setSelectedSpace(null);
}}
onSaved={onSpaceSaved}
onDeleted={onSpaceDeleted}
/>
)}
{showProviderModal && (
<AdminProviderModal
mode={providerModalMode}
provider={selectedProvider}
submitting={submittingProvider}
onClose={() => {
setShowProviderModal(false);
setSelectedProvider(null);
}}
onSubmit={submitProviderModal}
onDelete={requestDeleteProvider}
/>
)}
<ConfirmActionModal visible={confirmVisible} title={confirmTitle} message={confirmMessage} busy={confirmBusy} onClose={closeConfirm} onConfirm={confirmDelete} />
</div>
);
}
function FlagItem({ id, title, description, checked, onChange }: { id: string; title: string; description: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="feature-flag-item border rounded p-3 d-flex justify-content-between align-items-center">
<div>
<div className="fw-semibold">{title}</div>
<div className="small text-muted">{description}</div>
</div>
<div className="form-check form-switch m-0">
<input id={id} className="form-check-input" type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { Space, useSpaceStore } from "@/stores/spaceStore";
import Navbar from "@/components/Navbar";
import Sidebar from "@/components/Sidebar";
import SpaceSettingsModal from "@/components/SpaceSettingsModal";
import apiClient from "@/lib/apiClient";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const fetchSpaces = useSpaceStore((s) => s.fetchSpaces);
const currentSpace = useSpaceStore((s) => s.currentSpace!);
const [authChecked, setAuthChecked] = useState(false);
const [showSidebar, setShowSidebar] = useState(false);
const navbarRef = useRef<HTMLElement>(null);
const [navbarHeight, setNavbarHeight] = useState(56);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
const [showSpaceSettingsModal, setShowSpaceSettingsModal] = useState(false);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
fetchSpaces();
}
});
}, []);
useEffect(() => {
const el = navbarRef.current;
if (!el) return;
setNavbarHeight(el.offsetHeight);
const ro = new ResizeObserver(() => setNavbarHeight(el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, [authChecked]);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
function handleCreateCategory(name: string) {
apiClient.post(`/api/v1/spaces/${currentSpace?.id}/categories`, { name }).then(() => {
useSpaceStore.getState().fetchCategories(currentSpace?.id || "");
});
}
function handleSpaceSaved(_updatedSpace: Space) {
useSpaceStore.getState().fetchSpaces();
setShowSpaceSettingsModal(false);
}
function handleSpaceDeleted() {
useSpaceStore.getState().fetchSpaces();
setShowSpaceSettingsModal(false);
}
return (
<>
<div className="app-container">
<nav ref={navbarRef}>
<Navbar onToggleSidebar={() => setShowSidebar((v) => !v)} showSidebarToggle />
</nav>
<div className="app-main d-flex">
<Sidebar
open={showSidebar}
onClose={() => setShowSidebar(false)}
navbarHeight={navbarHeight}
onOpenCreateCategory={() => setShowCreateCategoryModal(true)}
onOpenSpaceSettings={() => setShowSpaceSettingsModal(true)}
/>
<main className="main-content flex-grow-1">{children}</main>
</div>
</div>
{showCreateCategoryModal && (
<CreateCategoryModal
onClose={() => setShowCreateCategoryModal(false)}
onSave={(name) => {
handleCreateCategory(name);
setShowCreateCategoryModal(false);
}}
/>
)}
{showSpaceSettingsModal && currentSpace && (
<SpaceSettingsModal
space={currentSpace}
onClose={() => setShowSpaceSettingsModal(false)}
onSaved={handleSpaceSaved}
onDeleted={handleSpaceDeleted}
/>
)}
</>
);
}
function CreateCategoryModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string) => void }) {
const [categoryName, setCategoryName] = useState("");
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") onSave(categoryName);
}
return (
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Create Category</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
<div className="mb-3">
<label htmlFor="categoryName" className="form-label">
Category Name
</label>
<input
type="text"
className="form-control"
id="categoryName"
placeholder="Enter category name"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="button" className="btn btn-primary" onClick={() => onSave(categoryName)} disabled={!categoryName.trim()}>
Create
</button>
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type TaskList, type Category } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const notes = useSpaceStore((s) => s.notes);
const taskLists = useSpaceStore((s) => s.taskLists);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const notesLoading = useSpaceStore((s) => s.notesLoading);
const notesHasMore = useSpaceStore((s) => s.notesHasMore);
const fetchNotes = useSpaceStore((s) => s.fetchNotes);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const hasPermission = useAuthStore((s) => s.hasPermission);
const [searchQuery, setSearchQuery] = useState(searchParams?.get("search") ?? "");
const [searchResults, setSearchResults] = useState<Note[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const canCreateNotes = hasPermission("*") || hasSpacePermission(currentSpace, "notes.create");
const canCreateTasklists = hasPermission("*") || hasSpacePermission(currentSpace, "tasklists.create");
const canCreateSpaces = hasPermission("*") || hasPermission("spaces.create");
useEffect(() => {
const q = searchParams?.get("search");
if (q && currentSpace) {
setSearchQuery(q);
doSearch(q, currentSpace.id);
} else {
setSearchQuery("");
setSearchResults([]);
}
}, [searchParams, currentSpace]);
async function doSearch(q: string, spaceId: string) {
if (!q.trim()) return;
setSearchLoading(true);
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/search`, {
params: { q, limit: 50 },
});
setSearchResults(res.data || []);
} catch {
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}
const isSearch = !!(searchQuery && searchParams?.get("search"));
// Group notes and task lists by category id
const notesByCategory: Record<string, Note[]> = {};
const taskListsByCategory: Record<string, TaskList[]> = {};
const uncategorizedNotes: Note[] = [];
const uncategorizedTaskLists: TaskList[] = [];
for (const note of notes) {
if (note.category_id) {
(notesByCategory[note.category_id] ??= []).push(note);
} else {
uncategorizedNotes.push(note);
}
}
for (const tl of taskLists) {
if (tl.category_id) {
(taskListsByCategory[tl.category_id] ??= []).push(tl);
} else {
uncategorizedTaskLists.push(tl);
}
}
// Flatten category tree for lookup
function flattenTree(cats: Category[]): Category[] {
const result: Category[] = [];
function walk(list: Category[]) {
for (const c of list) {
result.push(c);
const children = c.subcategories ?? c.children ?? [];
if (children.length) walk(children);
}
}
walk(cats);
return result;
}
const flatCategories = flattenTree(categoryTree);
// ── No spaces ──────────────────────────────────────────────────────────────
if (spaces.length === 0) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center py-5">
<i className="mdi mdi-folder-outline" style={{ fontSize: "5rem", color: "#6c757d", display: "block", marginBottom: "1rem" }} />
<h2 className="text-muted mb-3">No Spaces Yet</h2>
<p className="text-muted mb-4">Create a space to start organising your notes.</p>
{canCreateSpaces && (
<button className="btn btn-primary">
<i className="mdi mdi-plus-circle-outline me-2" />
Create Your First Space
</button>
)}
</div>
</div>
);
}
// ── No space selected ──────────────────────────────────────────────────────
if (!currentSpace) {
return (
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center py-5">
<i className="mdi mdi-arrow-up-left" style={{ fontSize: "3rem", color: "#6c757d", display: "block", marginBottom: "1rem" }} />
<h4 className="text-muted">Select a space to get started</h4>
</div>
</div>
);
}
return (
<>
{/* Toolbar */}
<div className="toolbar p-3 border-bottom">
<div className="row align-items-center">
<div className="col">
<h5 className="mb-0 breadcrumb-title">{currentSpace.name}</h5>
</div>
</div>
</div>
{/* Content */}
<div className="content overflow-auto flex-grow-1">
{/* Search results */}
{isSearch ? (
<div className="p-3">
<div className="mb-3 text-muted small border-bottom pb-2">
Search results for <strong>&quot;{searchQuery}&quot;</strong>
{!searchLoading && `${searchResults.length} found`}
</div>
{searchLoading ? (
<div className="d-flex align-items-center justify-content-center p-5">
<div className="spinner-border text-secondary" role="status" />
</div>
) : searchResults.length === 0 ? (
<p className="text-muted">No results found.</p>
) : (
<div className="notes-grid">
{searchResults.map((note) => (
<NoteCard
key={note.id}
note={note}
onClick={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${note.id}`)}
/>
))}
</div>
)}
</div>
) : notesLoading && notes.length === 0 ? (
<div className="d-flex align-items-center justify-content-center p-5">
<div className="spinner-border text-secondary" role="status" />
</div>
) : (
<div className="p-3">
{/* Category sections */}
{flatCategories.map((cat) => {
const catNotes = notesByCategory[cat.id] ?? [];
const catTaskLists = taskListsByCategory[cat.id] ?? [];
return (
<CategorySection
key={cat.id}
category={cat}
notes={catNotes}
taskLists={catTaskLists}
canCreateNotes={canCreateNotes}
canCreateTasklists={canCreateTasklists}
onNoteClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${id}`)}
onTaskListClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/${id}`)}
onNewNote={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/new?categoryId=${cat.id}`)}
onNewTaskList={() => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/new?categoryId=${cat.id}`)}
/>
);
})}
{/* Uncategorized section — only show if there are items */}
{(uncategorizedNotes.length > 0 || uncategorizedTaskLists.length > 0) && (
<CategorySection
category={null}
notes={uncategorizedNotes}
taskLists={uncategorizedTaskLists}
canCreateNotes={canCreateNotes}
canCreateTasklists={canCreateTasklists}
onNoteClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/notes/${id}`)}
onTaskListClick={(id) => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/${id}`)}
onNewNote={() => router.push(`/dashboard/spaces/${currentSpace.id}/notes/new`)}
onNewTaskList={() => router.push(`/dashboard/spaces/${currentSpace.id}/tasklists/new`)}
/>
)}
{/* Empty space */}
{flatCategories.length === 0 && uncategorizedNotes.length === 0 && uncategorizedTaskLists.length === 0 && (
<div className="text-center py-5">
<i className="mdi mdi-note-outline" style={{ fontSize: "3rem", color: "#6c757d", display: "block" }} />
<p className="text-muted mt-2">No content yet. Create a category in the sidebar to get started.</p>
</div>
)}
{notesHasMore && (
<div className="text-center p-3">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => fetchNotes(currentSpace.id, { reset: false })}
disabled={notesLoading}
>
{notesLoading ? "Loading…" : "Load more notes"}
</button>
</div>
)}
</div>
)}
</div>
</>
);
}
function CategorySection({
category,
notes,
taskLists,
canCreateNotes,
canCreateTasklists,
onNoteClick,
onTaskListClick,
onNewNote,
onNewTaskList,
}: {
category: Category | null;
notes: Note[];
taskLists: TaskList[];
canCreateNotes: boolean;
canCreateTasklists: boolean;
onNoteClick: (id: string) => void;
onTaskListClick: (id: string) => void;
onNewNote: () => void;
onNewTaskList: () => void;
}) {
return (
<div className="category-section mb-4">
<div className="category-section-header d-flex align-items-center gap-2 mb-2 pb-1 border-bottom">
<i className="mdi mdi-folder-outline text-muted" style={{ fontSize: "1rem" }} />
<span className="fw-semibold text-muted" style={{ fontSize: "0.85rem", letterSpacing: "0.04em", textTransform: "uppercase" }}>
{category?.name ?? "Uncategorized"}
</span>
</div>
<div className="category-items-row">
{notes.map((note) => (
<NoteCard key={note.id} note={note} onClick={() => onNoteClick(note.id)} />
))}
{taskLists.map((tl) => (
<TaskListCard key={tl.id} taskList={tl} onClick={() => onTaskListClick(tl.id)} />
))}
{canCreateNotes && (
<CreateCard icon="mdi-note-plus-outline" label="New Note" onClick={onNewNote} />
)}
{canCreateTasklists && (
<CreateCard icon="mdi-format-list-checkbox" label="New Task List" onClick={onNewTaskList} />
)}
</div>
</div>
);
}
function NoteCard({ note, onClick }: { note: Note; onClick: () => void }) {
return (
<div className="content-card note-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon">
<i className="mdi mdi-note-text-outline" />
</div>
<div className="content-card-title">
{note.is_password_protected && <i className="mdi mdi-lock-outline me-1" style={{ fontSize: "0.8rem" }} />}
{note.title || "Untitled"}
</div>
<div className="content-card-meta">{new Date(note.updated_at).toLocaleDateString()}</div>
</div>
);
}
function TaskListCard({ taskList, onClick }: { taskList: TaskList; onClick: () => void }) {
return (
<div className="content-card task-list-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon">
<i className="mdi mdi-format-list-checkbox" />
</div>
<div className="content-card-title">{taskList.name}</div>
{taskList.description && <div className="content-card-meta">{taskList.description.slice(0, 60)}</div>}
</div>
);
}
function CreateCard({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<div className="content-card create-card" onClick={onClick} role="button" tabIndex={0}>
<div className="content-card-icon create-icon">
<i className={`mdi ${icon}`} />
</div>
<div className="content-card-title">{label}</div>
</div>
);
}
@@ -0,0 +1,572 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type Category, type TaskList } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
import RichTextEditor from "@/components/RichTextEditor";
/** Read real URL params from window.location useParams() returns static
* placeholder values in a Next.js static export. */
function getNoteParams(): { spaceId: string; noteId: string } {
if (typeof window === "undefined") return { spaceId: "", noteId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/notes\/([^/]+)/);
return { spaceId: m?.[1] ?? "", noteId: m?.[2] ?? "" };
}
type PasswordMode = "keep" | "set" | "remove";
export default function NotePage() {
const { spaceId, noteId } = getNoteParams();
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const user = useAuthStore((s) => s.user);
const hasPermission = useAuthStore((s) => s.hasPermission);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const fetchCategories = useSpaceStore((s) => s.fetchCategories);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const selectSpace = useSpaceStore((s) => s.selectSpace);
const [authChecked, setAuthChecked] = useState(false);
const [note, setNote] = useState<Note | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [isEditing, setIsEditing] = useState(false);
// Password-lock state
const [unlocked, setUnlocked] = useState(false);
const [unlockPassword, setUnlockPassword] = useState("");
const [unlockError, setUnlockError] = useState("");
const [unlocking, setUnlocking] = useState(false);
// Editor state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState<string>("");
const [isPinned, setIsPinned] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [passwordMode, setPasswordMode] = useState<PasswordMode>("keep");
const [newPassword, setNewPassword] = useState("");
const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "dirty">("saved");
// Task lists for @TaskList mentions
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flat list of categories for dropdown
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
// Ensure space is selected
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
} else {
fetchCategories(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
loadNote();
}, [authChecked]);
async function loadNote() {
setLoading(true);
setError("");
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
const n: Note = res.data;
setNote(n);
setUnlocked(!n.is_password_protected);
if (!n.is_password_protected) populateEditor(n);
loadTaskLists();
} catch (e: unknown) {
const err = e as { response?: { status: number } };
if (err?.response?.status === 403) {
setError("Access denied.");
} else {
setError("Failed to load note.");
}
} finally {
setLoading(false);
}
}
async function unlockNote() {
if (!unlockPassword.trim()) {
setUnlockError("Password is required.");
return;
}
setUnlocking(true);
setUnlockError("");
try {
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/notes/${noteId}/unlock`, {
password: unlockPassword,
});
const n: Note = res.data;
setNote(n);
populateEditor(n);
setUnlocked(true);
setUnlockPassword("");
} catch {
setUnlockError("Incorrect password.");
} finally {
setUnlocking(false);
}
}
async function loadTaskLists() {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
setTaskLists(Array.isArray(res.data) ? res.data : []);
} catch {
// ignore
}
}
async function fetchTasksForList(taskListId: string) {
const [tasksRes, statusRes] = await Promise.all([
apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params: { taskListId } }),
apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`),
]);
const tasks = Array.isArray(tasksRes.data) ? tasksRes.data : [];
const statuses = Array.isArray(statusRes.data) ? statusRes.data : [];
const statusMap = new Map(statuses.map((s: { id: string; name: string; color: string }) => [s.id, s]));
return tasks
.filter((t: { parent_task_id: string | null }) => !t.parent_task_id)
.map((t: { id: string; title: string; status_id: string }) => {
const status = statusMap.get(t.status_id) as { name: string; color: string } | undefined;
return {
id: t.id,
title: t.title,
statusColor: status?.color ?? "#7c8596",
statusName: status?.name ?? "",
};
});
}
function populateEditor(n: Note) {
setTitle(n.title);
setDescription(n.description ?? "");
setContent(n.content ?? "");
setTags((n.tags ?? []).join(", "));
setCategoryId(n.category_id ?? "");
setIsPinned(n.is_pinned);
setIsFavorite(n.is_favorite);
setIsPublic(n.is_public);
setPasswordMode("keep");
setNewPassword("");
setSaveStatus("saved");
}
function startEditing() {
if (note) populateEditor(note);
setIsEditing(true);
}
function cancelEditing() {
setIsEditing(false);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
}
// Auto-save with 3s debounce
const scheduleSave = useCallback(() => {
setSaveStatus("dirty");
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
performSave();
}, 3000);
}, [title, description, content, tags, categoryId, isPinned, isFavorite, isPublic, passwordMode, newPassword]);
async function performSave() {
setSaveStatus("saving");
try {
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const catId = categoryId || null;
const body: Record<string, unknown> = {
title,
description,
content,
tags: tagList,
category_id: catId,
is_pinned: isPinned,
is_favorite: isFavorite,
is_public: isPublic,
};
if (passwordMode === "set" && newPassword) {
body.note_password = newPassword;
} else if (passwordMode === "remove") {
body.note_password = "";
}
const res = await apiClient.put(`/api/v1/spaces/${spaceId}/notes/${noteId}`, body);
setNote(res.data);
setSaveStatus("saved");
} catch {
setSaveStatus("dirty");
}
}
async function deleteNote() {
if (!confirm("Delete this note? This cannot be undone.")) return;
try {
await apiClient.delete(`/api/v1/spaces/${spaceId}/notes/${noteId}`);
router.push(`/dashboard`);
} catch {
alert("Failed to delete note.");
}
}
const canEdit = hasPermission("*") || hasSpacePermission(currentSpace, "notes.edit");
const canDelete = hasPermission("*") || hasSpacePermission(currentSpace, "notes.delete");
if (!authChecked || loading) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<div className="alert alert-danger">{error}</div>
<button className="btn btn-secondary" onClick={() => router.push("/dashboard")}>
Dashboard
</button>
</div>
);
}
if (!note) return null;
const flatCategories = flattenCategories(categoryTree);
// --- EDITOR MODE ---
if (isEditing) {
return (
<div className="note-editor-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
<button className="btn btn-sm btn-primary" onClick={performSave} disabled={saveStatus === "saving"}>
<i className="mdi mdi-content-save me-1" />
Save
</button>
<span className={`badge ms-2 ${saveStatus === "saved" ? "bg-success" : saveStatus === "saving" ? "bg-secondary" : "bg-warning text-dark"}`}>
{saveStatus === "saved" ? "Saved" : saveStatus === "saving" ? "Saving…" : "Unsaved"}
</span>
</div>
{/* Title */}
<input
className="form-control form-control-lg mb-2 note-title-input"
placeholder="Note title…"
value={title}
onChange={(e) => {
setTitle(e.target.value);
scheduleSave();
}}
maxLength={255}
/>
{/* Description */}
<textarea
className="form-control mb-2"
placeholder="Short description…"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
scheduleSave();
}}
maxLength={500}
/>
{/* WYSIWYG content editor */}
<RichTextEditor
key={noteId}
content={content}
onChange={(html) => {
setContent(html);
scheduleSave();
}}
placeholder="Write your note here…"
taskLists={taskLists}
spaceId={spaceId}
minHeight={450}
onFetchTasksForList={fetchTasksForList}
/>
{/* Metadata row */}
<div className="row g-3 mt-2">
<div className="col-md-4">
<label className="form-label small">Tags (comma-separated)</label>
<input
className="form-control form-control-sm"
placeholder="tag1, tag2…"
value={tags}
onChange={(e) => {
setTags(e.target.value);
scheduleSave();
}}
/>
</div>
<div className="col-md-4">
<label className="form-label small">Category</label>
<select
className="form-select form-select-sm"
value={categoryId}
onChange={(e) => {
setCategoryId(e.target.value);
scheduleSave();
}}
>
<option value="">Uncategorised</option>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label small">Password protection</label>
<select className="form-select form-select-sm" value={passwordMode} onChange={(e) => setPasswordMode(e.target.value as PasswordMode)}>
<option value="keep">Keep current</option>
<option value="set">Set new password</option>
<option value="remove">Remove password</option>
</select>
{passwordMode === "set" && (
<input
className="form-control form-control-sm mt-1"
type="password"
placeholder="New password (min 4 chars)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
/>
)}
</div>
</div>
<div className="d-flex gap-3 mt-2 ms-1">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPinned"
checked={isPinned}
onChange={(e) => {
setIsPinned(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isPinned">
Pinned
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isFavorite"
checked={isFavorite}
onChange={(e) => {
setIsFavorite(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isFavorite">
Favourite
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => {
setIsPublic(e.target.checked);
scheduleSave();
}}
/>
<label className="form-check-label small" htmlFor="isPublic">
Public
</label>
</div>
</div>
{/* Danger zone */}
{canDelete && (
<div className="border border-danger rounded p-3 mt-4">
<h6 className="text-danger mb-2">Danger Zone</h6>
<button className="btn btn-sm btn-danger" onClick={deleteNote}>
<i className="mdi mdi-delete-outline me-1" />
Delete Note
</button>
</div>
)}
</div>
);
}
// --- VIEWER MODE ---
const categoryLabel = flatCategories.find((c) => c.id === note.category_id)?.name;
// Password gate — show unlock form, hide all content and Edit button
if (note.is_password_protected && !unlocked) {
return (
<div className="note-viewer-container p-3">
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
</div>
<div className="d-flex justify-content-center mt-5">
<div className="card shadow-sm" style={{ maxWidth: 400, width: "100%" }}>
<div className="card-body p-4 text-center">
<i className="mdi mdi-lock-outline" style={{ fontSize: "3rem", color: "var(--color-primary)" }} />
<h5 className="mt-3 mb-1">{note.title}</h5>
<p className="text-muted small mb-4">This note is password protected. Enter the password to view it.</p>
<input
type="password"
className={`form-control mb-2${unlockError ? " is-invalid" : ""}`}
placeholder="Enter password…"
value={unlockPassword}
onChange={(e) => { setUnlockPassword(e.target.value); setUnlockError(""); }}
onKeyDown={(e) => e.key === "Enter" && unlockNote()}
autoFocus
/>
{unlockError && <div className="invalid-feedback d-block mb-2">{unlockError}</div>}
<button className="btn btn-primary w-100" onClick={unlockNote} disabled={unlocking}>
{unlocking ? "Unlocking…" : "Unlock"}
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="note-viewer-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3 flex-wrap">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
{canEdit && (
<button className="btn btn-sm btn-outline-primary" onClick={startEditing}>
<i className="mdi mdi-pencil-outline me-1" />
Edit
</button>
)}
</div>
{/* Note header */}
<div className="d-flex gap-2 align-items-center mb-2 flex-wrap">
<h2 className="note-viewer-title mb-1 flex-grow-1">{note.title}</h2>
<div className="ms-auto d-flex gap-2 align-items-center flex-wrap">
{note.is_pinned && (
<span className="badge bg-secondary">
<i className="mdi mdi-pin me-1" />
Pinned
</span>
)}
{note.is_favorite && (
<span className="badge bg-warning text-dark">
<i className="mdi mdi-star me-1" />
Favourite
</span>
)}
{note.is_public && (
<span className="badge bg-info text-dark">
<i className="mdi mdi-earth me-1" />
Public
</span>
)}
{!note.is_public && (
<span className="badge bg-secondary">
<i className="mdi mdi-lock-outline me-1" />
Private
</span>
)}
{note.is_password_protected && (
<span className="badge bg-warning text-dark">
<i className="mdi mdi-shield-key-outline me-1" />
Password
</span>
)}
</div>
</div>
{note.description && <p className="text-muted mb-2">{note.description}</p>}
<div className="d-flex gap-2 align-items-center mb-2 flex-wrap">
{(note.tags ?? []).map((tag) => (
<span key={tag} className="badge bg-secondary">
{tag}
</span>
))}
{categoryLabel && (
<span className="badge bg-light text-dark border">
<i className="mdi mdi-folder-outline me-1" />
{categoryLabel}
</span>
)}
<span className="text-muted small ms-auto">Updated {new Date(note.updated_at).toLocaleString()}</span>
</div>
<hr />
{/* WYSIWYG content (read-only) */}
<RichTextEditor
key={noteId}
content={note.content ?? ""}
readOnly
taskLists={taskLists}
spaceId={spaceId}
onNavigate={(path) => router.push(path)}
onFetchTasksForList={fetchTasksForList}
/>
</div>
);
}
@@ -0,0 +1,9 @@
import NotePageClient from "./NotePageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__", noteId: "__note__" }];
}
export default function NotePage() {
return <NotePageClient />;
}
@@ -0,0 +1,346 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSpaceStore, type Note, type Category, type TaskList } from "@/stores/spaceStore";
import apiClient from "@/lib/apiClient";
import RichTextEditor from "@/components/RichTextEditor";
/** Read real URL params from window.location useParams() returns static
* placeholder values in a Next.js static export. */
function getNoteParams(): { spaceId: string; noteId: string } {
if (typeof window === "undefined") return { spaceId: "", noteId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/notes\/([^/]+)/);
return { spaceId: m?.[1] ?? "", noteId: m?.[2] ?? "" };
}
type PasswordMode = "keep" | "set" | "remove";
export default function NewNotePage() {
const { spaceId, noteId } = getNoteParams();
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const user = useAuthStore((s) => s.user);
const hasPermission = useAuthStore((s) => s.hasPermission);
const hasSpacePermission = useAuthStore((s) => s.hasSpacePermission);
const fetchCategories = useSpaceStore((s) => s.fetchCategories);
const categoryTree = useSpaceStore((s) => s.categoryTree);
const currentSpace = useSpaceStore((s) => s.currentSpace);
const selectSpace = useSpaceStore((s) => s.selectSpace);
const [authChecked, setAuthChecked] = useState(false);
const [note, setNote] = useState<Note | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [isEditing, setIsEditing] = useState(false);
// Editor state
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [categoryId, setCategoryId] = useState<string>("");
const [isPinned, setIsPinned] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [isPublic, setIsPublic] = useState(false);
const [passwordMode, setPasswordMode] = useState<PasswordMode>("keep");
const [newPassword, setNewPassword] = useState("");
const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "dirty">("saved");
// Task lists for @TaskList mentions
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flat list of categories for dropdown — computed before any hooks that depend on it
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
const flatCategories = flattenCategories(categoryTree);
// Set default category once when categories first load (must be before early returns)
useEffect(() => {
if (flatCategories.length > 0 && !categoryId) {
setCategoryId(flatCategories[0].id);
}
}, [flatCategories.length]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
// Ensure space is selected
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
} else {
fetchCategories(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
loadTaskLists();
}, [authChecked]);
async function loadTaskLists() {
try {
const res = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
setTaskLists(Array.isArray(res.data) ? res.data : []);
setLoading(false);
} catch {
// ignore
}
}
async function fetchTasksForList(taskListId: string) {
const [tasksRes, statusRes] = await Promise.all([
apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params: { taskListId } }),
apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`),
]);
const tasks = Array.isArray(tasksRes.data) ? tasksRes.data : [];
const statuses = Array.isArray(statusRes.data) ? statusRes.data : [];
const statusMap = new Map(statuses.map((s: { id: string; name: string; color: string }) => [s.id, s]));
return tasks
.filter((t: { parent_task_id: string | null }) => !t.parent_task_id)
.map((t: { id: string; title: string; status_id: string }) => {
const status = statusMap.get(t.status_id) as { name: string; color: string } | undefined;
return {
id: t.id,
title: t.title,
statusColor: status?.color ?? "#7c8596",
statusName: status?.name ?? "",
};
});
}
function cancelEditing() {
router.push(`/dashboard/spaces/${spaceId}/notes`);
}
if (!authChecked || loading) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<div className="alert alert-danger">New Note Error: {error}</div>
<button className="btn btn-secondary" onClick={() => router.push("/dashboard")}>
Dashboard
</button>
</div>
);
}
if (flatCategories.length === 0) {
return (
<div className="p-4">
<div className="alert alert-warning">Please create a category before creating notes.</div>
</div>
);
}
async function performSave() {
setSaveStatus("saving");
try {
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const catId = categoryId || null;
const body: Record<string, unknown> = {
title,
description,
content,
tags: tagList,
category_id: catId,
is_pinned: isPinned,
is_favorite: isFavorite,
is_public: isPublic,
};
if (passwordMode === "set" && newPassword) {
body.note_password = newPassword;
} else if (passwordMode === "remove") {
body.note_password = "";
}
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/notes`, body);
setNote(res.data);
setSaveStatus("saved");
window.location.href = `/dashboard/spaces/${spaceId}/notes/${res.data.id}`;
} catch {
setSaveStatus("dirty");
}
}
return (
<div className="note-editor-container p-3">
{/* Toolbar */}
<div className="d-flex align-items-center gap-2 mb-3">
<button className="btn btn-sm btn-outline-secondary" onClick={() => router.push("/dashboard")}>
<i className="mdi mdi-view-dashboard-outline me-1" />
Dashboard
</button>
<span className="flex-grow-1"></span>
<button className="btn btn-sm btn-primary" onClick={performSave} disabled={saveStatus === "saving"}>
<i className="mdi mdi-content-save me-1" />
Save
</button>
<span className={`badge ms-2 ${saveStatus === "saved" ? "bg-success" : saveStatus === "saving" ? "bg-secondary" : "bg-warning text-dark"}`}>
{saveStatus === "saved" ? "Saved" : saveStatus === "saving" ? "Saving…" : "Unsaved"}
</span>
</div>
{/* Title */}
<input
className="form-control form-control-lg mb-2 note-title-input"
placeholder="Note title…"
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
maxLength={255}
/>
{/* Description */}
<textarea
className="form-control mb-2"
placeholder="Short description…"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
maxLength={500}
/>
{/* WYSIWYG content editor */}
<RichTextEditor
key={noteId}
content={content}
onChange={(html) => {
setContent(html);
}}
placeholder="Write your note here…"
taskLists={taskLists}
spaceId={spaceId}
minHeight={450}
onFetchTasksForList={fetchTasksForList}
/>
{/* Metadata row */}
<div className="row g-3 mt-2">
<div className="col-md-4">
<label className="form-label small">Tags (comma-separated)</label>
<input
className="form-control form-control-sm"
placeholder="tag1, tag2…"
value={tags}
onChange={(e) => {
setTags(e.target.value);
}}
/>
</div>
<div className="col-md-4">
<label className="form-label small">Category</label>
<select
className="form-select form-select-sm"
value={categoryId}
onChange={(e) => {
setCategoryId(e.target.value);
}}
>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="col-md-4">
<label className="form-label small">Password protection</label>
<select className="form-select form-select-sm" value={passwordMode} onChange={(e) => setPasswordMode(e.target.value as PasswordMode)}>
<option value="keep">Keep current</option>
<option value="set">Set new password</option>
<option value="remove">Remove password</option>
</select>
{passwordMode === "set" && (
<input
className="form-control form-control-sm mt-1"
type="password"
placeholder="New password (min 4 chars)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={4}
/>
)}
</div>
</div>
<div className="d-flex gap-3 mt-2 ms-1">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPinned"
checked={isPinned}
onChange={(e) => {
setIsPinned(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isPinned">
Pinned
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isFavorite"
checked={isFavorite}
onChange={(e) => {
setIsFavorite(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isFavorite">
Favourite
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => {
setIsPublic(e.target.checked);
}}
/>
<label className="form-check-label small" htmlFor="isPublic">
Public
</label>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import NewNotePageClient from "./NewNotePageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__" }];
}
export default function NewNotePage() {
return <NewNotePageClient />;
}
@@ -0,0 +1,9 @@
import TaskListPageClient from "./TaskListPageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__", taskListId: "__tasklist__" }];
}
export default function TaskListPage() {
return <TaskListPageClient />;
}
@@ -0,0 +1,132 @@
"use client";
import apiClient from "@/lib/apiClient";
import { useAuthStore } from "@/stores/authStore";
import { Category, useSpaceStore } from "@/stores/spaceStore";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
function getTaskListParams(): { spaceId: string; taskListId: string } {
if (typeof window === "undefined") return { spaceId: "", taskListId: "" };
const m = window.location.pathname.match(/\/dashboard\/spaces\/([^/]+)\/tasklists\/([^/]+)/);
return { spaceId: m?.[1] ?? "", taskListId: m?.[2] ?? "" };
}
export default function NewTaskListPageClient() {
const router = useRouter();
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const [authChecked, setAuthChecked] = useState(false);
const [tasklistName, setTasklistName] = useState("");
const [tasklistDescription, setTasklistDescription] = useState("");
const [tasklistCategory, setTasklistCategory] = useState("");
const { spaceId } = getTaskListParams();
const selectSpace = useSpaceStore((s) => s.selectSpace);
const categories = useSpaceStore((s) => s.categoryTree);
function flattenCategories(cats: Category[]): Category[] {
const result: Category[] = [];
function traverse(list: Category[]) {
for (const c of list) {
result.push(c);
const subs = c.subcategories ?? c.children ?? [];
if (subs.length) traverse(subs);
}
}
traverse(cats);
return result;
}
async function HandleSubmit(e: React.SubmitEvent) {
e.preventDefault();
const { spaceId } = getTaskListParams();
try {
const res = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, {
name: tasklistName,
description: tasklistDescription,
category_id: tasklistCategory,
});
router.push(`/dashboard/spaces/${spaceId}/tasklists/${res.data.id}`);
} catch (error) {
console.error("Error creating task list:", error);
}
}
useEffect(() => {
ensureInitialized().then(() => {
if (!useAuthStore.getState().user) {
router.replace("/login");
} else {
setAuthChecked(true);
if (!useSpaceStore.getState().currentSpace || useSpaceStore.getState().currentSpace?.id !== spaceId) {
selectSpace(spaceId);
}
}
});
}, []);
useEffect(() => {
if (!authChecked) return;
setTasklistCategory(categories[0]?.id ?? "");
}, [authChecked, categories]);
if (!authChecked) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: 200 }}>
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
const flatCategories = flattenCategories(categories);
if (flatCategories.length == 0) {
return (
<div className="p-4">
<div className="alert alert-warning">Please create a category before creating notes.</div>
</div>
);
}
return (
<div className="p-3">
<h1 className="h4">Create New Task List</h1>
<div className="card mb-3">
<div className="card-body">
<form onSubmit={HandleSubmit}>
<div className="mb-3">
<label htmlFor="taskListName" className="form-label">
Task List Name
</label>
<input type="text" className="form-control" id="taskListName" value={tasklistName} onChange={(e) => setTasklistName(e.target.value)} />
</div>
<div className="mb-3">
<label htmlFor="taskListDescription" className="form-label">
Description
</label>
<textarea className="form-control" id="taskListDescription" rows={3} value={tasklistDescription} onChange={(e) => setTasklistDescription(e.target.value)} />
</div>
<div className="mb-3">
<label htmlFor="taskListCategory" className="form-label">
Category
</label>
<select className="form-select" id="taskListCategory" value={tasklistCategory} onChange={(e) => setTasklistCategory(e.target.value)}>
{flatCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<button type="submit" className="btn btn-primary">
Create
</button>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import NewTaskListPageClient from "./NewTaskListPageClient";
export function generateStaticParams() {
return [{ spaceId: "__space__" }];
}
export default function TaskListPage() {
return <NewTaskListPageClient />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "bootstrap/dist/css/bootstrap.min.css";
import "@mdi/font/css/materialdesignicons.min.css";
import "highlight.js/styles/github-dark.min.css";
import "../styles/globals.css";
export const metadata: Metadata = {
title: "Notely",
description: "Note taking application",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
import apiClient from "@/lib/apiClient";
export default function LoginPage() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const loadFeatureFlags = useSettingsStore((s) => s.loadFeatureFlags);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; name: string }>>([]);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [providerLoginEnabled, setProviderLoginEnabled] = useState(true);
const handled = useRef(false);
useEffect(() => {
// apply saved theme
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
const init = async () => {
const flags = await loadFeatureFlags();
setRegistrationEnabled(!!flags.registration_enabled);
setProviderLoginEnabled(!!flags.provider_login_enabled);
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
return;
}
// Handle OAuth callback
if (!handled.current) {
handled.current = true;
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
if (status === "oauth_error") {
setError(params.get("message") || "Provider sign-in failed.");
return;
}
if (status === "oauth_success") {
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
} else {
setError("Provider sign-in returned an incomplete session.");
}
return;
}
}
// Load OAuth providers
if (flags.provider_login_enabled) {
try {
const res = await apiClient.get("/api/v1/auth/providers");
setProviders(res.data?.providers || []);
} catch {
setProviders([]);
}
}
// Show query message
const msg = new URLSearchParams(window.location.search).get("message");
if (msg) setError(msg);
};
init();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
router.replace("/dashboard");
} catch (err: unknown) {
setError(String(err));
} finally {
setLoading(false);
}
};
const startProviderLogin = (providerId: string) => {
window.location.href = `${window.location.origin}/api/v1/auth/providers/${providerId}/start`;
};
return (
<div className="login-page">
<div className="auth-container">
<div className="login-card">
<div className="brand-block">
<div className="brand-mark">
<i className="mdi mdi-note-text-outline" aria-hidden="true" />
</div>
<h1 className="brand-title">Notely</h1>
</div>
<h2 className="auth-title">Login</h2>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input id="email" type="email" className="form-control" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input id="password" type="password" className="form-control" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary w-100 auth-submit" disabled={loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Logging in
</>
) : (
"Login"
)}
</button>
</form>
{providerLoginEnabled && providers.length > 0 && (
<div className="mt-4">
<div className="oauth-divider">
<span>or continue with</span>
</div>
<div className="d-grid gap-2 mt-3">
{providers.map((provider) => (
<button key={provider.id} type="button" className="btn btn-outline-secondary auth-provider-btn" onClick={() => startProviderLogin(provider.id)}>
Sign in with {provider.name}
</button>
))}
</div>
</div>
)}
{registrationEnabled && (
<p className="text-center mt-4 mb-0 auth-switch-link">
Don&apos;t have an account? <Link href="/register">Register here</Link>
</p>
)}
</div>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
export default function RootPage() {
const router = useRouter();
const initialized = useAuthStore((s) => s.initialized);
const user = useAuthStore((s) => s.user);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
useEffect(() => {
ensureInitialized().then(() => {
if (useAuthStore.getState().user) {
router.replace("/dashboard");
} else {
router.replace("/login");
}
});
}, []);
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
export default function RegisterPage() {
const router = useRouter();
const register = useAuthStore((s) => s.register);
const ensureInitialized = useAuthStore((s) => s.ensureInitialized);
const loadFeatureFlags = useSettingsStore((s) => s.loadFeatureFlags);
const [form, setForm] = useState({
email: "",
username: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState(true);
useEffect(() => {
const theme = localStorage.getItem("theme") === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-bs-theme", theme);
const init = async () => {
const flags = await loadFeatureFlags();
setRegistrationEnabled(!!flags.registration_enabled);
if (!flags.registration_enabled) return;
await ensureInitialized();
if (useAuthStore.getState().user) {
router.replace("/dashboard");
}
};
init();
}, []);
const update = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => setForm((prev) => ({ ...prev, [field]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!registrationEnabled) {
setError("Registration is currently disabled.");
return;
}
if (form.password !== form.confirmPassword) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
await register(form.email, form.username, form.password, form.firstName, form.lastName);
router.replace("/dashboard");
} catch (err: unknown) {
setError(String(err));
} finally {
setLoading(false);
}
};
return (
<div className="register-page">
<div className="register-container">
<div className="register-card">
<div className="brand-block">
<div className="brand-mark">
<i className="mdi mdi-note-text-outline" aria-hidden="true" />
</div>
<h1 className="brand-title">Notely</h1>
</div>
<h2 className="auth-title">Register</h2>
{!registrationEnabled && (
<div className="alert alert-warning">
Registration is currently disabled by an administrator.{" "}
<Link href="/login" className="alert-link">
Go to login
</Link>
</div>
)}
<form onSubmit={handleSubmit} className={!registrationEnabled ? "opacity-50" : ""}>
<div className="row mb-3">
<div className="col-12 col-md-6 mb-3 mb-md-0">
<label htmlFor="firstName" className="form-label">
First Name
</label>
<input id="firstName" type="text" className="form-control" value={form.firstName} onChange={update("firstName")} disabled={!registrationEnabled} />
</div>
<div className="col-12 col-md-6">
<label htmlFor="lastName" className="form-label">
Last Name
</label>
<input id="lastName" type="text" className="form-control" value={form.lastName} onChange={update("lastName")} disabled={!registrationEnabled} />
</div>
</div>
<div className="mb-3">
<label htmlFor="username" className="form-label">
Username
</label>
<input id="username" type="text" className="form-control" value={form.username} onChange={update("username")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="email" className="form-label">
Email
</label>
<input id="email" type="email" className="form-control" value={form.email} onChange={update("email")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input id="password" type="password" className="form-control" value={form.password} onChange={update("password")} required disabled={!registrationEnabled} />
</div>
<div className="mb-3">
<label htmlFor="confirmPassword" className="form-label">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
className="form-control"
value={form.confirmPassword}
onChange={update("confirmPassword")}
required
disabled={!registrationEnabled}
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary w-100 auth-submit" disabled={!registrationEnabled || loading}>
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" />
Registering
</>
) : (
"Register"
)}
</button>
</form>
<p className="text-center mt-4 mb-0 auth-switch-link">
Already have an account? <Link href="/login">Login here</Link>
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
interface AdminGroup {
id: string;
name: string;
description: string;
is_system: boolean;
permissions: string[];
}
interface Props {
mode: "create" | "edit";
group: AdminGroup | null;
submitting: boolean;
onClose: () => void;
onSubmit: (data: { name: string; description: string; permissions: string[] }) => void;
}
export default function AdminGroupModal({ mode, group, submitting, onClose, onSubmit }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionsText, setPermissionsText] = useState("");
const isSystem = mode === "edit" && !!group?.is_system;
useEffect(() => {
if (mode === "edit" && group) {
setName(group.name || "");
setDescription(group.description || "");
setPermissionsText((group.permissions || []).join("\n"));
} else {
setName("");
setDescription("");
setPermissionsText("");
}
}, [mode, group]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const permissions = permissionsText
.split(/\r?\n/)
.map((p) => p.trim())
.filter(Boolean);
onSubmit({ name, description, permissions });
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{mode === "create" ? "Create Group" : "Edit Group"}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Group name</label>
<input className="form-control" type="text" required disabled={isSystem} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Description</label>
<input className="form-control" type="text" disabled={isSystem} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div>
<label className="form-label">Permissions (one per line)</label>
<textarea
className="form-control permissions-textarea"
rows={10}
placeholder={"space.create\nspace.project_docs.category.create\nspace.project_docs.*"}
disabled={isSystem}
value={permissionsText}
onChange={(e) => setPermissionsText(e.target.value)}
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
{!isSystem && (
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : mode === "create" ? "Create Group" : "Save Changes"}
</button>
)}
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useEffect, useState } from "react";
interface AuthProvider {
id: string;
name: string;
type?: string;
client_id?: string;
authorization_url?: string;
token_url?: string;
userinfo_url?: string;
id_token_claim?: string;
scopes?: string[];
is_active: boolean;
}
interface ProviderForm {
name: string;
type: string;
client_id: string;
client_secret: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
id_token_claim: string;
scopes: string;
is_active: boolean;
}
const defaultForm = (): ProviderForm => ({
name: "",
type: "oidc",
client_id: "",
client_secret: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
id_token_claim: "id_token",
scopes: "openid, profile, email",
is_active: true,
});
interface Props {
mode: "create" | "edit";
provider: AuthProvider | null;
submitting: boolean;
onClose: () => void;
onSubmit: (data: Omit<ProviderForm, "scopes"> & { scopes: string[] }) => void;
onDelete: (provider: AuthProvider) => void;
}
export default function AdminProviderModal({ mode, provider, submitting, onClose, onSubmit, onDelete }: Props) {
const [form, setForm] = useState<ProviderForm>(defaultForm());
useEffect(() => {
if (mode === "edit" && provider) {
setForm({
name: provider.name || "",
type: provider.type || "oidc",
client_id: provider.client_id || "",
client_secret: "",
authorization_url: provider.authorization_url || "",
token_url: provider.token_url || "",
userinfo_url: provider.userinfo_url || "",
id_token_claim: provider.id_token_claim || "id_token",
scopes: (provider.scopes || []).join(", "),
is_active: provider.is_active ?? true,
});
} else {
setForm(defaultForm());
}
}, [mode, provider]);
const set = (field: keyof ProviderForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
...form,
scopes: form.scopes
.split(",")
.map((s) => s.trim())
.filter(Boolean),
});
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{mode === "create" ? "Add Identity Provider" : "Edit Identity Provider"}</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">
Display Name <span className="text-danger">*</span>
</label>
<input type="text" className="form-control" required value={form.name} onChange={set("name")} />
</div>
<div className="col-md-6">
<label className="form-label">
Provider Type <span className="text-danger">*</span>
</label>
<select className="form-select" value={form.type} onChange={set("type")}>
<option value="oidc">OIDC</option>
<option value="oauth2">OAuth2</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">
Client ID <span className="text-danger">*</span>
</label>
<input type="text" className="form-control" required value={form.client_id} onChange={set("client_id")} />
</div>
<div className="col-md-6">
<label className="form-label">
Client Secret {mode === "create" ? <span className="text-danger">*</span> : <span className="text-muted small">(leave blank to keep existing)</span>}
</label>
<input
type="password"
className="form-control"
required={mode === "create"}
autoComplete="new-password"
value={form.client_secret}
onChange={set("client_secret")}
/>
</div>
<div className="col-md-6">
<label className="form-label">
Authorization URL <span className="text-danger">*</span>
</label>
<input type="url" className="form-control" required value={form.authorization_url} onChange={set("authorization_url")} />
</div>
<div className="col-md-6">
<label className="form-label">
Token URL <span className="text-danger">*</span>
</label>
<input type="url" className="form-control" required value={form.token_url} onChange={set("token_url")} />
</div>
<div className="col-md-6">
<label className="form-label">UserInfo URL</label>
<input type="url" className="form-control" placeholder="Optional" value={form.userinfo_url} onChange={set("userinfo_url")} />
</div>
<div className="col-md-6">
<label className="form-label">ID Token Claim</label>
<input type="text" className="form-control" placeholder="id_token" value={form.id_token_claim} onChange={set("id_token_claim")} />
</div>
<div className="col-12">
<label className="form-label">Scopes</label>
<input type="text" className="form-control" placeholder="openid, profile, email" value={form.scopes} onChange={set("scopes")} />
<div className="form-text">Comma-separated list of OAuth scopes.</div>
</div>
<div className="col-12">
<div className="form-check">
<input
id="provider-active"
type="checkbox"
className="form-check-input"
checked={form.is_active}
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.checked }))}
/>
<label htmlFor="provider-active" className="form-check-label">
Provider is active
</label>
</div>
</div>
{mode === "edit" && provider && (
<div className="col-12">
<div className="border border-danger rounded p-3 mt-2">
<h6 className="text-danger mb-1">Danger Zone</h6>
<p className="small text-muted mb-2">Permanently delete this provider configuration. This action cannot be undone.</p>
<button className="btn btn-danger" type="button" disabled={submitting} onClick={() => onDelete(provider)}>
<i className="mdi mdi-delete-outline me-1" aria-hidden="true" />
Delete Provider
</button>
</div>
</div>
)}
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : mode === "create" ? "Add Provider" : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,294 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import apiClient from "@/lib/apiClient";
import ConfirmActionModal from "./ConfirmActionModal";
interface AdminSpace {
id: string;
name: string;
description: string;
icon?: string;
is_public: boolean;
}
interface AdminUser {
id: string;
username: string;
}
interface SpaceMember {
user_id: string;
username?: string;
joined_at?: string;
}
interface Props {
space: AdminSpace;
users: AdminUser[];
onClose: () => void;
onSaved: (updated: AdminSpace) => void;
onDeleted: (space: AdminSpace) => void;
}
export default function AdminSpaceModal({ space, users, onClose, onSaved, onDeleted }: Props) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [savingSpace, setSavingSpace] = useState(false);
const [members, setMembers] = useState<SpaceMember[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [addingMember, setAddingMember] = useState(false);
const [removingMemberId, setRemovingMemberId] = useState("");
const [newUserId, setNewUserId] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// confirm dialog state
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmBusy, setConfirmBusy] = useState(false);
const [confirmIntent, setConfirmIntent] = useState<{ type: "member" | "space"; payload: SpaceMember | AdminSpace | null }>({ type: "space", payload: null });
const clearMessages = () => {
setError("");
setSuccess("");
};
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
clearMessages();
try {
const res = await apiClient.get(`/api/v1/admin/spaces/${space.id}/members`);
setMembers(res.data?.members || []);
} catch {
setError("Failed to load members.");
} finally {
setLoadingMembers(false);
}
}, [space.id]);
useEffect(() => {
setName(space.name || "");
setDescription(space.description || "");
setIcon(space.icon || "");
setIsPublic(!!space.is_public);
loadMembers();
}, [space, loadMembers]);
const saveSpace = async () => {
setSavingSpace(true);
clearMessages();
try {
const res = await apiClient.put(`/api/v1/admin/spaces/${space.id}`, { name, description, icon, is_public: isPublic });
setSuccess("Space updated.");
onSaved(res.data);
} catch {
setError("Failed to update space.");
} finally {
setSavingSpace(false);
}
};
const addMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUserId) return;
setAddingMember(true);
clearMessages();
try {
await apiClient.post(`/api/v1/admin/spaces/${space.id}/members`, { user_id: newUserId });
setSuccess("Member added.");
setNewUserId("");
await loadMembers();
} catch {
setError("Failed to add member.");
} finally {
setAddingMember(false);
}
};
const requestRemoveMember = (member: SpaceMember) => {
setConfirmIntent({ type: "member", payload: member });
setConfirmVisible(true);
};
const requestDeleteSpace = () => {
setConfirmIntent({ type: "space", payload: space });
setConfirmVisible(true);
};
const confirmAction = async () => {
if (confirmBusy) return;
setConfirmBusy(true);
try {
if (confirmIntent.type === "member") {
const member = confirmIntent.payload as SpaceMember;
setRemovingMemberId(member.user_id);
await apiClient.delete(`/api/v1/admin/spaces/${space.id}/members/${member.user_id}`);
setSuccess("Member removed.");
await loadMembers();
setRemovingMemberId("");
} else {
await apiClient.delete(`/api/v1/admin/spaces/${space.id}`);
onDeleted(space);
}
setConfirmVisible(false);
} catch {
setError("Action failed.");
setRemovingMemberId("");
} finally {
setConfirmBusy(false);
}
};
const selectableUsers = users.filter((u) => !members.some((m) => m.user_id === u.id));
const formatDate = (iso?: string) => (iso ? new Date(iso).toLocaleDateString() : "—");
const confirmTitle = confirmIntent.type === "member" ? "Remove Member" : "Delete Space";
const confirmMessage =
confirmIntent.type === "member"
? `Remove member "${(confirmIntent.payload as SpaceMember)?.username || (confirmIntent.payload as SpaceMember)?.user_id}" from this space?`
: `Permanently delete space "${space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget && !confirmVisible) onClose();
}}
>
<div className="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Edit Space</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
{/* Space settings */}
<div className="row g-3 mb-4">
<div className="col-md-5">
<label className="form-label">Name</label>
<input type="text" className="form-control" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="col-md-5">
<label className="form-label">Description</label>
<input type="text" className="form-control" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="col-md-2">
<label className="form-label">Icon</label>
<input type="text" className="form-control" maxLength={20} value={icon} onChange={(e) => setIcon(e.target.value)} />
</div>
<div className="col-12 d-flex justify-content-between align-items-center">
<div className="form-check form-switch">
<input id="admin-space-public" className="form-check-input" type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<label htmlFor="admin-space-public" className="form-check-label">
Public space
</label>
</div>
<button className="btn btn-primary" disabled={savingSpace} onClick={saveSpace}>
{savingSpace ? "Saving..." : "Save Space"}
</button>
</div>
</div>
<hr />
{/* Members */}
<div className="d-flex justify-content-between align-items-center mt-3 mb-2">
<h6 className="mb-0">Members</h6>
<button className="btn btn-sm btn-outline-secondary" disabled={loadingMembers} onClick={loadMembers}>
Refresh
</button>
</div>
<form className="row g-2 align-items-end mb-3" onSubmit={addMember}>
<div className="col-md-10">
<label className="form-label form-label-sm mb-1">Username</label>
<select className="form-select form-select-sm" required value={newUserId} onChange={(e) => setNewUserId(e.target.value)}>
<option value="">Select user</option>
{selectableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={addingMember}>
{addingMember ? "..." : "Add"}
</button>
</div>
</form>
{loadingMembers ? (
<div className="text-muted small">Loading members...</div>
) : members.length === 0 ? (
<div className="text-muted small">No members found.</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Username</th>
<th>Joined</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.user_id}>
<td>{m.username || m.user_id}</td>
<td className="small text-muted">{formatDate(m.joined_at)}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-danger" disabled={removingMemberId === m.user_id} onClick={() => requestRemoveMember(m)}>
{removingMemberId === m.user_id ? "Removing..." : "Remove"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{error && <div className="alert alert-danger mt-3 mb-0">{error}</div>}
{success && <div className="alert alert-success mt-3 mb-0">{success}</div>}
<hr />
{/* Danger zone */}
<div className="border border-danger rounded p-3 mt-4">
<h6 className="text-danger mb-1">Danger Zone</h6>
<p className="small text-muted mb-2">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
<button className="btn btn-danger" type="button" onClick={requestDeleteSpace}>
<i className="mdi mdi-delete-outline me-1" aria-hidden="true" />
Delete Space
</button>
</div>
</div>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
<ConfirmActionModal
visible={confirmVisible}
title={confirmTitle}
message={confirmMessage}
busy={confirmBusy}
onClose={() => {
if (!confirmBusy) setConfirmVisible(false);
}}
onConfirm={confirmAction}
/>
</>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
interface Group {
id: string;
name: string;
}
interface AdminUser {
id: string;
username: string;
email: string;
is_active: boolean;
group_ids?: string[];
}
interface Props {
user: AdminUser | null;
groups: Group[];
submitting: boolean;
onClose: () => void;
onSubmit: (data: { group_ids: string[] }) => void;
}
export default function AdminUserModal({ user, groups, submitting, onClose, onSubmit }: Props) {
const [groupIds, setGroupIds] = useState<string[]>([]);
useEffect(() => {
setGroupIds([...(user?.group_ids || [])]);
}, [user]);
if (!user) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ group_ids: groupIds });
};
const toggleGroup = (id: string) => {
setGroupIds((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id]));
};
return (
<>
<div
className="modal fade show d-block admin-modal"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1050 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Edit User</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="mb-3">
<label className="form-label">Username</label>
<input className="form-control" type="text" value={user.username} disabled readOnly />
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input className="form-control" type="text" value={user.email} disabled readOnly />
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<input className="form-control" type="text" value={user.is_active ? "Active" : "Inactive"} disabled readOnly />
</div>
<div>
<label className="form-label">Groups</label>
<select
className="form-select"
multiple
size={Math.max(4, groups.length)}
value={groupIds}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
setGroupIds(selected);
}}
>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<div className="small text-muted mt-1">Ctrl/Cmd+Click for multiple groups</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
</div>
</div>
</div>
<div className="modal-backdrop fade show admin-modal-backdrop" style={{ zIndex: 1045 }} />
</>
);
}
@@ -0,0 +1,66 @@
"use client";
interface Props {
visible: boolean;
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
busyLabel?: string;
busy?: boolean;
onClose: () => void;
onConfirm: () => void;
}
export default function ConfirmActionModal({
visible,
title = "Confirm",
message = "Are you sure you want to continue?",
confirmLabel = "Delete",
cancelLabel = "Cancel",
busyLabel = "Deleting...",
busy = false,
onClose,
onConfirm,
}: Props) {
if (!visible) return null;
return (
<>
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
style={{ zIndex: 1060 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center gap-2 mb-0">
<i className="mdi mdi-alert-outline text-danger" aria-hidden="true" />
<span>{title}</span>
</h5>
<button type="button" className="btn-close" aria-label="Close" disabled={busy} onClick={onClose} />
</div>
<div className="modal-body">
<p className="text-muted mb-0">{message}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" disabled={busy} onClick={onClose}>
{cancelLabel}
</button>
<button type="button" className="btn btn-danger" disabled={busy} onClick={onConfirm}>
{busy ? busyLabel : confirmLabel}
</button>
</div>
</div>
</div>
</div>
<div className="modal-backdrop fade show" style={{ zIndex: 1055 }} />
</>
);
}

Some files were not shown because too many files have changed in this diff Show More