Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ead8219f3b | |||
| b690b00016 | |||
| 503d2415e6 | |||
| 74d8899eec | |||
| 295e03feb4 | |||
| b09137eca5 | |||
| b9ca845b9c | |||
| a1dd2f2c00 | |||
| a081bff35b | |||
| 1b336299ee |
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ secret*
|
|||||||
*.a
|
*.a
|
||||||
*.so
|
*.so
|
||||||
.go/
|
.go/
|
||||||
|
|
||||||
|
frontend_new/out
|
||||||
|
backend/public
|
||||||
|
frontend_new/.next
|
||||||
+1
-4
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ space.<space_permission_key>.<action>
|
|||||||
- space_permission_key is derived from the space name (normalized token)
|
- space_permission_key is derived from the space name (normalized token)
|
||||||
- Example:
|
- Example:
|
||||||
- space.product_docs.note.create
|
- space.product_docs.note.create
|
||||||
|
- space.product_docs.tasks.create
|
||||||
- space.product_docs.settings.member.manage
|
- space.product_docs.settings.member.manage
|
||||||
|
|
||||||
## Space-Scoped Actions Currently Enforced
|
## Space-Scoped Actions Currently Enforced
|
||||||
@@ -49,6 +50,16 @@ space.<space_permission_key>.<action>
|
|||||||
- note.edit
|
- note.edit
|
||||||
- note.delete
|
- note.delete
|
||||||
|
|
||||||
|
### Task Management
|
||||||
|
|
||||||
|
- tasks.create
|
||||||
|
- tasks.edit
|
||||||
|
- tasks.delete
|
||||||
|
|
||||||
|
### Task Status Management
|
||||||
|
|
||||||
|
- tasks.status.manage
|
||||||
|
|
||||||
## Wildcard Support
|
## Wildcard Support
|
||||||
|
|
||||||
Permissions support wildcard matching with \*.
|
Permissions support wildcard matching with \*.
|
||||||
@@ -59,6 +70,8 @@ Examples:
|
|||||||
- Grants all permissions for the product_docs space
|
- Grants all permissions for the product_docs space
|
||||||
- space.\*.note.create
|
- space.\*.note.create
|
||||||
- Grants note.create for all spaces
|
- Grants note.create for all spaces
|
||||||
|
- space.\*.tasks.\*
|
||||||
|
- Grants all task permissions for all spaces
|
||||||
- `*`
|
- `*`
|
||||||
- Grants all permissions globally
|
- Grants all permissions globally
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+145
-33
@@ -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,11 +153,22 @@ func main() {
|
|||||||
|
|
||||||
categoryService := services.NewCategoryService(
|
categoryService := services.NewCategoryService(
|
||||||
db.CategoryRepo,
|
db.CategoryRepo,
|
||||||
|
db.TaskListRepo,
|
||||||
db.MembershipRepo,
|
db.MembershipRepo,
|
||||||
db.NoteRepo,
|
db.NoteRepo,
|
||||||
permissionService,
|
permissionService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
taskService := services.NewTaskService(
|
||||||
|
db.TaskRepo,
|
||||||
|
db.TaskListRepo,
|
||||||
|
db.TaskStatusRepo,
|
||||||
|
db.NoteRepo,
|
||||||
|
db.CategoryRepo,
|
||||||
|
db.MembershipRepo,
|
||||||
|
permissionService,
|
||||||
|
)
|
||||||
|
|
||||||
adminService := services.NewAdminService(
|
adminService := services.NewAdminService(
|
||||||
db.UserRepo,
|
db.UserRepo,
|
||||||
db.GroupRepo,
|
db.GroupRepo,
|
||||||
@@ -189,6 +202,7 @@ func main() {
|
|||||||
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||||
noteHandler := handlers.NewNoteHandler(noteService)
|
noteHandler := handlers.NewNoteHandler(noteService)
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||||
|
taskHandler := handlers.NewTaskHandler(taskService)
|
||||||
adminHandler := handlers.NewAdminHandler(adminService)
|
adminHandler := handlers.NewAdminHandler(adminService)
|
||||||
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
||||||
settingsHandler := handlers.NewSettingsHandler(authService)
|
settingsHandler := handlers.NewSettingsHandler(authService)
|
||||||
@@ -258,6 +272,30 @@ func main() {
|
|||||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
||||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
||||||
|
|
||||||
|
// Task endpoints
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
|
||||||
|
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
||||||
|
|
||||||
|
// Task status endpoints (scoped to task list)
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||||
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||||
|
|
||||||
// File explorer endpoints (space-scoped)
|
// 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")
|
||||||
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
|
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
|
||||||
@@ -321,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{
|
||||||
@@ -492,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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -452,6 +453,183 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== TASK DTOs ==========
|
||||||
|
|
||||||
|
// CreateTaskRequest represents task creation input.
|
||||||
|
type CreateTaskRequest struct {
|
||||||
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||||
|
Description string `json:"description" validate:"max=2000"`
|
||||||
|
TaskListID string `json:"task_list_id" validate:"required"`
|
||||||
|
StatusID string `json:"status_id" validate:"required"`
|
||||||
|
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||||
|
NoteLinks []string `json:"note_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskRequest represents task update input.
|
||||||
|
type UpdateTaskRequest struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
TaskListID *string `json:"task_list_id,omitempty"`
|
||||||
|
StatusID *string `json:"status_id,omitempty"`
|
||||||
|
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||||
|
NoteLinks []string `json:"note_links,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTransitionRequest allows moving task status by one step.
|
||||||
|
type TaskTransitionRequest struct {
|
||||||
|
Direction string `json:"direction" validate:"required,oneof=forward backward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkTaskNoteRequest links/unlinks a note from a task.
|
||||||
|
type LinkTaskNoteRequest struct {
|
||||||
|
NoteID string `json:"note_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskDTO represents a task in API responses.
|
||||||
|
type TaskDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
TaskListID string `json:"task_list_id"`
|
||||||
|
StatusID string `json:"status_id"`
|
||||||
|
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||||
|
Depth int `json:"depth"`
|
||||||
|
NoteLinks []string `json:"note_links"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskWithStatusDTO includes status details and child tasks for detail views.
|
||||||
|
type TaskWithStatusDTO struct {
|
||||||
|
*TaskDTO
|
||||||
|
StatusName string `json:"status_name"`
|
||||||
|
StatusColor string `json:"status_color,omitempty"`
|
||||||
|
StatusOrder int `json:"status_order"`
|
||||||
|
Subtasks []*TaskDTO `json:"subtasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskStatusRequest represents task status creation input.
|
||||||
|
type CreateTaskStatusRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
Color string `json:"color,omitempty" validate:"max=20"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskStatusRequest represents task status updates.
|
||||||
|
type UpdateTaskStatusRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
Color string `json:"color,omitempty" validate:"max=20"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderTaskStatusesRequest represents a full ordered status ID list.
|
||||||
|
type ReorderTaskStatusesRequest struct {
|
||||||
|
OrderedStatusIDs []string `json:"ordered_status_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatusDTO represents a task status in API responses.
|
||||||
|
type TaskStatusDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TaskListID string `json:"task_list_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskListRequest represents task list creation input.
|
||||||
|
type CreateTaskListRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=120"`
|
||||||
|
Description string `json:"description" validate:"max=500"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskListRequest represents task list update input.
|
||||||
|
type UpdateTaskListRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskListDTO represents a task list in API responses.
|
||||||
|
type TaskListDTO struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"space_id"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
UpdatedBy string `json:"updated_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskDTO creates a DTO from a task entity.
|
||||||
|
func NewTaskDTO(task *entities.Task) *TaskDTO {
|
||||||
|
var parentTaskID *string
|
||||||
|
if task.ParentTaskID != nil {
|
||||||
|
id := task.ParentTaskID.Hex()
|
||||||
|
parentTaskID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
noteLinks := make([]string, 0, len(task.NoteLinks))
|
||||||
|
for _, noteID := range task.NoteLinks {
|
||||||
|
noteLinks = append(noteLinks, noteID.Hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TaskDTO{
|
||||||
|
ID: task.ID.Hex(),
|
||||||
|
SpaceID: task.SpaceID.Hex(),
|
||||||
|
Title: task.Title,
|
||||||
|
Description: task.Description,
|
||||||
|
TaskListID: task.TaskListID.Hex(),
|
||||||
|
StatusID: task.StatusID.Hex(),
|
||||||
|
ParentTaskID: parentTaskID,
|
||||||
|
Depth: task.Depth,
|
||||||
|
NoteLinks: noteLinks,
|
||||||
|
CreatedBy: task.CreatedBy.Hex(),
|
||||||
|
UpdatedBy: task.UpdatedBy.Hex(),
|
||||||
|
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskListDTO creates a DTO from a task list entity.
|
||||||
|
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
|
||||||
|
var categoryID *string
|
||||||
|
if taskList.CategoryID != nil {
|
||||||
|
id := taskList.CategoryID.Hex()
|
||||||
|
categoryID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TaskListDTO{
|
||||||
|
ID: taskList.ID.Hex(),
|
||||||
|
SpaceID: taskList.SpaceID.Hex(),
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Name: taskList.Name,
|
||||||
|
Description: taskList.Description,
|
||||||
|
CreatedBy: taskList.CreatedBy.Hex(),
|
||||||
|
UpdatedBy: taskList.UpdatedBy.Hex(),
|
||||||
|
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskStatusDTO creates a DTO from a task status entity.
|
||||||
|
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||||
|
return &TaskStatusDTO{
|
||||||
|
ID: status.ID.Hex(),
|
||||||
|
TaskListID: status.TaskListID.Hex(),
|
||||||
|
Name: status.Name,
|
||||||
|
Color: status.Color,
|
||||||
|
Order: status.Order,
|
||||||
|
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ERROR DTOs ==========
|
// ========== ERROR DTOs ==========
|
||||||
|
|
||||||
// ErrorResponse represents an error response
|
// ErrorResponse represents an error response
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
package entities
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxTaskDepth = 2
|
||||||
|
|
||||||
|
// Task represents a task and supports up to 3 nesting levels (0,1,2).
|
||||||
|
type Task struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
Title string `bson:"title"`
|
||||||
|
Description string `bson:"description"`
|
||||||
|
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||||
|
StatusID bson.ObjectID `bson:"status_id"`
|
||||||
|
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
|
||||||
|
Depth int `bson:"depth"`
|
||||||
|
NoteLinks []bson.ObjectID `bson:"note_links"`
|
||||||
|
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||||
|
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatus defines the ordered linear status progression for a task list.
|
||||||
|
type TaskStatus struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
Color string `bson:"color,omitempty"`
|
||||||
|
Order int `bson:"order"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskList groups tasks under a named list that can be attached to a category.
|
||||||
|
type TaskList struct {
|
||||||
|
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||||
|
SpaceID bson.ObjectID `bson:"space_id"`
|
||||||
|
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
|
||||||
|
Name string `bson:"name"`
|
||||||
|
Description string `bson:"description,omitempty"`
|
||||||
|
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||||
|
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||||
|
CreatedAt time.Time `bson:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at"`
|
||||||
|
}
|
||||||
@@ -216,3 +216,37 @@ type NoteRevisionRepository interface {
|
|||||||
// GetRevisionByID retrieves a specific revision
|
// GetRevisionByID retrieves a specific revision
|
||||||
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
|
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskRepository defines task operations
|
||||||
|
type TaskRepository interface {
|
||||||
|
CreateTask(ctx context.Context, task *entities.Task) error
|
||||||
|
GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error)
|
||||||
|
ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error)
|
||||||
|
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
|
||||||
|
UpdateTask(ctx context.Context, task *entities.Task) error
|
||||||
|
DeleteTask(ctx context.Context, id bson.ObjectID) error
|
||||||
|
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||||
|
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
|
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskListRepository defines task list operations.
|
||||||
|
type TaskListRepository interface {
|
||||||
|
CreateTaskList(ctx context.Context, list *entities.TaskList) error
|
||||||
|
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
|
||||||
|
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
|
||||||
|
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
|
||||||
|
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
|
||||||
|
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
|
||||||
|
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatusRepository defines task status operations
|
||||||
|
type TaskStatusRepository interface {
|
||||||
|
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||||
|
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
|
||||||
|
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
|
||||||
|
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||||
|
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
||||||
|
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ type Database struct {
|
|||||||
MembershipRepo *MembershipRepository
|
MembershipRepo *MembershipRepository
|
||||||
NoteRepo *NoteRepository
|
NoteRepo *NoteRepository
|
||||||
CategoryRepo *CategoryRepository
|
CategoryRepo *CategoryRepository
|
||||||
|
TaskListRepo *TaskListRepository
|
||||||
|
TaskRepo *TaskRepository
|
||||||
|
TaskStatusRepo *TaskStatusRepository
|
||||||
RevisionRepo *NoteRevisionRepository
|
RevisionRepo *NoteRevisionRepository
|
||||||
GroupRepo *PermissionGroupRepository
|
GroupRepo *PermissionGroupRepository
|
||||||
ProviderRepo *AuthProviderRepository
|
ProviderRepo *AuthProviderRepository
|
||||||
@@ -47,6 +50,9 @@ 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),
|
||||||
|
TaskStatusRepo: NewTaskStatusRepository(db),
|
||||||
RevisionRepo: NewNoteRevisionRepository(db),
|
RevisionRepo: NewNoteRevisionRepository(db),
|
||||||
GroupRepo: NewPermissionGroupRepository(db),
|
GroupRepo: NewPermissionGroupRepository(db),
|
||||||
ProviderRepo: NewAuthProviderRepository(db),
|
ProviderRepo: NewAuthProviderRepository(db),
|
||||||
@@ -80,6 +86,15 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
|
|||||||
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
|
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
|
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskRepository implements task data access.
|
||||||
|
type TaskRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskRepository creates a new task repository.
|
||||||
|
func NewTaskRepository(db *mongo.Database) *TaskRepository {
|
||||||
|
return &TaskRepository{collection: db.Collection("tasks")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) CreateTask(ctx context.Context, task *entities.Task) error {
|
||||||
|
task.ID = bson.NewObjectID()
|
||||||
|
task.CreatedAt = time.Now()
|
||||||
|
task.UpdatedAt = time.Now()
|
||||||
|
if task.NoteLinks == nil {
|
||||||
|
task.NoteLinks = []bson.ObjectID{}
|
||||||
|
}
|
||||||
|
_, err := r.collection.InsertOne(ctx, task)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error) {
|
||||||
|
var task entities.Task
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&task)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("task not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error) {
|
||||||
|
query := bson.M{"space_id": spaceID}
|
||||||
|
for k, v := range filters {
|
||||||
|
query[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
|
||||||
|
cursor, err := r.collection.Find(ctx, query, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var tasks []*entities.Task
|
||||||
|
if err := cursor.All(ctx, &tasks); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error) {
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{
|
||||||
|
"space_id": spaceID,
|
||||||
|
"$or": []bson.M{
|
||||||
|
{"title": bson.M{"$regex": query, "$options": "i"}},
|
||||||
|
{"description": bson.M{"$regex": query, "$options": "i"}},
|
||||||
|
},
|
||||||
|
}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}).SetLimit(30))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var tasks []*entities.Task
|
||||||
|
if err := cursor.All(ctx, &tasks); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) UpdateTask(ctx context.Context, task *entities.Task) error {
|
||||||
|
task.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": task.ID}, task)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) DeleteTask(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error) {
|
||||||
|
return r.collection.CountDocuments(ctx, bson.M{"parent_task_id": parentTaskID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "updated_at", Value: -1}}},
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "status_id", Value: 1}}},
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "task_list_id", Value: 1}}},
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "parent_task_id", Value: 1}}},
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "note_links", Value: 1}}},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskListRepository implements task list data access.
|
||||||
|
type TaskListRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskListRepository creates a new task list repository.
|
||||||
|
func NewTaskListRepository(db *mongo.Database) *TaskListRepository {
|
||||||
|
return &TaskListRepository{collection: db.Collection("task_lists")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) CreateTaskList(ctx context.Context, list *entities.TaskList) error {
|
||||||
|
list.ID = bson.NewObjectID()
|
||||||
|
list.CreatedAt = time.Now()
|
||||||
|
list.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.InsertOne(ctx, list)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error) {
|
||||||
|
var list entities.TaskList
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&list)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("task list not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error) {
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var lists []*entities.TaskList
|
||||||
|
if err := cursor.All(ctx, &lists); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return lists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error) {
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "category_id": categoryID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var lists []*entities.TaskList
|
||||||
|
if err := cursor.All(ctx, &lists); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return lists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) UpdateTaskList(ctx context.Context, list *entities.TaskList) error {
|
||||||
|
list.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": list.ID}, list)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) DeleteTaskList(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskListRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}, {Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStatusRepository implements task status data access.
|
||||||
|
type TaskStatusRepository struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskStatusRepository creates a new task status repository.
|
||||||
|
func NewTaskStatusRepository(db *mongo.Database) *TaskStatusRepository {
|
||||||
|
return &TaskStatusRepository{collection: db.Collection("task_statuses")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) CreateStatus(ctx context.Context, status *entities.TaskStatus) error {
|
||||||
|
status.ID = bson.NewObjectID()
|
||||||
|
status.CreatedAt = time.Now()
|
||||||
|
status.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.InsertOne(ctx, status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error) {
|
||||||
|
var status entities.TaskStatus
|
||||||
|
err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&status)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, errors.New("task status not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error) {
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{"task_list_id": taskListID}, options.Find().SetSort(bson.D{{Key: "order", Value: 1}}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var statuses []*entities.TaskStatus
|
||||||
|
if err := cursor.All(ctx, &statuses); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) UpdateStatus(ctx context.Context, status *entities.TaskStatus) error {
|
||||||
|
status.UpdatedAt = time.Now()
|
||||||
|
_, err := r.collection.ReplaceOne(ctx, bson.M{"_id": status.ID}, status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) DeleteStatus(ctx context.Context, id bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error {
|
||||||
|
_, err := r.collection.DeleteMany(ctx, bson.M{"task_list_id": taskListID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TaskStatusRepository) EnsureIndexes(ctx context.Context) error {
|
||||||
|
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
||||||
|
{
|
||||||
|
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "name", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: bson.D{{Key: "task_list_id", Value: 1}, {Key: "order", Value: 1}},
|
||||||
|
Options: options.Index().SetUnique(true),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskHandler handles task and task status endpoints.
|
||||||
|
type TaskHandler struct {
|
||||||
|
taskService *services.TaskService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskHandler creates a task handler.
|
||||||
|
func NewTaskHandler(taskService *services.TaskService) *TaskHandler {
|
||||||
|
return &TaskHandler{taskService: taskService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) {
|
||||||
|
userID, err := getUserObjectID(r)
|
||||||
|
if err != nil {
|
||||||
|
return bson.NilObjectID, bson.NilObjectID, err
|
||||||
|
}
|
||||||
|
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||||
|
if err != nil {
|
||||||
|
return bson.NilObjectID, bson.NilObjectID, err
|
||||||
|
}
|
||||||
|
return userID, spaceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.CreateTask(r.Context(), spaceID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
|
||||||
|
statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
|
||||||
|
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
|
||||||
|
|
||||||
|
taskListFilter := &taskListID
|
||||||
|
statusFilter := &statusID
|
||||||
|
parentFilter := &parentTaskID
|
||||||
|
if taskListID == "" {
|
||||||
|
taskListFilter = nil
|
||||||
|
}
|
||||||
|
if statusID == "" {
|
||||||
|
statusFilter = nil
|
||||||
|
}
|
||||||
|
if parentTaskID == "" {
|
||||||
|
parentFilter = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(lists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateTaskListRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateTaskListRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateTaskRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.TaskTransitionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.LinkTaskNoteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteID, err := bson.ObjectIDFromHex(req.NoteID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateTaskStatusRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateTaskStatusRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, spaceID, err := parseIDsFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ReorderTaskStatusesRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(statuses)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
-1224
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
|
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
|
||||||
Files
|
Files
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="spaceId"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
|
||||||
|
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
|
||||||
|
@click="toggleTaskPicker"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,19 +35,52 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
|
<div :class="editorColumnClass">
|
||||||
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||||
|
<div v-if="showTaskMention" class="task-mention-panel">
|
||||||
|
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
|
||||||
|
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
|
||||||
|
<span>{{ task.title }}</span>
|
||||||
|
<small>Depth {{ task.depth + 1 }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
<div :class="previewColumnClass">
|
||||||
<div class="preview-pane border rounded p-3">
|
<div class="preview-pane border rounded p-3" @click="onPreviewClick">
|
||||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
|
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
|
||||||
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
|
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
|
||||||
|
<div class="task-picker border rounded">
|
||||||
|
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
|
||||||
|
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
|
||||||
|
<span class="small fw-semibold">Space Tasks</span>
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
|
||||||
|
<i class="mdi mdi-refresh" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="task-picker-search p-2 border-bottom">
|
||||||
|
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
|
||||||
|
</div>
|
||||||
|
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
|
||||||
|
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
|
||||||
|
Loading tasks...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
|
||||||
|
<div v-else class="task-picker-list">
|
||||||
|
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
|
||||||
|
<span class="task-picker-title">{{ task.title }}</span>
|
||||||
|
<small>{{ task.picker_status_name }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -82,24 +125,33 @@
|
|||||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
<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>
|
||||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useSettingsStore } from "../stores/settingsStore";
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
|
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: {
|
||||||
@@ -120,8 +172,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const spaceStore = useSpaceStore();
|
||||||
const publicSharingEnabled = ref(true);
|
const publicSharingEnabled = ref(true);
|
||||||
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
||||||
|
|
||||||
@@ -135,12 +188,111 @@ const notePassword = ref("");
|
|||||||
const saveTimeout = ref(null);
|
const saveTimeout = ref(null);
|
||||||
const saveState = ref("saved");
|
const saveState = ref("saved");
|
||||||
const saveStateTimeout = ref(null);
|
const saveStateTimeout = ref(null);
|
||||||
|
const taskMentionQuery = ref("");
|
||||||
|
const taskMentionResults = ref([]);
|
||||||
|
const showTaskMention = ref(false);
|
||||||
|
const linkedTasks = ref([]);
|
||||||
|
const showTaskPicker = ref(false);
|
||||||
|
const taskPickerQuery = ref("");
|
||||||
|
const taskPickerLoading = ref(false);
|
||||||
|
const showDeleteConfirmModal = ref(false);
|
||||||
|
|
||||||
|
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
||||||
|
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
||||||
|
|
||||||
|
const editorColumnClass = computed(() => {
|
||||||
|
if (hasTwoAuxPanels.value) {
|
||||||
|
return "col-12 col-xl-4";
|
||||||
|
}
|
||||||
|
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewColumnClass = computed(() => {
|
||||||
|
if (hasTwoAuxPanels.value) {
|
||||||
|
return "col-12 col-xl-4 mt-3 mt-xl-0";
|
||||||
|
}
|
||||||
|
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileExplorerColumnClass = computed(() => {
|
||||||
|
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskPickerColumnClass = computed(() => {
|
||||||
|
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskStatusNameById = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const status of spaceStore.taskStatuses || []) {
|
||||||
|
if (status?.id) {
|
||||||
|
map.set(status.id, status.name || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskPickerItems = computed(() => {
|
||||||
|
const query = taskPickerQuery.value.trim().toLowerCase();
|
||||||
|
const allTasks = [...(spaceStore.tasks || [])]
|
||||||
|
.map((task) => ({
|
||||||
|
...task,
|
||||||
|
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
||||||
|
if (!query) {
|
||||||
|
return allTasks;
|
||||||
|
}
|
||||||
|
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
const renderedMarkdown = computed(() => {
|
||||||
const html = renderMarkdown(editingNote.value.content || "");
|
const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
|
||||||
return DOMPurify.sanitize(html);
|
return DOMPurify.sanitize(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const taskByTitle = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const task of linkedTasks.value || []) {
|
||||||
|
const key = normalizeTaskTitle(task.title);
|
||||||
|
if (!key || map.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(key, task);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichTaskMentions = (content) => {
|
||||||
|
if (!content) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
|
||||||
|
const title = (rawTitle || "").trim();
|
||||||
|
if (!title) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
|
||||||
|
if (!linkedTask?.id) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusName = (linkedTask.status_name || "Unknown").trim();
|
||||||
|
const safeTitle = title.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
|
||||||
|
const statusColor = (linkedTask.status_color || "").trim();
|
||||||
|
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
|
||||||
|
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
|
||||||
|
|
||||||
|
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveStatusLabel = computed(() => {
|
const saveStatusLabel = computed(() => {
|
||||||
switch (saveState.value) {
|
switch (saveState.value) {
|
||||||
case "dirty":
|
case "dirty":
|
||||||
@@ -155,12 +307,21 @@ const saveStatusLabel = computed(() => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.note,
|
() => props.note,
|
||||||
(newNote) => {
|
async (newNote) => {
|
||||||
editingNote.value = { ...newNote };
|
editingNote.value = { ...newNote };
|
||||||
tagsInput.value = newNote.tags?.join(", ") || "";
|
tagsInput.value = newNote.tags?.join(", ") || "";
|
||||||
passwordAction.value = "keep";
|
passwordAction.value = "keep";
|
||||||
notePassword.value = "";
|
notePassword.value = "";
|
||||||
saveState.value = "saved";
|
saveState.value = "saved";
|
||||||
|
if (props.spaceId && newNote?.id) {
|
||||||
|
try {
|
||||||
|
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
|
||||||
|
} catch {
|
||||||
|
linkedTasks.value = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
linkedTasks.value = [];
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -211,21 +372,33 @@ const saveNote = () => {
|
|||||||
notePassword.value = "";
|
notePassword.value = "";
|
||||||
}
|
}
|
||||||
markSavedSoon();
|
markSavedSoon();
|
||||||
|
// Auto-link any @task(Title) mentions present in the saved content
|
||||||
|
syncTaskMentionLinks(note.content || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSave = () => {
|
const autoSave = () => {
|
||||||
saveState.value = "dirty";
|
saveState.value = "dirty";
|
||||||
clearTimeout(saveTimeout.value);
|
clearTimeout(saveTimeout.value);
|
||||||
saveTimeout.value = setTimeout(saveNote, 3000);
|
saveTimeout.value = setTimeout(saveNote, 3000);
|
||||||
|
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. */
|
||||||
@@ -249,6 +422,152 @@ const insertAtCursor = (snippet) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const detectTaskMention = async () => {
|
||||||
|
const content = editingNote.value.content || "";
|
||||||
|
const match = content.match(/@task\s+([^\n]{1,40})$/i);
|
||||||
|
if (!match || !props.spaceId) {
|
||||||
|
showTaskMention.value = false;
|
||||||
|
taskMentionResults.value = [];
|
||||||
|
taskMentionQuery.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = match[1].trim();
|
||||||
|
taskMentionQuery.value = query;
|
||||||
|
if (!query) {
|
||||||
|
showTaskMention.value = false;
|
||||||
|
taskMentionResults.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
|
||||||
|
showTaskMention.value = taskMentionResults.value.length > 0;
|
||||||
|
} catch {
|
||||||
|
taskMentionResults.value = [];
|
||||||
|
showTaskMention.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceTaskMentionText = (title) => {
|
||||||
|
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectMentionTask = async (task) => {
|
||||||
|
replaceTaskMentionText(task.title);
|
||||||
|
showTaskMention.value = false;
|
||||||
|
taskMentionResults.value = [];
|
||||||
|
if (!props.spaceId || !editingNote.value.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
|
||||||
|
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||||
|
} catch {
|
||||||
|
alert("Unable to link task to this note.");
|
||||||
|
}
|
||||||
|
autoSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertTaskMention = (task) => {
|
||||||
|
if (!task?.title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertAtCursor(`@task(${task.title})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTaskPicker = async () => {
|
||||||
|
if (!props.spaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskPickerLoading.value = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
|
||||||
|
} finally {
|
||||||
|
taskPickerLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTaskPicker = async () => {
|
||||||
|
showTaskPicker.value = !showTaskPicker.value;
|
||||||
|
if (showTaskPicker.value && !spaceStore.tasks.length) {
|
||||||
|
await refreshTaskPicker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse all @task(Title) mentions in content and ensure each is linked.
|
||||||
|
* Called after every real save so new mentions are linked automatically.
|
||||||
|
*/
|
||||||
|
const syncTaskMentionLinks = async (content) => {
|
||||||
|
if (!props.spaceId || !editingNote.value.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mentionTitles = new Set();
|
||||||
|
const rx = /@task\(([^)]+)\)/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = rx.exec(content)) !== null) {
|
||||||
|
const title = (m[1] || "").trim();
|
||||||
|
if (title) {
|
||||||
|
mentionTitles.add(title.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mentionTitles.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current;
|
||||||
|
try {
|
||||||
|
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
|
||||||
|
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
|
||||||
|
if (!toLink.length) {
|
||||||
|
linkedTasks.value = current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
toLink.map(async (title) => {
|
||||||
|
try {
|
||||||
|
const results = await spaceStore.searchTasks(props.spaceId, title);
|
||||||
|
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
|
||||||
|
if (exact) {
|
||||||
|
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort — skip silently
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreviewClick = (event) => {
|
||||||
|
const anchor = event.target?.closest?.("a");
|
||||||
|
if (!anchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = anchor.getAttribute("href") || "";
|
||||||
|
if (!href.startsWith("#task:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const taskId = href.slice("#task:".length);
|
||||||
|
if (!taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
|
||||||
|
if (matchedTask) {
|
||||||
|
emit("open-linked-task", matchedTask);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearTimeout(saveTimeout.value);
|
clearTimeout(saveTimeout.value);
|
||||||
clearTimeout(saveStateTimeout.value);
|
clearTimeout(saveStateTimeout.value);
|
||||||
@@ -257,120 +576,17 @@ onBeforeUnmount(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await settingsStore.loadFeatureFlags();
|
await settingsStore.loadFeatureFlags();
|
||||||
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
||||||
|
if (props.spaceId && editingNote.value?.id) {
|
||||||
|
try {
|
||||||
|
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||||
|
} catch {
|
||||||
|
linkedTasks.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.spaceId && !spaceStore.tasks.length) {
|
||||||
|
await refreshTaskPicker();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode overrides */
|
|
||||||
:root[data-bs-theme="dark"] .editor-toolbar {
|
|
||||||
border-bottom-color: #3a3f4b;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .flag-check {
|
|
||||||
background: #2d3748;
|
|
||||||
border-color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .preview-pane {
|
|
||||||
background-color: #21252e;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone {
|
|
||||||
background: #2d1a1a;
|
|
||||||
border-color: #7a3030;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-title {
|
|
||||||
color: #fc8181;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .danger-zone-copy {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
<div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -46,13 +46,61 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
|
linkedTasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["open-linked-task"]);
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
const renderedMarkdown = computed(() => {
|
||||||
const html = renderMarkdown(props.note.content || "");
|
const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
|
||||||
return DOMPurify.sanitize(html);
|
return DOMPurify.sanitize(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const taskByTitle = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const task of props.linkedTasks || []) {
|
||||||
|
const key = normalizeTaskTitle(task.title);
|
||||||
|
if (!key || map.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(key, task);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichTaskMentions = (content) => {
|
||||||
|
if (!content) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
|
||||||
|
const title = (rawTitle || "").trim();
|
||||||
|
if (!title) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
|
||||||
|
if (!linkedTask?.id) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusName = (linkedTask.status_name || "Unknown").trim();
|
||||||
|
const safeTitle = title.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
const safeStatusName = statusName.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||||
|
|
||||||
|
const statusColor = (linkedTask.status_color || "").trim();
|
||||||
|
const safeStatusColor = statusColor.replace(/"/g, "").replace(/</g, "").replace(/>/g, "");
|
||||||
|
const styleAttr = safeStatusColor ? ` style="--task-status-color:${safeStatusColor}"` : "";
|
||||||
|
|
||||||
|
return `<a href="#task:${linkedTask.id}" class="task-inline-link"${styleAttr}><i class="mdi mdi-checkbox-marked-circle-outline" aria-hidden="true"></i><span class="task-inline-title">${safeTitle}</span><span class="task-inline-status">${safeStatusName}</span></a>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
const categoryId = props.note.category_id;
|
const categoryId = props.note.category_id;
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
@@ -79,144 +127,29 @@ const categoryLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
||||||
|
|
||||||
|
const onMarkdownClick = (event) => {
|
||||||
|
const anchor = event.target?.closest?.("a");
|
||||||
|
if (!anchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = anchor.getAttribute("href") || "";
|
||||||
|
if (!href.startsWith("#task:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const taskId = href.slice("#task:".length);
|
||||||
|
if (!taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
|
||||||
|
if (matchedTask) {
|
||||||
|
emit("open-linked-task", matchedTask);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</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(h1),
|
|
||||||
.markdown-body :deep(h2),
|
|
||||||
.markdown-body :deep(h3) {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body :deep(p),
|
|
||||||
.markdown-body :deep(li) {
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body :deep(pre) {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body :deep(code) {
|
|
||||||
font-family: "Courier New", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-body :deep(blockquote) {
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-left: 4px solid #748ffc;
|
|
||||||
background: #f8f9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode overrides */
|
|
||||||
:root[data-bs-theme="dark"] .note-meta {
|
|
||||||
border-bottom-color: #3a3f4b;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .tag-chip {
|
|
||||||
background: #1e2d5f;
|
|
||||||
color: #93b4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .pinned-chip {
|
|
||||||
color: #7dd3fc;
|
|
||||||
background: #1a3a5c;
|
|
||||||
border-color: #2d6a9f;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .featured-chip {
|
|
||||||
color: #fbbf24;
|
|
||||||
background: #3a2e0a;
|
|
||||||
border-color: #7a5a0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .public-chip {
|
|
||||||
color: #67e8f9;
|
|
||||||
background: #0c2a3a;
|
|
||||||
border-color: #1d6a7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .private-chip {
|
|
||||||
color: #c4b5fd;
|
|
||||||
background: #2d1f5e;
|
|
||||||
border-color: #5b3f9a;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .protected-chip {
|
|
||||||
color: #fdba74;
|
|
||||||
background: #3a1f0a;
|
|
||||||
border-color: #7a4f1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-bs-theme="dark"] .markdown-body :deep(blockquote) {
|
|
||||||
background: #1e2430;
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<template>
|
||||||
|
<section class="task-board">
|
||||||
|
<div class="task-board-header">
|
||||||
|
<div class="task-title-wrap">
|
||||||
|
<h4 class="mb-0">Tasks</h4>
|
||||||
|
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-filters">
|
||||||
|
<select v-model="filterStatus" class="form-select" @change="emitFilters">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option v-for="status in statuses" :key="status.id" :value="status.id">
|
||||||
|
{{ status.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filterParent" class="form-select" @change="emitFilters">
|
||||||
|
<option value="">Any parent</option>
|
||||||
|
<option value="root">Top-level only</option>
|
||||||
|
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
|
||||||
|
{{ task.title }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-status-groups">
|
||||||
|
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
||||||
|
|
||||||
|
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
|
||||||
|
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
|
||||||
|
<div class="status-group-title-wrap">
|
||||||
|
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
|
||||||
|
<span class="status-group-title">{{ section.status.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-group-count">{{ section.parentTasks.length }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
|
||||||
|
|
||||||
|
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
|
||||||
|
<div
|
||||||
|
class="task-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="emit('select-task', parentTask)"
|
||||||
|
@keydown.enter="emit('select-task', parentTask)"
|
||||||
|
@keydown.space.prevent="emit('select-task', parentTask)"
|
||||||
|
>
|
||||||
|
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
|
||||||
|
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
|
||||||
|
</span>
|
||||||
|
<span class="task-main">
|
||||||
|
<strong>{{ parentTask.title }}</strong>
|
||||||
|
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
|
||||||
|
</span>
|
||||||
|
<div class="task-status-menu" @click.stop>
|
||||||
|
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
|
||||||
|
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
|
||||||
|
</button>
|
||||||
|
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
|
||||||
|
<button
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
type="button"
|
||||||
|
class="status-option"
|
||||||
|
:class="{ selected: parentTask.status_id === status.id }"
|
||||||
|
@click="onTaskStatusChange(parentTask, status.id)"
|
||||||
|
>
|
||||||
|
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||||
|
<span class="status-option-label">{{ status.name }}</span>
|
||||||
|
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isExpanded(parentTask.id)">
|
||||||
|
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
|
||||||
|
<div
|
||||||
|
class="task-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="emit('select-task', childTask)"
|
||||||
|
@keydown.enter="emit('select-task', childTask)"
|
||||||
|
@keydown.space.prevent="emit('select-task', childTask)"
|
||||||
|
>
|
||||||
|
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
|
||||||
|
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
|
||||||
|
</span>
|
||||||
|
<span class="task-main">
|
||||||
|
<strong>{{ childTask.title }}</strong>
|
||||||
|
<small class="text-muted">{{ childTask.description || "No description" }}</small>
|
||||||
|
</span>
|
||||||
|
<div class="task-status-menu" @click.stop>
|
||||||
|
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
|
||||||
|
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
|
||||||
|
</button>
|
||||||
|
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
|
||||||
|
<button
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
type="button"
|
||||||
|
class="status-option"
|
||||||
|
:class="{ selected: childTask.status_id === status.id }"
|
||||||
|
@click="onTaskStatusChange(childTask, status.id)"
|
||||||
|
>
|
||||||
|
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||||
|
<span class="status-option-label">{{ status.name }}</span>
|
||||||
|
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isExpanded(childTask.id)">
|
||||||
|
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
|
||||||
|
<div
|
||||||
|
class="task-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="emit('select-task', grandchildTask)"
|
||||||
|
@keydown.enter="emit('select-task', grandchildTask)"
|
||||||
|
@keydown.space.prevent="emit('select-task', grandchildTask)"
|
||||||
|
>
|
||||||
|
<span class="tree-toggle"></span>
|
||||||
|
<span class="task-main">
|
||||||
|
<strong>{{ grandchildTask.title }}</strong>
|
||||||
|
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
|
||||||
|
</span>
|
||||||
|
<div class="task-status-menu" @click.stop>
|
||||||
|
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
|
||||||
|
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
|
||||||
|
</button>
|
||||||
|
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
|
||||||
|
<button
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
type="button"
|
||||||
|
class="status-option"
|
||||||
|
:class="{ selected: grandchildTask.status_id === status.id }"
|
||||||
|
@click="onTaskStatusChange(grandchildTask, status.id)"
|
||||||
|
>
|
||||||
|
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||||
|
<span class="status-option-label">{{ status.name }}</span>
|
||||||
|
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedTaskList: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
|
||||||
|
|
||||||
|
const filterStatus = ref("");
|
||||||
|
const filterParent = ref("");
|
||||||
|
const expandedTaskIds = ref({});
|
||||||
|
const openStatusMenuTaskId = ref("");
|
||||||
|
|
||||||
|
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
||||||
|
const tasksById = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const task of props.tasks) {
|
||||||
|
map.set(task.id, task);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasksByParentId = computed(() => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const task of props.tasks) {
|
||||||
|
if (!task.parent_task_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = map.get(task.parent_task_id) || [];
|
||||||
|
existing.push(task);
|
||||||
|
map.set(task.parent_task_id, existing);
|
||||||
|
}
|
||||||
|
for (const [key, children] of map) {
|
||||||
|
map.set(
|
||||||
|
key,
|
||||||
|
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
|
||||||
|
|
||||||
|
const statusSections = computed(() =>
|
||||||
|
props.statuses.map((status) => ({
|
||||||
|
status,
|
||||||
|
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emitFilters = () => {
|
||||||
|
emit("filter-change", {
|
||||||
|
statusId: filterStatus.value || null,
|
||||||
|
parentTaskId: filterParent.value || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusHeaderStyle = (status) => {
|
||||||
|
const color = status.color || "#7c8596";
|
||||||
|
return {
|
||||||
|
borderColor: color,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
|
||||||
|
|
||||||
|
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
|
||||||
|
|
||||||
|
const statusDotStyle = (statusId) => ({
|
||||||
|
backgroundColor: statusColor(statusId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
|
||||||
|
|
||||||
|
const toggleStatusMenu = (taskId) => {
|
||||||
|
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStatusMenu = () => {
|
||||||
|
openStatusMenuTaskId.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentClick = () => {
|
||||||
|
closeStatusMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
|
||||||
|
|
||||||
|
const hasChildren = (task) => childrenFor(task.id).length > 0;
|
||||||
|
|
||||||
|
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
|
||||||
|
|
||||||
|
const toggleExpanded = (task) => {
|
||||||
|
if (!hasChildren(task)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedTaskIds.value = {
|
||||||
|
...expandedTaskIds.value,
|
||||||
|
[task.id]: !expandedTaskIds.value[task.id],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTaskStatusChange = (task, statusId) => {
|
||||||
|
if (!task?.id || !statusId || task.status_id === statusId) {
|
||||||
|
closeStatusMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("update-task-status", {
|
||||||
|
taskId: task.id,
|
||||||
|
currentStatusId: task.status_id,
|
||||||
|
targetStatusId: statusId,
|
||||||
|
});
|
||||||
|
closeStatusMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener("click", onDocumentClick);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("click", onDocumentClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
|
||||||
|
|
||||||
|
<label class="form-label mt-3">Description</label>
|
||||||
|
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
|
||||||
|
|
||||||
|
<label class="form-label mt-3">Parent Task</label>
|
||||||
|
<select v-model="localTask.parent_task_id" class="form-select">
|
||||||
|
<option value="">No parent (top level)</option>
|
||||||
|
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select v-model="localTask.status_id" class="form-select">
|
||||||
|
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="status-progress mt-3">
|
||||||
|
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
|
||||||
|
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
|
||||||
|
<span>{{ status.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
|
||||||
|
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6>Subtasks</h6>
|
||||||
|
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
|
||||||
|
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
|
||||||
|
<span>{{ subtask.title }}</span>
|
||||||
|
<small>L{{ subtask.depth + 1 }}</small>
|
||||||
|
</button>
|
||||||
|
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
|
||||||
|
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
task: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
parentTaskOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
subtasks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
|
||||||
|
|
||||||
|
const localTask = ref({});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.task,
|
||||||
|
(value) => {
|
||||||
|
localTask.value = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
task_list_id: "",
|
||||||
|
status_id: props.statuses[0]?.id || "",
|
||||||
|
parent_task_id: "",
|
||||||
|
note_links: [],
|
||||||
|
...value,
|
||||||
|
task_list_id: value?.task_list_id || "",
|
||||||
|
parent_task_id: value?.parent_task_id || "",
|
||||||
|
note_links: value?.note_links || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
|
||||||
|
|
||||||
|
const isReached = (status) => {
|
||||||
|
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||||
|
return status.order <= current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepClass = (status) => {
|
||||||
|
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||||
|
return {
|
||||||
|
current: status.order === current,
|
||||||
|
done: status.order < current,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTask = () => {
|
||||||
|
emit("save-task", {
|
||||||
|
...localTask.value,
|
||||||
|
task_list_id: localTask.value.task_list_id || null,
|
||||||
|
parent_task_id: localTask.value.parent_task_id || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="home-page">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup></script>
|
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +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 taskStatuses = ref([]);
|
||||||
|
const noteLinkedTasks = ref([]);
|
||||||
|
|
||||||
const refreshSpaceData = async (spaceId) => {
|
const refreshSpaceData = async (spaceId) => {
|
||||||
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]);
|
await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId), fetchTaskLists(spaceId), fetchTasks(spaceId)]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSpaces = async () => {
|
const fetchSpaces = async () => {
|
||||||
@@ -208,6 +212,163 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchTaskStatuses = async (spaceId, taskListId) => {
|
||||||
|
if (!spaceId || !taskListId) {
|
||||||
|
taskStatuses.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`);
|
||||||
|
taskStatuses.value = response.data || [];
|
||||||
|
return taskStatuses.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching task statuses:", error);
|
||||||
|
taskStatuses.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTaskLists = async (spaceId) => {
|
||||||
|
if (!spaceId) {
|
||||||
|
taskLists.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/task-lists`);
|
||||||
|
taskLists.value = response.data || [];
|
||||||
|
return taskLists.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching task lists:", error);
|
||||||
|
taskLists.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTaskList = async (spaceId, payload) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists`, payload);
|
||||||
|
await fetchTaskLists(spaceId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTaskList = async (spaceId, taskListId, payload) => {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`, payload);
|
||||||
|
await fetchTaskLists(spaceId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTaskList = async (spaceId, taskListId) => {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}`);
|
||||||
|
await fetchTaskLists(spaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTaskStatus = async (spaceId, taskListId, payload) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses`, payload);
|
||||||
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTaskStatus = async (spaceId, taskListId, statusId, payload) => {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`, payload);
|
||||||
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTaskStatus = async (spaceId, taskListId, statusId) => {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/${statusId}`);
|
||||||
|
await fetchTaskStatuses(spaceId, taskListId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderTaskStatuses = async (spaceId, taskListId, orderedStatusIds) => {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/task-lists/${taskListId}/statuses/reorder`, {
|
||||||
|
ordered_status_ids: orderedStatusIds,
|
||||||
|
});
|
||||||
|
taskStatuses.value = response.data || [];
|
||||||
|
return taskStatuses.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasks = async (spaceId, filters = {}) => {
|
||||||
|
if (!spaceId) {
|
||||||
|
tasks.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const params = {};
|
||||||
|
if (filters.taskListId) {
|
||||||
|
params.taskListId = filters.taskListId;
|
||||||
|
}
|
||||||
|
if (filters.statusId) {
|
||||||
|
params.statusId = filters.statusId;
|
||||||
|
}
|
||||||
|
if (typeof filters.parentTaskId === "string") {
|
||||||
|
params.parentTaskId = filters.parentTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks`, { params });
|
||||||
|
tasks.value = response.data || [];
|
||||||
|
return tasks.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tasks:", error);
|
||||||
|
tasks.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchTasks = async (spaceId, query) => {
|
||||||
|
if (!spaceId || !query?.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/search`, { params: { q: query } });
|
||||||
|
return response.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTask = async (spaceId, taskId) => {
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTask = async (spaceId, payload) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks`, payload);
|
||||||
|
await fetchTasks(spaceId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTask = async (spaceId, taskId, payload) => {
|
||||||
|
const response = await apiClient.put(`/api/v1/spaces/${spaceId}/tasks/${taskId}`, payload);
|
||||||
|
await fetchTasks(spaceId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTask = async (spaceId, taskId) => {
|
||||||
|
await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}`);
|
||||||
|
await fetchTasks(spaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionTask = async (spaceId, taskId, direction) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/transition`, { direction });
|
||||||
|
await fetchTasks(spaceId);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasksForNote = async (spaceId, noteId) => {
|
||||||
|
if (!spaceId || !noteId) {
|
||||||
|
noteLinkedTasks.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes/${noteId}/tasks`);
|
||||||
|
noteLinkedTasks.value = response.data || [];
|
||||||
|
return noteLinkedTasks.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkTaskToNote = async (spaceId, taskId, noteId) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes`, { note_id: noteId });
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlinkTaskFromNote = async (spaceId, taskId, noteId) => {
|
||||||
|
const response = await apiClient.delete(`/api/v1/spaces/${spaceId}/tasks/${taskId}/notes/${noteId}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces,
|
spaces,
|
||||||
currentSpace,
|
currentSpace,
|
||||||
@@ -217,6 +378,10 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
notesLoading,
|
notesLoading,
|
||||||
categories,
|
categories,
|
||||||
categoryTree,
|
categoryTree,
|
||||||
|
taskLists,
|
||||||
|
tasks,
|
||||||
|
taskStatuses,
|
||||||
|
noteLinkedTasks,
|
||||||
fetchSpaces,
|
fetchSpaces,
|
||||||
selectSpace,
|
selectSpace,
|
||||||
fetchNotes,
|
fetchNotes,
|
||||||
@@ -232,5 +397,24 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
deleteNote,
|
deleteNote,
|
||||||
searchNotes,
|
searchNotes,
|
||||||
clearSearchResults,
|
clearSearchResults,
|
||||||
|
fetchTaskStatuses,
|
||||||
|
fetchTaskLists,
|
||||||
|
createTaskList,
|
||||||
|
updateTaskList,
|
||||||
|
deleteTaskList,
|
||||||
|
createTaskStatus,
|
||||||
|
updateTaskStatus,
|
||||||
|
deleteTaskStatus,
|
||||||
|
reorderTaskStatuses,
|
||||||
|
fetchTasks,
|
||||||
|
searchTasks,
|
||||||
|
getTask,
|
||||||
|
createTask,
|
||||||
|
updateTask,
|
||||||
|
deleteTask,
|
||||||
|
transitionTask,
|
||||||
|
fetchTasksForNote,
|
||||||
|
linkTaskToNote,
|
||||||
|
unlinkTaskFromNote,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
trailingSlash: true,
|
||||||
|
distDir: "out",
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2281
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>"{searchQuery}"</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 />;
|
||||||
|
}
|
||||||
+1025
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
import TaskListPageClient from "./TaskListPageClient";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return [{ spaceId: "__space__", taskListId: "__tasklist__" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskListPage() {
|
||||||
|
return <TaskListPageClient />;
|
||||||
|
}
|
||||||
+132
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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't have an account? <Link href="/register">Register here</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user