Compare commits
10 Commits
v1.0.10
...
503d2415e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503d2415e6 | ||
|
|
74d8899eec | ||
|
|
295e03feb4 | ||
|
|
b09137eca5 | ||
|
|
b9ca845b9c | ||
|
|
a1dd2f2c00 | ||
|
|
a081bff35b | ||
|
|
1b336299ee | ||
|
|
d793b5ccf2 | ||
|
|
005a8f4cf0 |
@@ -10,8 +10,6 @@ JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production
|
||||
ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882
|
||||
FRONTEND_URL="http://localhost"
|
||||
|
||||
VITE_API_BASE_URL="http://localhost"
|
||||
|
||||
# Default Admin
|
||||
DEFAULT_ADMIN_EMAIL=admin@notely.local
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
|
||||
@@ -46,8 +46,6 @@ jobs:
|
||||
context: .
|
||||
file: ./devops/docker/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_API_BASE_URL=${{ secrets.VITE_API_BASE_URL }}
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.short_sha }}
|
||||
|
||||
212
ENV_SETUP.md
212
ENV_SETUP.md
@@ -1,98 +1,148 @@
|
||||
# Environment Configuration
|
||||
# Environment Setup
|
||||
|
||||
Copy `.env.example` files and configure for your environment:
|
||||
Notely uses three different environment-file locations depending on how you run the app.
|
||||
|
||||
## Backend (.env)
|
||||
## 1. Root `.env`
|
||||
|
||||
```env
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||
Use the root `.env` file when running `docker compose` from the repository root.
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
|
||||
JWT_ISSUER=noteapp
|
||||
|
||||
# Encryption (32 bytes = 32 characters)
|
||||
ENCRYPTION_KEY=00000000000000000000000000000000
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS (comma-separated for multiple origins)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS=50
|
||||
RATE_LIMIT_WINDOW=1s
|
||||
```
|
||||
|
||||
## Frontend (.env)
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_ENV=development
|
||||
```
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### Development (.env.development)
|
||||
|
||||
- Less strict security (for easier testing)
|
||||
- Localhost CORS allowed
|
||||
- JWT secrets can be simple
|
||||
- Logging more verbose
|
||||
|
||||
### Production (.env.production)
|
||||
|
||||
- Strict security requirements
|
||||
- Specific CORS origins only
|
||||
- Strong random JWT secrets
|
||||
- Limited logging (performance)
|
||||
- All environment variables must be set
|
||||
|
||||
## Generating Secrets
|
||||
Start from:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Variables Used By Docker Compose
|
||||
|
||||
Required or commonly used:
|
||||
|
||||
- `MONGODB_URI`
|
||||
- `BACKEND_PORT`
|
||||
- `JWT_SECRET`
|
||||
- `ENCRYPTION_KEY`
|
||||
- `FRONTEND_URL`
|
||||
- `DEFAULT_ADMIN_EMAIL`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `NGINX_HTTP_PORT`
|
||||
- `NGINX_HTTPS_PORT`
|
||||
|
||||
Optional backend runtime values that Docker Compose will also pass through if present:
|
||||
|
||||
- `REDIS_ADDR`
|
||||
- `REDIS_USER`
|
||||
- `REDIS_PASSWORD`
|
||||
- `REDIS_DB`
|
||||
- `SESSION_TTL_HOURS`
|
||||
|
||||
### Current Defaults In The Checked-In Example
|
||||
|
||||
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
|
||||
- Backend port: `8080`
|
||||
- Public frontend URL: `http://localhost`
|
||||
|
||||
## 2. `backend/.env`
|
||||
|
||||
Use `backend/.env` for local backend development.
|
||||
|
||||
Start from:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Variables Currently Read By The Backend Runtime
|
||||
|
||||
Read in `backend/cmd/server/main.go` or other active handlers:
|
||||
|
||||
- `MONGODB_URI`
|
||||
- `JWT_SECRET`
|
||||
- `ENCRYPTION_KEY`
|
||||
- `PORT`
|
||||
- `REDIS_ADDR`
|
||||
- `REDIS_USER`
|
||||
- `REDIS_PASSWORD`
|
||||
- `REDIS_DB`
|
||||
- `SESSION_TTL_HOURS`
|
||||
- `DEFAULT_ADMIN_EMAIL`
|
||||
- `DEFAULT_ADMIN_USERNAME`
|
||||
- `DEFAULT_ADMIN_PASSWORD`
|
||||
- `FRONTEND_URL`
|
||||
|
||||
### Variables Present In `backend/.env.example` But Not Currently Consumed By Runtime Code
|
||||
|
||||
These values exist in the example file, but the current code path does not read them yet:
|
||||
|
||||
- `JWT_ISSUER`
|
||||
- `ENV`
|
||||
- `LOG_LEVEL`
|
||||
- `CORS_ALLOWED_ORIGINS`
|
||||
- `RATE_LIMIT_REQUESTS`
|
||||
- `RATE_LIMIT_WINDOW`
|
||||
|
||||
### Backend Defaults If A Variable Is Missing
|
||||
|
||||
- `MONGODB_URI`: `mongodb://localhost:27017`
|
||||
- `JWT_SECRET`: `your-secret-key-change-in-production`
|
||||
- `ENCRYPTION_KEY`: `00000000000000000000000000000000`
|
||||
- `PORT`: `8080`
|
||||
- `REDIS_ADDR`: `localhost:6379`
|
||||
- `REDIS_DB`: `0`
|
||||
- `SESSION_TTL_HOURS`: `168`
|
||||
- `FRONTEND_URL`: falls back to `http://localhost:5173` for login redirects
|
||||
|
||||
## 3. `frontend/.env`
|
||||
|
||||
Use `frontend/.env` for local frontend development.
|
||||
|
||||
Start from:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Frontend Variables In `frontend/.env.example`
|
||||
|
||||
- `VITE_ENV`
|
||||
- `VITE_ENABLE_ANALYTICS`
|
||||
|
||||
### Variables Currently Relevant To The Frontend App
|
||||
|
||||
- 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.
|
||||
|
||||
## Secret Generation
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# JWT Secret (32+ characters)
|
||||
openssl rand -base64 32
|
||||
|
||||
# Encryption Key (32 bytes)
|
||||
openssl rand -hex 16 # outputs 32 characters
|
||||
|
||||
# Random token
|
||||
openssl rand -hex 16
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
Use generated values for:
|
||||
|
||||
Environment variables are defined in `docker-compose.yml` and will override `.env` files. Update the file for your deployment:
|
||||
- `JWT_SECRET`
|
||||
- `ENCRYPTION_KEY`
|
||||
- provider secrets or other sensitive credentials stored through admin settings
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
MONGODB_URI: mongodb://admin:password@mongodb:27017/noteapp?authSource=admin
|
||||
JWT_SECRET: your-secret-key-change-in-production
|
||||
# ... other vars
|
||||
```
|
||||
## Compose Vs Local Development
|
||||
|
||||
## Kubernetes
|
||||
Use the right env file for the right mode:
|
||||
|
||||
Use `kubectl create secret` for sensitive data:
|
||||
- root `.env`: Docker Compose
|
||||
- `backend/.env`: local backend
|
||||
- `frontend/.env`: local frontend
|
||||
|
||||
```bash
|
||||
# Create secret from literal values
|
||||
kubectl create secret generic app-secrets \
|
||||
--from-literal=mongodb-uri="..." \
|
||||
--from-literal=jwt-secret="..." \
|
||||
-n noteapp
|
||||
Do not assume values from one location are automatically shared with the others.
|
||||
|
||||
# Or use ConfigMap for non-sensitive config
|
||||
kubectl create configmap app-config \
|
||||
--from-file=config.yaml \
|
||||
-n noteapp
|
||||
```
|
||||
## Important Notes
|
||||
|
||||
---
|
||||
|
||||
**IMPORTANT**: Never commit .env files or secrets to version control!
|
||||
- Do not commit real secrets
|
||||
- Keep `ENCRYPTION_KEY` at 32 characters for the current AES-256 usage
|
||||
- If OAuth login is enabled, set `FRONTEND_URL` correctly so callback redirects go to the intended UI
|
||||
- If Redis settings are omitted, the backend assumes a local Redis instance at `localhost:6379`
|
||||
|
||||
@@ -14,7 +14,7 @@ This file lists the permissions currently checked by the application.
|
||||
- space.edit
|
||||
- Global space edit capability (used as fallback alongside space-scoped settings edit)
|
||||
- space.delete
|
||||
- Global space delete capability (used as fallback alongside space-scoped delete)
|
||||
- Global space delete capability (used as fallback alongside space-scoped settings.delete)
|
||||
|
||||
## Space-Scoped Permission Format
|
||||
|
||||
@@ -23,6 +23,7 @@ space.<space_permission_key>.<action>
|
||||
- space_permission_key is derived from the space name (normalized token)
|
||||
- Example:
|
||||
- space.product_docs.note.create
|
||||
- space.product_docs.tasks.create
|
||||
- space.product_docs.settings.member.manage
|
||||
|
||||
## Space-Scoped Actions Currently Enforced
|
||||
@@ -30,7 +31,7 @@ space.<space_permission_key>.<action>
|
||||
### Space Management
|
||||
|
||||
- settings.edit
|
||||
- delete
|
||||
- settings.delete
|
||||
|
||||
### Member Management
|
||||
|
||||
@@ -49,6 +50,16 @@ space.<space_permission_key>.<action>
|
||||
- note.edit
|
||||
- note.delete
|
||||
|
||||
### Task Management
|
||||
|
||||
- tasks.create
|
||||
- tasks.edit
|
||||
- tasks.delete
|
||||
|
||||
### Task Status Management
|
||||
|
||||
- tasks.status.manage
|
||||
|
||||
## Wildcard Support
|
||||
|
||||
Permissions support wildcard matching with \*.
|
||||
@@ -59,6 +70,8 @@ Examples:
|
||||
- Grants all permissions for the product_docs space
|
||||
- space.\*.note.create
|
||||
- Grants note.create for all spaces
|
||||
- space.\*.tasks.\*
|
||||
- Grants all task permissions for all spaces
|
||||
- `*`
|
||||
- Grants all permissions globally
|
||||
|
||||
|
||||
323
QUICKSTART.md
323
QUICKSTART.md
@@ -1,304 +1,151 @@
|
||||
# 🚀 Quick Start Guide
|
||||
# Quick Start
|
||||
|
||||
## Prerequisites
|
||||
This guide covers the fastest way to run Notely and the current local-development workflow.
|
||||
|
||||
- Docker and Docker Compose (recommended for quickest setup)
|
||||
- OR: Go 1.21+, Node.js 18+, MongoDB 7.0+
|
||||
## Option 1: Docker Compose
|
||||
|
||||
## Option 1: Docker Compose (Recommended - 1 Command)
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# Clone/navigate to project
|
||||
cd noteapp
|
||||
|
||||
# Start everything
|
||||
docker-compose up
|
||||
|
||||
# Wait for services to initialize (~30 seconds)
|
||||
# Then open: http://localhost
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**Services running**:
|
||||
Open:
|
||||
|
||||
- Notely: http://localhost:8080
|
||||
- MongoDB: localhost:27017
|
||||
- Nginx Reverse Proxy: http://localhost:80
|
||||
- App UI: `http://localhost`
|
||||
- Backend health endpoint: `http://localhost:8080/health`
|
||||
- MongoDB: `localhost:27017`
|
||||
- Redis: `localhost:6379`
|
||||
|
||||
**Test user (after startup)**:
|
||||
Compose starts four services:
|
||||
|
||||
- Register a new account at http://localhost/register
|
||||
- Login and create a Space
|
||||
- Add Categories and Notes
|
||||
- `mongodb`
|
||||
- `redis`
|
||||
- `notely`
|
||||
- `nginx`
|
||||
|
||||
## Option 2: Local Development
|
||||
|
||||
### Backend Setup
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 18+
|
||||
- MongoDB
|
||||
- Redis
|
||||
|
||||
If you do not already have MongoDB and Redis running locally, you can start just those services with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d mongodb redis
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Ensure MongoDB is running
|
||||
# Docker: docker run -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=admin \
|
||||
# -e MONGO_INITDB_ROOT_PASSWORD=password mongo:7.0-alpine
|
||||
|
||||
# Run backend
|
||||
go run ./cmd/server/main.go
|
||||
|
||||
# Logs should show: "Server starting on port 8080"
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
The backend listens on `http://localhost:8080` by default.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Open: http://localhost:5173 in browser
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
The Vite dev server listens on `http://localhost:5173` and proxies `/api` to `http://localhost:8080`.
|
||||
|
||||
### Backend Tests
|
||||
## Day-To-Day Commands
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run specific test
|
||||
go test -v -run TestRegisterUser ./tests/unit/...
|
||||
|
||||
# With coverage
|
||||
go test -cover ./...
|
||||
go test -v ./tests/unit/...
|
||||
go test -v ./tests/integration/...
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Run tests
|
||||
npm run build
|
||||
npm run lint
|
||||
npm run test
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 📝 Key API Endpoints
|
||||
## First Run Checklist
|
||||
|
||||
### Authentication
|
||||
1. Register a user or set `DEFAULT_ADMIN_*` values in your env file before startup.
|
||||
2. Sign in.
|
||||
3. Create a space.
|
||||
4. Create categories and notes.
|
||||
5. Use the top search bar to verify note search.
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:8080/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"username": "myuser",
|
||||
"password": "SecurePassword123",
|
||||
"password_confirm": "SecurePassword123",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe"
|
||||
}'
|
||||
## Useful Endpoints
|
||||
|
||||
# Login
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePassword123"
|
||||
}'
|
||||
Authentication:
|
||||
|
||||
# Response contains: access_token, refresh_token, user data
|
||||
```
|
||||
- `POST /api/v1/auth/register`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `GET /api/v1/auth/me`
|
||||
|
||||
### Create Space
|
||||
Spaces:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/spaces \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My First Space",
|
||||
"description": "Notes for my project",
|
||||
"icon": "📚",
|
||||
"is_public": false
|
||||
}'
|
||||
```
|
||||
- `GET /api/v1/spaces`
|
||||
- `POST /api/v1/spaces`
|
||||
- `PUT /api/v1/spaces/{spaceId}`
|
||||
- `DELETE /api/v1/spaces/{spaceId}`
|
||||
|
||||
### Create Note
|
||||
Notes:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "My First Note",
|
||||
"content": "# Markdown Heading\n\nThis is a note",
|
||||
"tags": ["important", "work"],
|
||||
"category_id": null,
|
||||
"is_pinned": false,
|
||||
"is_favorite": true
|
||||
}'
|
||||
```
|
||||
- `GET /api/v1/spaces/{spaceId}/notes`
|
||||
- `POST /api/v1/spaces/{spaceId}/notes`
|
||||
- `GET /api/v1/spaces/{spaceId}/notes/search?q=<query>`
|
||||
- `POST /api/v1/spaces/{spaceId}/notes/{noteId}/unlock`
|
||||
|
||||
### Search Notes
|
||||
Public access:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/spaces/{spaceId}/notes/search?q=important" \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
- `GET /api/v1/public/spaces`
|
||||
- `GET /api/v1/public/spaces/{spaceId}/notes`
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
### MongoDB Connection Error
|
||||
### Backend cannot connect to MongoDB
|
||||
|
||||
```
|
||||
Error: Failed to connect to database
|
||||
Check `MONGODB_URI` in your selected env file and make sure MongoDB is reachable.
|
||||
|
||||
Solution:
|
||||
docker run -d -p 27017:27017 \
|
||||
-e MONGO_INITDB_ROOT_USERNAME=admin \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD=password \
|
||||
mongo:7.0-alpine
|
||||
```
|
||||
### Backend cannot connect to Redis
|
||||
|
||||
### Port Already in Use
|
||||
Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis should usually be reachable at `localhost:6379`.
|
||||
|
||||
```bash
|
||||
# Find process on port 8080
|
||||
lsof -i :8080
|
||||
### The browser cannot reach the API in local dev
|
||||
|
||||
# Kill it
|
||||
kill -9 <PID>
|
||||
Check:
|
||||
|
||||
# Or use different port
|
||||
PORT=8081 go run ./cmd/server/main.go
|
||||
```
|
||||
- backend is running on port `8080`
|
||||
- frontend and API are reachable through the same host/origin
|
||||
- Vite proxy settings in `frontend/vite.config.js`
|
||||
|
||||
### CORS Errors
|
||||
### OAuth callback redirects to the wrong URL
|
||||
|
||||
Make sure frontend and backend URLs match in:
|
||||
Check `FRONTEND_URL` in your selected env file.
|
||||
|
||||
- Frontend: `VITE_API_BASE_URL` in `.env`
|
||||
- Backend: `CORS_ALLOWED_ORIGINS` in `.env`
|
||||
### Permission-denied behavior is unclear
|
||||
|
||||
### MongoDB Auth Failed
|
||||
Read `PERMISSIONS.md` and then inspect the relevant backend service in `backend/internal/application/services/`.
|
||||
|
||||
If using Docker Compose:
|
||||
## Related Docs
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `password`
|
||||
- Connection string includes `?authSource=admin`
|
||||
|
||||
## 📚 Project Structure
|
||||
|
||||
```
|
||||
noteapp/
|
||||
├── backend/ # Go REST API
|
||||
│ ├── cmd/server/ # Entry point
|
||||
│ ├── internal/
|
||||
│ │ ├── domain/ # Business logic
|
||||
│ │ ├── application/ # Services & DTOs
|
||||
│ │ ├── infrastructure/ # DB, auth, security
|
||||
│ │ └── interfaces/ # HTTP handlers
|
||||
│ ├── tests/ # Test files
|
||||
│ ├── go.mod & go.sum # Dependencies
|
||||
│ └── README.md
|
||||
│
|
||||
├── frontend/ # Vue 3 SPA
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── stores/ # Pinia state
|
||||
│ │ ├── services/ # API client
|
||||
│ │ ├── router/ # Vue Router
|
||||
│ │ ├── assets/ # Styles & images
|
||||
│ │ └── main.js # Entry point
|
||||
│ ├── tests/ # Test files
|
||||
│ ├── package.json # Dependencies
|
||||
│ └── vite.config.js # Vite configuration
|
||||
│
|
||||
├── devops/
|
||||
│ ├── docker/
|
||||
│ │ ├── Dockerfile.backend
|
||||
│ │ ├── Dockerfile.frontend
|
||||
│ │ └── nginx.conf
|
||||
│ └── kubernetes/
|
||||
│ └── deployment.yaml
|
||||
│
|
||||
├── docker-compose.yml # Local development setup
|
||||
├── README.md # Project docs
|
||||
├── ARCHITECTURE.md # Architecture overview
|
||||
├── SECURITY.md # Security implementation
|
||||
└── ENV_SETUP.md # Environment configuration
|
||||
```
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
### Understanding the Code
|
||||
|
||||
1. **Start here**: `ARCHITECTURE.md` - Clean architecture pattern
|
||||
2. **Then read**: Backend `domain/entities/*.go` - Core models
|
||||
3. **Next**: Backend `application/services/*.go` - Business logic
|
||||
4. **UI**: Frontend `src/stores/authStore.js` - State management
|
||||
5. **API**: Backend `interfaces/handlers/*.go` - HTTP layer
|
||||
|
||||
### Security Deep Dive
|
||||
|
||||
See `SECURITY.md` for:
|
||||
|
||||
- Password hashing (Argon2id)
|
||||
- JWT authentication
|
||||
- Authorization (RBAC)
|
||||
- Input validation
|
||||
- XSS prevention
|
||||
- CSRF protection
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Explore the UI**: Create spaces, notes, categories
|
||||
2. **Read the code**: Start with `index ARCHITECTURE.md`
|
||||
3. **Run tests**: `go test ./...` and `npm test`
|
||||
4. **Deploy**: Use `docker-compose.yml` or Kubernetes
|
||||
5. **Extend**: Add OAuth2, WebSockets, more features
|
||||
|
||||
## 💡 Quick Tips
|
||||
|
||||
- **Hot reload**: Changes auto-reload in dev mode
|
||||
- **Network tab**: Check API calls in browser DevTools
|
||||
- **Logs**: Docker: `docker-compose logs -f service-name`
|
||||
- **Database GUI**: MongoDB Compass (free tool to browse data)
|
||||
- **API testing**: Postman or `curl` commands
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- Check logs: `docker-compose logs`
|
||||
- Review `SECURITY.md` for auth issues
|
||||
- Check `ENV_SETUP.md` for config problems
|
||||
- See `ARCHITECTURE.md` for code structure
|
||||
|
||||
---
|
||||
|
||||
**Now you're ready to explore and extend Notely! 🎉**
|
||||
- `README.md`
|
||||
- `ENV_SETUP.md`
|
||||
- `PERMISSIONS.md`
|
||||
|
||||
543
README.md
543
README.md
@@ -1,306 +1,174 @@
|
||||
# Notely - Secure Multi-Space Note-Taking Application
|
||||
# Notely
|
||||
|
||||
A production-ready, secure multi-tenant note-taking platform built with Go, Vue 3, and MongoDB.
|
||||
Notely is a multi-space note application built with Go, Vue 3, MongoDB, and Redis.
|
||||
|
||||
## 🚀 Quick Start
|
||||
The repository contains a Go backend, a Vue frontend, Docker Compose assets for local deployment, and Kubernetes manifests for cluster deployment. In containerized environments, the frontend is built into the backend image and served by the Go server. Docker Compose also places Nginx in front of the app for HTTP and HTTPS entry points.
|
||||
|
||||
### Prerequisites
|
||||
## What Is In This Repo
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Go 1.21+ (for local development)
|
||||
- Node.js 18+ (for frontend development)
|
||||
- MongoDB 7.0+ (for local development)
|
||||
- Backend API in `backend/`
|
||||
- Frontend SPA in `frontend/`
|
||||
- Docker and Nginx assets in `devops/docker/`
|
||||
- Kubernetes manifests in `devops/kubernetes/`
|
||||
- Root documentation in `README.md`, `QUICKSTART.md`, `ENV_SETUP.md`, and `PERMISSIONS.md`
|
||||
|
||||
### Development with Docker Compose
|
||||
## Core Features
|
||||
|
||||
- Email/password authentication
|
||||
- Session cookies backed by Redis, with bearer-token fallback for API clients
|
||||
- Admin bootstrap from environment variables
|
||||
- Permission-based authorization with wildcard support
|
||||
- Spaces, categories, and notes
|
||||
- Full-text note search
|
||||
- Public spaces and public notes
|
||||
- Password-protected notes
|
||||
- OAuth/OIDC provider support
|
||||
- Feature flags for registration, provider login, public sharing, and file explorer support
|
||||
- Optional S3-compatible file explorer when enabled through feature flags
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Backend
|
||||
|
||||
- Language: Go
|
||||
- Module: `gitea.hostxtra.co.uk/mrhid6/notely/backend`
|
||||
- Entry point: `backend/cmd/server/main.go`
|
||||
- Architecture style: domain/application/infrastructure/interfaces split
|
||||
- Storage: MongoDB
|
||||
- Session store: Redis
|
||||
|
||||
### Frontend
|
||||
|
||||
- Framework: Vue 3
|
||||
- Router: Vue Router
|
||||
- State: Pinia
|
||||
- Build tool: Vite
|
||||
|
||||
### Container Layout
|
||||
|
||||
- `devops/docker/Dockerfile` builds the frontend and backend into a single app image
|
||||
- `docker-compose.yml` starts:
|
||||
- `mongodb`
|
||||
- `redis`
|
||||
- `notely` (combined app image)
|
||||
- `nginx`
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- `README.md`: project overview and current architecture
|
||||
- `QUICKSTART.md`: fast setup and day-to-day development commands
|
||||
- `ENV_SETUP.md`: environment-variable reference and configuration layout
|
||||
- `PERMISSIONS.md`: enforced permission model and naming
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Docker Compose
|
||||
|
||||
1. Copy the root environment file:
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up
|
||||
|
||||
# Backend: http://localhost:8080
|
||||
# Frontend: http://localhost:5173
|
||||
# MongoDB: localhost:27017
|
||||
# Nginx: http://localhost:80
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Local Development Setup
|
||||
2. Start the stack:
|
||||
|
||||
#### Backend
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
3. Open the app:
|
||||
|
||||
- UI through Nginx: `http://localhost`
|
||||
- Backend health check: `http://localhost:8080/health`
|
||||
- MongoDB: `localhost:27017`
|
||||
- Redis: `localhost:6379`
|
||||
|
||||
### Local Development
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 18+
|
||||
- MongoDB
|
||||
- Redis
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Install dependencies
|
||||
cp .env.example .env
|
||||
go mod download
|
||||
|
||||
# Set environment variables
|
||||
export MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
||||
export JWT_SECRET=your-secret-key
|
||||
export ENCRYPTION_KEY=00000000000000000000000000000000
|
||||
|
||||
# Run migrations and server
|
||||
go run ./cmd/server/main.go
|
||||
```
|
||||
|
||||
#### Frontend
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
cp .env.example .env
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📚 Architecture
|
||||
Local frontend development runs at `http://localhost:5173` and proxies `/api` requests to `http://localhost:8080`.
|
||||
|
||||
### Backend (GoClean Architecture)
|
||||
## API Surface
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/server/ # Entry point
|
||||
├── internal/
|
||||
│ ├── domain/ # Business logic (entities, interfaces)
|
||||
│ ├── application/ # Use cases (services, DTOs)
|
||||
│ ├── infrastructure/ # External dependencies (DB, auth)
|
||||
│ └── interfaces/ # API handlers & middleware
|
||||
├── pkg/ # Public packages
|
||||
└── tests/ # Test suites
|
||||
```
|
||||
The router in `backend/cmd/server/main.go` currently exposes these endpoint groups.
|
||||
|
||||
### Frontend (Vue 3 Composition API)
|
||||
### Public Endpoints
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Reusable Vue components
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ ├── services/ # API client
|
||||
│ ├── router/ # Vue Router config
|
||||
│ ├── assets/ # Styles and assets
|
||||
│ └── main.js # Entry point
|
||||
├── index.html
|
||||
└── vite.config.js
|
||||
```
|
||||
- `GET /health`
|
||||
- `POST /api/v1/auth/register`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `GET /api/v1/auth/providers`
|
||||
- `GET /api/v1/auth/providers/{providerId}/start`
|
||||
- `GET /api/v1/auth/providers/{providerId}/callback`
|
||||
- `GET /api/v1/settings/feature-flags`
|
||||
- `GET /api/v1/public/spaces`
|
||||
- `GET /api/v1/public/spaces/{spaceId}`
|
||||
- `GET /api/v1/public/spaces/{spaceId}/notes`
|
||||
- `GET /api/v1/public/spaces/{spaceId}/notes/{noteId}`
|
||||
- `POST /api/v1/public/spaces/{spaceId}/notes/{noteId}/unlock`
|
||||
|
||||
## 🔐 Security Features
|
||||
### Authenticated User Endpoints
|
||||
|
||||
### Authentication
|
||||
- `GET /api/v1/auth/me`
|
||||
- Space CRUD under `/api/v1/spaces`
|
||||
- Space member management under `/api/v1/spaces/{spaceId}/members`
|
||||
- Note CRUD, search, and unlock under `/api/v1/spaces/{spaceId}/notes`
|
||||
- Category CRUD and move under `/api/v1/spaces/{spaceId}/categories`
|
||||
- File explorer operations under `/api/v1/spaces/{spaceId}/files`
|
||||
|
||||
- **Argon2id password hashing** - Industry-standard PBKDF2
|
||||
- **JWT tokens** with short expiration (1 hour)
|
||||
- **HTTP-only secure cookies** for refresh tokens
|
||||
- **CSRF protection** via SameSite cookies
|
||||
- **Brute-force protection** via login attempt tracking
|
||||
### Admin Endpoints
|
||||
|
||||
### Authorization
|
||||
Admin routes live under `/api/v1/admin` and cover:
|
||||
|
||||
- **Role-based access control (RBAC)** per space:
|
||||
- Owner: Full control
|
||||
- Editor: Edit notes and categories
|
||||
- Viewer: Read-only access
|
||||
- **Space-level data isolation** - all queries include space_id
|
||||
- **IDOR prevention** - middleware enforces ownership verification
|
||||
- users
|
||||
- groups
|
||||
- spaces
|
||||
- feature flags
|
||||
- auth providers
|
||||
|
||||
### Data Security
|
||||
## Permissions
|
||||
|
||||
- **Encryption at rest** for sensitive fields (OAuth secrets)
|
||||
- **HTTPS/TLS** in production (Nginx reverse proxy)
|
||||
- **Content Security Policy (CSP)** headers
|
||||
- **XSS protection** - DOMPurify for markdown sanitization
|
||||
- **SQL injection prevention** - parameterized queries (MongoDB)
|
||||
Notely uses permission-based authorization, not fixed owner/editor/viewer roles.
|
||||
|
||||
### API Security
|
||||
- Global permissions include `space.create`, `space.edit`, and `space.delete`
|
||||
- Space-scoped permissions follow `space.<space_key>.<action>`
|
||||
- Example: `space.product_docs.note.create`
|
||||
- Example: `space.product_docs.settings.delete`
|
||||
- Space deletion requires either:
|
||||
- global `space.delete`, or
|
||||
- space-scoped `space.<space_key>.settings.delete`
|
||||
|
||||
- **Rate limiting** - IP-based and user-based
|
||||
- **Security headers** - HSTS, X-Frame-Options, X-Content-Type-Options
|
||||
- **CORS properly configured** - whitelist origin domains
|
||||
- **Input validation** on all endpoints
|
||||
See `PERMISSIONS.md` for the current enforced permission set.
|
||||
|
||||
## 📦 API Endpoints
|
||||
## Testing And Quality Checks
|
||||
|
||||
### Authentication
|
||||
|
||||
```
|
||||
POST /api/v1/auth/register - Register new user
|
||||
POST /api/v1/auth/login - Login user
|
||||
POST /api/v1/auth/refresh - Refresh access token
|
||||
POST /api/v1/auth/logout - Logout user
|
||||
GET /health - Health check
|
||||
```
|
||||
|
||||
### Spaces
|
||||
|
||||
```
|
||||
GET /api/v1/spaces - List user's spaces
|
||||
POST /api/v1/spaces - Create space
|
||||
GET /api/v1/spaces/{spaceId} - Get space details
|
||||
PUT /api/v1/spaces/{spaceId} - Update space
|
||||
DELETE /api/v1/spaces/{spaceId} - Delete space
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
```
|
||||
GET /api/v1/spaces/{spaceId}/notes - List notes
|
||||
POST /api/v1/spaces/{spaceId}/notes - Create note
|
||||
GET /api/v1/spaces/{spaceId}/notes/{noteId} - Get note
|
||||
PUT /api/v1/spaces/{spaceId}/notes/{noteId} - Update note
|
||||
DELETE /api/v1/spaces/{spaceId}/notes/{noteId} - Delete note
|
||||
GET /api/v1/spaces/{spaceId}/notes/search?q= - Search notes
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
```
|
||||
GET /api/v1/spaces/{spaceId}/categories - List categories
|
||||
POST /api/v1/spaces/{spaceId}/categories - Create category
|
||||
PUT /api/v1/spaces/{spaceId}/categories/{id} - Update category
|
||||
DELETE /api/v1/spaces/{spaceId}/categories/{id} - Delete category
|
||||
```
|
||||
|
||||
## 🗄️ Database Design
|
||||
|
||||
### MongoDB Collections
|
||||
|
||||
#### users
|
||||
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
email: String (unique),
|
||||
username: String (unique),
|
||||
password_hash: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
avatar: String,
|
||||
is_active: Boolean,
|
||||
email_verified: Boolean,
|
||||
created_at: Date,
|
||||
updated_at: Date,
|
||||
last_login_at: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### spaces
|
||||
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
name: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
owner_id: ObjectId,
|
||||
is_public: Boolean,
|
||||
created_at: Date,
|
||||
updated_at: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### memberships
|
||||
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
user_id: ObjectId,
|
||||
space_id: ObjectId,
|
||||
role: String (owner|editor|viewer),
|
||||
joined_at: Date,
|
||||
invited_by: ObjectId,
|
||||
invited_at: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### notes
|
||||
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
space_id: ObjectId,
|
||||
category_id: ObjectId,
|
||||
title: String,
|
||||
content: String (Markdown),
|
||||
tags: [String],
|
||||
is_pinned: Boolean,
|
||||
is_favorite: Boolean,
|
||||
created_by: ObjectId,
|
||||
updated_by: ObjectId,
|
||||
created_at: Date,
|
||||
updated_at: Date,
|
||||
viewed_at: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### categories
|
||||
|
||||
```javascript
|
||||
{
|
||||
_id: ObjectId,
|
||||
space_id: ObjectId,
|
||||
name: String,
|
||||
description: String,
|
||||
parent_id: ObjectId (for hierarchical structure),
|
||||
icon: String,
|
||||
order: Number,
|
||||
created_by: ObjectId,
|
||||
updated_by: ObjectId,
|
||||
created_at: Date,
|
||||
updated_at: Date
|
||||
}
|
||||
```
|
||||
|
||||
#### Indexes
|
||||
|
||||
```
|
||||
users: { email: 1 (unique), username: 1 (unique) }
|
||||
spaces: { owner_id: 1, created_at: -1 }
|
||||
memberships: { user_id: 1, space_id: 1 (unique), space_id: 1 }
|
||||
notes: { space_id: 1, category_id: 1, updated_at: -1, text: "text" }
|
||||
categories: { space_id: 1, parent_id: 1, order: 1 }
|
||||
```
|
||||
|
||||
## 🐳 Deployment
|
||||
|
||||
### Docker Compose (Development/Testing)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- **MongoDB** (port 27017)
|
||||
- **Backend API** (port 8080)
|
||||
- **Frontend** (port 5173)
|
||||
- **Nginx Reverse Proxy** (port 80)
|
||||
|
||||
### Kubernetes (Production)
|
||||
|
||||
```bash
|
||||
# Create namespace and secrets
|
||||
kubectl apply -f devops/kubernetes/deployment.yaml
|
||||
|
||||
# Verify deployment
|
||||
kubectl get pods -n noteapp
|
||||
kubectl port-forward svc/frontend 5173:5173 -n noteapp
|
||||
kubectl port-forward svc/backend 8080:8080 -n noteapp
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- **StatefulSet** for MongoDB with persistent storage
|
||||
- **Deployments** for backend and frontend with horizontal scaling
|
||||
- **Ingress** for routing (requires ingress controller)
|
||||
- **HPA** (Horizontal Pod Autoscaler) for automatic scaling
|
||||
- **Liveness & readiness probes** for health checks
|
||||
- **Resource limits** for fair resource allocation
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Backend Tests
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
@@ -309,118 +177,73 @@ go test -v ./tests/unit/...
|
||||
go test -v ./tests/integration/...
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
## Deployment Notes
|
||||
|
||||
### Environment Variables
|
||||
### Docker Compose
|
||||
|
||||
#### Backend (.env)
|
||||
Docker Compose uses the combined application image plus Nginx, MongoDB, and Redis. Configuration is driven by the root `.env` file.
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp
|
||||
JWT_SECRET=your-secret-key-min-32-chars
|
||||
ENCRYPTION_KEY=32-char-encryption-key-for-secrets
|
||||
PORT=8080
|
||||
LOG_LEVEL=info
|
||||
ENV=development
|
||||
```
|
||||
### Kubernetes
|
||||
|
||||
#### Frontend (.env)
|
||||
The manifest at `devops/kubernetes/deployment.yaml` currently provisions:
|
||||
|
||||
```
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
```
|
||||
- `noteapp` namespace
|
||||
- MongoDB StatefulSet and PVC
|
||||
- single `noteapp` Deployment for the combined app image
|
||||
- ClusterIP services
|
||||
- Ingress
|
||||
- HorizontalPodAutoscaler
|
||||
|
||||
## 📝 Development Guidelines
|
||||
|
||||
### Code Structure
|
||||
|
||||
- Follow clean architecture principles
|
||||
- Separate concerns: domain, application, infrastructure
|
||||
- Use interfaces for dependency injection
|
||||
- Keep services testable and focused
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Never store secrets in code** - use environment variables
|
||||
2. **Validate all inputs** on backend
|
||||
3. **Sanitize outputs** before rendering
|
||||
4. **Use HTTPS in production**
|
||||
5. **Implement rate limiting** on APIs
|
||||
6. **Log security events** (login attempts, permission denied)
|
||||
7. **Audit trail** for sensitive operations
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
[TYPE] Description
|
||||
|
||||
types: feat, fix, docs, style, refactor, test, chore
|
||||
```
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
### Request/Response Format
|
||||
|
||||
All API requests and responses use JSON.
|
||||
Apply it with:
|
||||
|
||||
```bash
|
||||
# Example: Create Note
|
||||
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \
|
||||
-H "Authorization: Bearer {accessToken}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "My Note",
|
||||
"content": "# Markdown content",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"category_id": null,
|
||||
"is_pinned": false,
|
||||
"is_favorite": false
|
||||
}'
|
||||
kubectl apply -f devops/kubernetes/deployment.yaml
|
||||
```
|
||||
|
||||
## 🚨 Error Handling
|
||||
## Current Repo Layout
|
||||
|
||||
All errors return appropriate HTTP status codes:
|
||||
```text
|
||||
noteapp/
|
||||
├── backend/
|
||||
│ ├── cmd/server/
|
||||
│ ├── internal/
|
||||
│ ├── pkg/
|
||||
│ ├── tests/
|
||||
│ └── .env.example
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ ├── tests/
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ ├── vitest.config.js
|
||||
│ └── .env.example
|
||||
├── devops/
|
||||
│ ├── docker/
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── nginx.conf
|
||||
│ │ └── ssl/
|
||||
│ └── kubernetes/
|
||||
│ └── deployment.yaml
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── ENV_SETUP.md
|
||||
├── PERMISSIONS.md
|
||||
├── QUICKSTART.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden (insufficient permissions)
|
||||
- `404` - Not Found
|
||||
- `409` - Conflict (e.g., duplicate email)
|
||||
- `429` - Too Many Requests (rate limit exceeded)
|
||||
- `500` - Internal Server Error
|
||||
## Notes For Contributors
|
||||
|
||||
## 🎯 Future Enhancements
|
||||
|
||||
- [ ] OAuth2/OIDC integration
|
||||
- [ ] Email notifications
|
||||
- [ ] Real-time collaboration (WebSockets)
|
||||
- [ ] Full-text search with Elasticsearch
|
||||
- [ ] Export to PDF/Markdown
|
||||
- [ ] Mobile applications
|
||||
- [ ] Plugin system
|
||||
- [ ] Advanced permissions management
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - See LICENSE file
|
||||
|
||||
## 👥 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for secure, collaborative note-taking**
|
||||
- Check `PERMISSIONS.md` when changing authorization behavior
|
||||
- Check `ENV_SETUP.md` when adding or changing configuration
|
||||
- Check `backend/cmd/server/main.go` before documenting routes
|
||||
- Keep docs aligned with actual package scripts and checked-in files
|
||||
|
||||
284
SECURITY.md
284
SECURITY.md
@@ -1,284 +0,0 @@
|
||||
# Security Implementation Guide
|
||||
|
||||
This document outlines the security measures implemented in Notely.
|
||||
|
||||
## 🔐 Authentication Security
|
||||
|
||||
### Password Hashing
|
||||
|
||||
- **Algorithm**: Argon2id (memory-hard, resistant to GPU attacks)
|
||||
- **Configuration**:
|
||||
- Memory: 64 MB
|
||||
- Time: 1 iteration
|
||||
- Parallelism: 4 threads
|
||||
- Salt: 16 random bytes (cryptographically secure)
|
||||
|
||||
```go
|
||||
// Generated hash format:
|
||||
$argon2id$v=19$m=65536,t=1,p=4$salt_hex$hash_hex
|
||||
```
|
||||
|
||||
### JWT Tokens
|
||||
|
||||
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||
- **Access Token TTL**: 1 hour
|
||||
- **Refresh Token TTL**: 7 days (HTTP-only secure cookie)
|
||||
- **Claims**:
|
||||
- `user_id`: User's MongoDB ObjectID
|
||||
- `email`: User's email address
|
||||
- `username`: User's username
|
||||
- `iat`: Issued at timestamp
|
||||
- `exp`: Expiration timestamp
|
||||
- `iss`: Issuer (verified against hardcoded value)
|
||||
|
||||
### Brute-Force Protection
|
||||
|
||||
- Track failed login attempts in `login_attempts` collection
|
||||
- Rate limit: Max 5 failed attempts per IP per 15 minutes
|
||||
- Account lockout: 15 minutes after 5 consecutive failures
|
||||
- Cleanup: Expired records auto-deleted via TTL index
|
||||
|
||||
## 🛡️ Authorization Security
|
||||
|
||||
### Role-Based Access Control (RBAC)
|
||||
|
||||
```
|
||||
Space Roles:
|
||||
├── Owner (all permissions)
|
||||
├── Editor (create/edit/delete notes)
|
||||
└── Viewer (read-only)
|
||||
```
|
||||
|
||||
### Space-Level Data Isolation
|
||||
|
||||
**ALL queries include mandatory `space_id` filter**
|
||||
|
||||
```go
|
||||
// Correct query pattern:
|
||||
db.notes.find({ space_id: spaceID, ... })
|
||||
|
||||
// Never allow:
|
||||
db.notes.find({ user_id: userID }) // ❌ Cross-space leak possible
|
||||
```
|
||||
|
||||
### Middleware Authorization Flow
|
||||
|
||||
```
|
||||
1. Extract JWT token → Verify signature & expiration
|
||||
2. Load user credentials → Verify user is active
|
||||
3. Check space membership → Verify user_id + space_id + role
|
||||
4. Execute request → With space_id context
|
||||
```
|
||||
|
||||
## 🔑 Data Encryption
|
||||
|
||||
### At Rest
|
||||
|
||||
- OAuth client secrets encrypted with AES-256-GCM
|
||||
- Stored in MongoDB with encryption key in environment variables
|
||||
- Decryption happens only when reading from database
|
||||
|
||||
```go
|
||||
plaintext, err := encryptor.Encrypt(clientSecret) // Stores encrypted blob
|
||||
recovered, err := encryptor.Decrypt(plaintext) // Decrypts on retrieval
|
||||
```
|
||||
|
||||
### In Transit
|
||||
|
||||
- HTTPS/TLS required in production (enforced via Nginx)
|
||||
- Secure cookies: `Secure`, `HttpOnly`, `SameSite=Lax` flags
|
||||
- All sensitive data transmitted over encrypted channels
|
||||
|
||||
## 🚨 Input Validation
|
||||
|
||||
### Backend Validation (MANDATORY)
|
||||
|
||||
Every endpoint validates:
|
||||
|
||||
1. **Type validation** - JSON schema validation
|
||||
2. **Length limits** - min/max string lengths
|
||||
3. **Format validation** - email, ObjectID, URL formats
|
||||
4. **Range validation** - pagination limits
|
||||
|
||||
```go
|
||||
type CreateNoteRequest struct {
|
||||
Title string `validate:"required,min=1,max=255"`
|
||||
Content string `validate:"max=50000"`
|
||||
Tags []string `validate:"max=100,dive,max=50"`
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Validation
|
||||
|
||||
- **Input sanitization** - trim whitespace
|
||||
- **Format validation** - regex patterns
|
||||
- **Debounced searches** - prevent query spam
|
||||
- **Client-side feedback** - improve UX
|
||||
|
||||
### Output Sanitization
|
||||
|
||||
Markdown → HTML conversion sanitized with DOMPurify:
|
||||
|
||||
```javascript
|
||||
// XSS prevention
|
||||
const dirty = marked.parse(userMarkdown);
|
||||
const clean = DOMPurify.sanitize(dirty);
|
||||
|
||||
// Blocks: scripts, event handlers, dangerous attributes
|
||||
```
|
||||
|
||||
## 🌐 Web Security Headers
|
||||
|
||||
Implemented via Nginx and Go middleware:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
| --------------------------- | --------------------------------- | ------------------------------- |
|
||||
| `Strict-Transport-Security` | `max-age=31536000` | Force HTTPS |
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
|
||||
| `X-Frame-Options` | `DENY` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | XSS protection (older browsers) |
|
||||
| `Content-Security-Policy` | Restrictive policy | Prevent XSS attacks |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Referrer control |
|
||||
|
||||
**CSP Policy:**
|
||||
|
||||
```
|
||||
default-src 'self'
|
||||
script-src 'self' 'unsafe-inline' (for development only)
|
||||
style-src 'self' 'unsafe-inline'
|
||||
img-src 'self' data: https:
|
||||
font-src 'self'
|
||||
connect-src 'self'
|
||||
frame-ancestors 'none'
|
||||
```
|
||||
|
||||
## 🍪 Cookie Security
|
||||
|
||||
### Access Token (via Authorization header)
|
||||
|
||||
- Stored in **memory** (not localStorage)
|
||||
- Passed via `Authorization: Bearer {token}`
|
||||
|
||||
### Refresh Token (HTTP-only cookie)
|
||||
|
||||
```go
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refresh_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
HttpOnly: true, // ✅ Cannot access from JavaScript
|
||||
Secure: true, // ✅ HTTPS only
|
||||
SameSite: http.SameSiteLaxMode, // ✅ CSRF protection
|
||||
})
|
||||
```
|
||||
|
||||
## 🔄 Rate Limiting
|
||||
|
||||
### API Rate Limiting
|
||||
|
||||
- **General**: 50 requests / second per IP
|
||||
- **Login**: 10 requests / second per IP
|
||||
- **Burst allowance**: 20 additional requests
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
```
|
||||
|
||||
### Login Attempt Tracking
|
||||
|
||||
- Track per email + IP combination
|
||||
- Maximum 5 attempts per 15 minutes
|
||||
- Exponential backoff on repeated failures
|
||||
|
||||
## 🔒 Database Security
|
||||
|
||||
### MongoDB
|
||||
|
||||
- **Authentication**: Username/password with role-based access
|
||||
- **Network**: Runs in secure Docker network (not exposed)
|
||||
- **Admin credentials**: Stored in Kubernetes Secrets (not in code)
|
||||
- **Backups**: TBD - use MongoDB Atlas or encrypted backups
|
||||
|
||||
### Connection String
|
||||
|
||||
```
|
||||
mongodb://admin:password@mongodb:27017/dbname?authSource=admin
|
||||
```
|
||||
|
||||
## 🚨 Logging & Monitoring
|
||||
|
||||
### Security Events Logged
|
||||
|
||||
- ✅ User registration attempts
|
||||
- ✅ Login attempts (success/failure)
|
||||
- ✅ Authorization failures
|
||||
- ✅ Permission denied events
|
||||
- ✅ Sensitive data access
|
||||
|
||||
### Data NOT logged
|
||||
|
||||
- ❌ Passwords/hashes
|
||||
- ❌ JWT tokens
|
||||
- ❌ Encryption keys
|
||||
- ❌ OAuth secrets
|
||||
|
||||
## 🧪 Security Testing
|
||||
|
||||
### What to Test
|
||||
|
||||
1. **Authentication**: Register, login, token refresh, logout
|
||||
2. **Authorization**: RBAC enforcement, space isolation
|
||||
3. **Input validation**: Invalid data rejection
|
||||
4. **XSS prevention**: Markdown sanitization
|
||||
5. **CSRF protection**: Token validation
|
||||
6. **Rate limiting**: Too many requests blocked
|
||||
7. **SQL Injection**: MongoDB-specific (parameterized queries safe)
|
||||
|
||||
### Manual Testing Commands
|
||||
|
||||
```bash
|
||||
# Test invalid input
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-d '{"email":"not-an-email","password":""}'
|
||||
|
||||
# Test expired token
|
||||
curl -H "Authorization: Bearer expired.token.here" \
|
||||
http://localhost:8080/api/v1/spaces
|
||||
|
||||
# Test rate limiting
|
||||
for i in {1..100}; do
|
||||
curl http://localhost:8080/api/v1/auth/login &
|
||||
done
|
||||
```
|
||||
|
||||
## 🛠️ Production Checklist
|
||||
|
||||
- [ ] Change default JWT_SECRET (min 32 characters)
|
||||
- [ ] Change default ENCRYPTION_KEY (32 bytes)
|
||||
- [ ] Generate TLS certificates (Let's Encrypt recommended)
|
||||
- [ ] Configure Nginx SSL/TLS
|
||||
- [ ] Enable HTTPS redirect
|
||||
- [ ] Set up database backups
|
||||
- [ ] Configure logging & monitoring
|
||||
- [ ] Implement CORS whitelist (specific domains)
|
||||
- [ ] Set up rate limiting (tuned to your traffic)
|
||||
- [ ] Enable database authentication
|
||||
- [ ] Use Kubernetes Network Policies
|
||||
- [ ] Set up Pod Security Policies
|
||||
- [ ] Enable audit logging
|
||||
- [ ] Configure Secrets encryption at rest
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [MongoDB Security](https://docs.mongodb.com/manual/security/)
|
||||
- [JWT Best Practices](https://tools.ietf.org/html/rfc8949)
|
||||
- [Argon2 Specification](https://github.com/P-H-C/phc-winner-argon2)
|
||||
- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: March 2026
|
||||
**Security Level**: Production-Grade
|
||||
@@ -10,16 +10,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/handlers"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"github.com/noteapp/backend/internal/interfaces/handlers"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -135,6 +135,7 @@ func main() {
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.TaskListRepo,
|
||||
db.UserRepo,
|
||||
permissionService,
|
||||
)
|
||||
@@ -151,11 +152,22 @@ func main() {
|
||||
|
||||
categoryService := services.NewCategoryService(
|
||||
db.CategoryRepo,
|
||||
db.TaskListRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
taskService := services.NewTaskService(
|
||||
db.TaskRepo,
|
||||
db.TaskListRepo,
|
||||
db.TaskStatusRepo,
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.MembershipRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
adminService := services.NewAdminService(
|
||||
db.UserRepo,
|
||||
db.GroupRepo,
|
||||
@@ -189,6 +201,7 @@ func main() {
|
||||
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||
noteHandler := handlers.NewNoteHandler(noteService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||
taskHandler := handlers.NewTaskHandler(taskService)
|
||||
adminHandler := handlers.NewAdminHandler(adminService)
|
||||
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
||||
settingsHandler := handlers.NewSettingsHandler(authService)
|
||||
@@ -258,6 +271,30 @@ func main() {
|
||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
||||
|
||||
// Task endpoints
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
|
||||
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
||||
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
||||
|
||||
// Task status endpoints (scoped to task list)
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
||||
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
||||
|
||||
// File explorer endpoints (space-scoped)
|
||||
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
|
||||
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
|
||||
@@ -316,6 +353,7 @@ func main() {
|
||||
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||
// manage identity providers — admin-only
|
||||
admin.HandleFunc("/auth/providers", authHandler.ListProvidersForAdmin).Methods("GET")
|
||||
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
||||
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/noteapp/backend
|
||||
module gitea.hostxtra.co.uk/mrhid6/notely/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// ========== AUTH DTOs ==========
|
||||
@@ -430,6 +430,7 @@ type CategoryTreeDTO struct {
|
||||
*CategoryDTO
|
||||
Subcategories []*CategoryTreeDTO `json:"subcategories"`
|
||||
Notes []*NoteListItemDTO `json:"notes"`
|
||||
TaskLists []*TaskListDTO `json:"task_lists"`
|
||||
}
|
||||
|
||||
// NewCategoryDTO creates a DTO from a category entity
|
||||
@@ -452,6 +453,183 @@ func NewCategoryDTO(category *entities.Category) *CategoryDTO {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== TASK DTOs ==========
|
||||
|
||||
// CreateTaskRequest represents task creation input.
|
||||
type CreateTaskRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Description string `json:"description" validate:"max=2000"`
|
||||
TaskListID string `json:"task_list_id" validate:"required"`
|
||||
StatusID string `json:"status_id" validate:"required"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest represents task update input.
|
||||
type UpdateTaskRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
TaskListID *string `json:"task_list_id,omitempty"`
|
||||
StatusID *string `json:"status_id,omitempty"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
NoteLinks []string `json:"note_links,omitempty"`
|
||||
}
|
||||
|
||||
// TaskTransitionRequest allows moving task status by one step.
|
||||
type TaskTransitionRequest struct {
|
||||
Direction string `json:"direction" validate:"required,oneof=forward backward"`
|
||||
}
|
||||
|
||||
// LinkTaskNoteRequest links/unlinks a note from a task.
|
||||
type LinkTaskNoteRequest struct {
|
||||
NoteID string `json:"note_id" validate:"required"`
|
||||
}
|
||||
|
||||
// TaskDTO represents a task in API responses.
|
||||
type TaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
TaskListID string `json:"task_list_id"`
|
||||
StatusID string `json:"status_id"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
NoteLinks []string `json:"note_links"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskWithStatusDTO includes status details and child tasks for detail views.
|
||||
type TaskWithStatusDTO struct {
|
||||
*TaskDTO
|
||||
StatusName string `json:"status_name"`
|
||||
StatusColor string `json:"status_color,omitempty"`
|
||||
StatusOrder int `json:"status_order"`
|
||||
Subtasks []*TaskDTO `json:"subtasks"`
|
||||
}
|
||||
|
||||
// CreateTaskStatusRequest represents task status creation input.
|
||||
type CreateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// UpdateTaskStatusRequest represents task status updates.
|
||||
type UpdateTaskStatusRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Color string `json:"color,omitempty" validate:"max=20"`
|
||||
}
|
||||
|
||||
// ReorderTaskStatusesRequest represents a full ordered status ID list.
|
||||
type ReorderTaskStatusesRequest struct {
|
||||
OrderedStatusIDs []string `json:"ordered_status_ids"`
|
||||
}
|
||||
|
||||
// TaskStatusDTO represents a task status in API responses.
|
||||
type TaskStatusDTO struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"task_list_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateTaskListRequest represents task list creation input.
|
||||
type CreateTaskListRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=120"`
|
||||
Description string `json:"description" validate:"max=500"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTaskListRequest represents task list update input.
|
||||
type UpdateTaskListRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// TaskListDTO represents a task list in API responses.
|
||||
type TaskListDTO struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewTaskDTO creates a DTO from a task entity.
|
||||
func NewTaskDTO(task *entities.Task) *TaskDTO {
|
||||
var parentTaskID *string
|
||||
if task.ParentTaskID != nil {
|
||||
id := task.ParentTaskID.Hex()
|
||||
parentTaskID = &id
|
||||
}
|
||||
|
||||
noteLinks := make([]string, 0, len(task.NoteLinks))
|
||||
for _, noteID := range task.NoteLinks {
|
||||
noteLinks = append(noteLinks, noteID.Hex())
|
||||
}
|
||||
|
||||
return &TaskDTO{
|
||||
ID: task.ID.Hex(),
|
||||
SpaceID: task.SpaceID.Hex(),
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
TaskListID: task.TaskListID.Hex(),
|
||||
StatusID: task.StatusID.Hex(),
|
||||
ParentTaskID: parentTaskID,
|
||||
Depth: task.Depth,
|
||||
NoteLinks: noteLinks,
|
||||
CreatedBy: task.CreatedBy.Hex(),
|
||||
UpdatedBy: task.UpdatedBy.Hex(),
|
||||
CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: task.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskListDTO creates a DTO from a task list entity.
|
||||
func NewTaskListDTO(taskList *entities.TaskList) *TaskListDTO {
|
||||
var categoryID *string
|
||||
if taskList.CategoryID != nil {
|
||||
id := taskList.CategoryID.Hex()
|
||||
categoryID = &id
|
||||
}
|
||||
|
||||
return &TaskListDTO{
|
||||
ID: taskList.ID.Hex(),
|
||||
SpaceID: taskList.SpaceID.Hex(),
|
||||
CategoryID: categoryID,
|
||||
Name: taskList.Name,
|
||||
Description: taskList.Description,
|
||||
CreatedBy: taskList.CreatedBy.Hex(),
|
||||
UpdatedBy: taskList.UpdatedBy.Hex(),
|
||||
CreatedAt: taskList.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: taskList.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskStatusDTO creates a DTO from a task status entity.
|
||||
func NewTaskStatusDTO(status *entities.TaskStatus) *TaskStatusDTO {
|
||||
return &TaskStatusDTO{
|
||||
ID: status.ID.Hex(),
|
||||
TaskListID: status.TaskListID.Hex(),
|
||||
Name: status.Name,
|
||||
Color: status.Color,
|
||||
Order: status.Order,
|
||||
CreatedAt: status.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: status.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ERROR DTOs ==========
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
)
|
||||
|
||||
// AdminService handles admin-level operations
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -259,6 +259,25 @@ func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
|
||||
func (s *AuthService) ListProvidersForAdmin(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
|
||||
if s.providerRepo == nil {
|
||||
return []*dto.AuthProviderDTO{}, nil
|
||||
}
|
||||
|
||||
providers, err := s.providerRepo.GetAllProvidersForAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*dto.AuthProviderDTO, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
result = append(result, dto.NewAuthProviderDTO(provider))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFeatureFlags returns current app-wide feature flags.
|
||||
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||
if s.featureFlagRepo == nil {
|
||||
|
||||
@@ -7,14 +7,15 @@ import (
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
)
|
||||
|
||||
// CategoryService handles category operations
|
||||
type CategoryService struct {
|
||||
categoryRepo repositories.CategoryRepository
|
||||
taskListRepo repositories.TaskListRepository
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
permissionService *PermissionService
|
||||
@@ -23,12 +24,14 @@ type CategoryService struct {
|
||||
// NewCategoryService creates a new category service
|
||||
func NewCategoryService(
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
taskListRepo repositories.TaskListRepository,
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
permissionService *PermissionService,
|
||||
) *CategoryService {
|
||||
return &CategoryService{
|
||||
categoryRepo: categoryRepo,
|
||||
taskListRepo: taskListRepo,
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
permissionService: permissionService,
|
||||
@@ -134,6 +137,14 @@ func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entit
|
||||
}
|
||||
}
|
||||
|
||||
// Get task lists in this category
|
||||
taskLists, err := s.taskListRepo.ListTaskListsByCategory(ctx, spaceID, category.ID)
|
||||
if err == nil {
|
||||
for _, taskList := range taskLists {
|
||||
tree.TaskLists = append(tree.TaskLists, dto.NewTaskListDTO(taskList))
|
||||
}
|
||||
}
|
||||
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
)
|
||||
|
||||
// S3Object represents a file or folder entry with key relative to the space root.
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ type SpaceService struct {
|
||||
membershipRepo repositories.MembershipRepository
|
||||
noteRepo repositories.NoteRepository
|
||||
categoryRepo repositories.CategoryRepository
|
||||
taskListRepo repositories.TaskListRepository
|
||||
userRepo repositories.UserRepository
|
||||
permissionService *PermissionService
|
||||
}
|
||||
@@ -27,6 +28,7 @@ func NewSpaceService(
|
||||
membershipRepo repositories.MembershipRepository,
|
||||
noteRepo repositories.NoteRepository,
|
||||
categoryRepo repositories.CategoryRepository,
|
||||
taskListRepo repositories.TaskListRepository,
|
||||
userRepo repositories.UserRepository,
|
||||
permissionService *PermissionService,
|
||||
) *SpaceService {
|
||||
@@ -35,6 +37,7 @@ func NewSpaceService(
|
||||
membershipRepo: membershipRepo,
|
||||
noteRepo: noteRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
taskListRepo: taskListRepo,
|
||||
userRepo: userRepo,
|
||||
permissionService: permissionService,
|
||||
}
|
||||
@@ -180,6 +183,9 @@ func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.Obj
|
||||
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.taskListRepo.DeleteTaskListsBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
1049
backend/internal/application/services/task_service.go
Normal file
1049
backend/internal/application/services/task_service.go
Normal file
File diff suppressed because it is too large
Load Diff
50
backend/internal/domain/entities/task.go
Normal file
50
backend/internal/domain/entities/task.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const MaxTaskDepth = 2
|
||||
|
||||
// Task represents a task and supports up to 3 nesting levels (0,1,2).
|
||||
type Task struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
Title string `bson:"title"`
|
||||
Description string `bson:"description"`
|
||||
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||
StatusID bson.ObjectID `bson:"status_id"`
|
||||
ParentTaskID *bson.ObjectID `bson:"parent_task_id,omitempty"`
|
||||
Depth int `bson:"depth"`
|
||||
NoteLinks []bson.ObjectID `bson:"note_links"`
|
||||
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskStatus defines the ordered linear status progression for a task list.
|
||||
type TaskStatus struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
TaskListID bson.ObjectID `bson:"task_list_id"`
|
||||
Name string `bson:"name"`
|
||||
Color string `bson:"color,omitempty"`
|
||||
Order int `bson:"order"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskList groups tasks under a named list that can be attached to a category.
|
||||
type TaskList struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
SpaceID bson.ObjectID `bson:"space_id"`
|
||||
CategoryID *bson.ObjectID `bson:"category_id,omitempty"`
|
||||
Name string `bson:"name"`
|
||||
Description string `bson:"description,omitempty"`
|
||||
CreatedBy bson.ObjectID `bson:"created_by"`
|
||||
UpdatedBy bson.ObjectID `bson:"updated_by"`
|
||||
CreatedAt time.Time `bson:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at"`
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package repositories
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package repositories
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -174,6 +174,9 @@ type AuthProviderRepository interface {
|
||||
// GetAllProviders retrieves all active providers
|
||||
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||
|
||||
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
|
||||
GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||
|
||||
// UpdateProvider updates a provider
|
||||
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||
|
||||
@@ -213,3 +216,37 @@ type NoteRevisionRepository interface {
|
||||
// GetRevisionByID retrieves a specific revision
|
||||
GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error)
|
||||
}
|
||||
|
||||
// TaskRepository defines task operations
|
||||
type TaskRepository interface {
|
||||
CreateTask(ctx context.Context, task *entities.Task) error
|
||||
GetTaskByID(ctx context.Context, id bson.ObjectID) (*entities.Task, error)
|
||||
ListTasks(ctx context.Context, spaceID bson.ObjectID, filters map[string]any) ([]*entities.Task, error)
|
||||
SearchTasks(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Task, error)
|
||||
UpdateTask(ctx context.Context, task *entities.Task) error
|
||||
DeleteTask(ctx context.Context, id bson.ObjectID) error
|
||||
DeleteTasksByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||
DeleteTasksBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||
CountChildren(ctx context.Context, parentTaskID bson.ObjectID) (int64, error)
|
||||
}
|
||||
|
||||
// TaskListRepository defines task list operations.
|
||||
type TaskListRepository interface {
|
||||
CreateTaskList(ctx context.Context, list *entities.TaskList) error
|
||||
GetTaskListByID(ctx context.Context, id bson.ObjectID) (*entities.TaskList, error)
|
||||
ListTaskLists(ctx context.Context, spaceID bson.ObjectID) ([]*entities.TaskList, error)
|
||||
ListTaskListsByCategory(ctx context.Context, spaceID bson.ObjectID, categoryID bson.ObjectID) ([]*entities.TaskList, error)
|
||||
UpdateTaskList(ctx context.Context, list *entities.TaskList) error
|
||||
DeleteTaskList(ctx context.Context, id bson.ObjectID) error
|
||||
DeleteTaskListsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error
|
||||
}
|
||||
|
||||
// TaskStatusRepository defines task status operations
|
||||
type TaskStatusRepository interface {
|
||||
CreateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||
GetStatusByID(ctx context.Context, id bson.ObjectID) (*entities.TaskStatus, error)
|
||||
ListStatuses(ctx context.Context, taskListID bson.ObjectID) ([]*entities.TaskStatus, error)
|
||||
UpdateStatus(ctx context.Context, status *entities.TaskStatus) error
|
||||
DeleteStatus(ctx context.Context, id bson.ObjectID) error
|
||||
DeleteStatusesByTaskListID(ctx context.Context, taskListID bson.ObjectID) error
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// AccountRecoveryRepository implements account recovery operations
|
||||
@@ -222,6 +222,23 @@ func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entiti
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
|
||||
func (r *AuthProviderRepository) GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error) {
|
||||
var providers []*entities.AuthProvider
|
||||
|
||||
cursor, err := r.collection.Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
if err = cursor.All(ctx, &providers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// UpdateProvider updates a provider
|
||||
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||
provider.UpdatedAt = time.Now()
|
||||
|
||||
@@ -16,6 +16,9 @@ type Database struct {
|
||||
MembershipRepo *MembershipRepository
|
||||
NoteRepo *NoteRepository
|
||||
CategoryRepo *CategoryRepository
|
||||
TaskListRepo *TaskListRepository
|
||||
TaskRepo *TaskRepository
|
||||
TaskStatusRepo *TaskStatusRepository
|
||||
RevisionRepo *NoteRevisionRepository
|
||||
GroupRepo *PermissionGroupRepository
|
||||
ProviderRepo *AuthProviderRepository
|
||||
@@ -47,6 +50,9 @@ func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) {
|
||||
MembershipRepo: NewMembershipRepository(db),
|
||||
NoteRepo: NewNoteRepository(db),
|
||||
CategoryRepo: NewCategoryRepository(db),
|
||||
TaskListRepo: NewTaskListRepository(db),
|
||||
TaskRepo: NewTaskRepository(db),
|
||||
TaskStatusRepo: NewTaskStatusRepository(db),
|
||||
RevisionRepo: NewNoteRevisionRepository(db),
|
||||
GroupRepo: NewPermissionGroupRepository(db),
|
||||
ProviderRepo: NewAuthProviderRepository(db),
|
||||
@@ -80,6 +86,15 @@ func (d *Database) EnsureIndexes(ctx context.Context) error {
|
||||
if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.TaskRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.TaskListRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.TaskStatusRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.GroupRepo.EnsureIndexes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"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"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// NoteRepository implements the note repository interface
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// SpaceRepository implements the space repository interface
|
||||
|
||||
277
backend/internal/infrastructure/database/task_repository.go
Normal file
277
backend/internal/infrastructure/database/task_repository.go
Normal file
@@ -0,0 +1,277 @@
|
||||
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: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
|
||||
{Keys: bson.D{{Key: "space_id", Value: 1}, {Key: "category_id", Value: 1}}},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskStatusRepository implements task status data access.
|
||||
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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
)
|
||||
|
||||
// UserRepository implements the user repository interface
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin-level HTTP requests
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -121,6 +121,18 @@ func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
|
||||
}
|
||||
|
||||
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
|
||||
func (h *AuthHandler) ListProvidersForAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
providers, err := h.authService.ListProvidersForAdmin(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
|
||||
}
|
||||
|
||||
// CreateProvider stores a new OAuth/OIDC provider configuration.
|
||||
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateAuthProviderRequest
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
)
|
||||
|
||||
// CategoryHandler handles category endpoints
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
)
|
||||
|
||||
const maxUploadSize = 100 << 20 // 100 MB
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
)
|
||||
|
||||
// NoteHandler handles note endpoints
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
)
|
||||
|
||||
// PublicHandler handles unauthenticated public read-only requests
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
)
|
||||
|
||||
// SettingsHandler handles public app settings endpoints.
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
511
backend/internal/interfaces/handlers/task_handler.go
Normal file
511
backend/internal/interfaces/handlers/task_handler.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"github.com/gorilla/mux"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// TaskHandler handles task and task status endpoints.
|
||||
type TaskHandler struct {
|
||||
taskService *services.TaskService
|
||||
}
|
||||
|
||||
// NewTaskHandler creates a task handler.
|
||||
func NewTaskHandler(taskService *services.TaskService) *TaskHandler {
|
||||
return &TaskHandler{taskService: taskService}
|
||||
}
|
||||
|
||||
func parseIDsFromRequest(r *http.Request) (bson.ObjectID, bson.ObjectID, error) {
|
||||
userID, err := getUserObjectID(r)
|
||||
if err != nil {
|
||||
return bson.NilObjectID, bson.NilObjectID, err
|
||||
}
|
||||
spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"])
|
||||
if err != nil {
|
||||
return bson.NilObjectID, bson.NilObjectID, err
|
||||
}
|
||||
return userID, spaceID, nil
|
||||
}
|
||||
|
||||
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.CreateTask(r.Context(), spaceID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
taskListID := strings.TrimSpace(r.URL.Query().Get("taskListId"))
|
||||
statusID := strings.TrimSpace(r.URL.Query().Get("statusId"))
|
||||
parentTaskID := strings.TrimSpace(r.URL.Query().Get("parentTaskId"))
|
||||
|
||||
taskListFilter := &taskListID
|
||||
statusFilter := &statusID
|
||||
parentFilter := &parentTaskID
|
||||
if taskListID == "" {
|
||||
taskListFilter = nil
|
||||
}
|
||||
if statusID == "" {
|
||||
statusFilter = nil
|
||||
}
|
||||
if parentTaskID == "" {
|
||||
parentFilter = nil
|
||||
}
|
||||
|
||||
tasks, err := h.taskService.ListTasks(r.Context(), spaceID, userID, taskListFilter, statusFilter, parentFilter)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tasks)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) ListTaskLists(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
lists, err := h.taskService.ListTaskLists(r.Context(), spaceID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(lists)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) CreateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTaskListRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := h.taskService.CreateTaskList(r.Context(), spaceID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) UpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateTaskListRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := h.taskService.UpdateTaskList(r.Context(), spaceID, taskListID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(list)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) DeleteTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.taskService.DeleteTaskList(r.Context(), spaceID, taskListID, userID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) SearchTasks(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query().Get("q")
|
||||
tasks, err := h.taskService.SearchTasks(r.Context(), spaceID, userID, query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tasks)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.GetTaskByID(r.Context(), spaceID, taskID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) UpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.UpdateTask(r.Context(), spaceID, taskID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.taskService.DeleteTask(r.Context(), spaceID, taskID, userID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) TransitionTaskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.TaskTransitionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.TransitionTaskStatus(r.Context(), spaceID, taskID, userID, req.Direction)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) LinkTaskNote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.LinkTaskNoteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
noteID, err := bson.ObjectIDFromHex(req.NoteID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.LinkNoteToTask(r.Context(), spaceID, taskID, noteID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) UnlinkTaskNote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.taskService.UnlinkNoteFromTask(r.Context(), spaceID, taskID, noteID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) ListTasksByNote(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
noteID, err := bson.ObjectIDFromHex(mux.Vars(r)["noteId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := h.taskService.ListTasksLinkedToNote(r.Context(), spaceID, noteID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tasks)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) ListStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
statuses, err := h.taskService.ListStatuses(r.Context(), spaceID, taskListID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(statuses)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) CreateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTaskStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.taskService.CreateStatus(r.Context(), spaceID, taskListID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateTaskStatusRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.taskService.UpdateStatus(r.Context(), spaceID, taskListID, statusID, userID, &req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) DeleteStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
statusID, err := bson.ObjectIDFromHex(mux.Vars(r)["statusId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid status id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.taskService.DeleteStatus(r.Context(), spaceID, taskListID, statusID, userID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TaskHandler) ReorderStatuses(w http.ResponseWriter, r *http.Request) {
|
||||
userID, spaceID, err := parseIDsFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
taskListID, err := bson.ObjectIDFromHex(mux.Vars(r)["taskListId"])
|
||||
if err != nil {
|
||||
http.Error(w, "invalid task list id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.ReorderTaskStatusesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
statuses, err := h.taskService.ReorderStatuses(r.Context(), spaceID, taskListID, userID, req.OrderedStatusIDs)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(statuses)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||
)
|
||||
|
||||
// ContextKey is a custom type for context keys
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
|
||||
)
|
||||
|
||||
// TestDatabaseConnection tests MongoDB connection
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/noteapp/backend/internal/application/dto"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ FROM node:25-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./devops/docker/Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
container_name: notely-app
|
||||
ports:
|
||||
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Frontend Environment Example
|
||||
|
||||
# API Base URL (Backend server)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Environment
|
||||
VITE_ENV=development
|
||||
|
||||
|
||||
33
frontend/eslint.config.js
Normal file
33
frontend/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import pluginVue from "eslint-plugin-vue";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs["flat/essential"],
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,vue}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
1717
frontend/package-lock.json
generated
1717
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs"
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
@@ -23,8 +25,13 @@
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@vitejs/plugin-vue": "^4.2.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"vite": "^4.3.0",
|
||||
"vitest": "^0.34.0"
|
||||
}
|
||||
|
||||
1201
frontend/src/App.vue
1201
frontend/src/App.vue
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,106 @@
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--secondary-color: #764ba2;
|
||||
--text-color: #333;
|
||||
--bg-color: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
--color-primary: #667eea;
|
||||
--color-primary-strong: #4f46a5;
|
||||
--color-text: #333333;
|
||||
--color-text-muted: #6c757d;
|
||||
--color-bg: #f8f9fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #f1f3f5;
|
||||
--color-border: #dee2e6;
|
||||
--color-info: #748ffc;
|
||||
--color-code-bg: #353943;
|
||||
--color-code-text: #f9fafb;
|
||||
--color-scroll-track: #f1f1f1;
|
||||
--color-scroll-thumb: #888888;
|
||||
--color-scroll-thumb-hover: #555555;
|
||||
|
||||
--primary-color: var(--color-primary);
|
||||
--secondary-color: var(--color-primary-strong);
|
||||
--text-color: var(--color-text);
|
||||
--bg-color: var(--color-bg);
|
||||
--border-color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--color-text: #e2e8f0;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-bg: #1a1d23;
|
||||
--color-surface: #21252e;
|
||||
--color-surface-muted: #2d3748;
|
||||
--color-border: #3a3f4b;
|
||||
--color-info: #7aa2f7;
|
||||
--color-code-bg: #2d3748;
|
||||
--color-code-text: #e2e8f0;
|
||||
--color-scroll-track: #2d3748;
|
||||
--color-scroll-thumb: #4a5568;
|
||||
--color-scroll-thumb-hover: #718096;
|
||||
|
||||
--text-color: var(--color-text);
|
||||
--bg-color: var(--color-bg);
|
||||
--border-color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .sidebar {
|
||||
background-color: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .toolbar {
|
||||
background-color: var(--color-surface);
|
||||
border-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .main-content {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body table {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body th {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body tr:nth-child(even) td {
|
||||
background: #232830;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body blockquote {
|
||||
background: #1e2430;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body :not(pre) > code {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body pre code {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .markdown-body pre {
|
||||
background: var(--color-code-bg);
|
||||
color: var(--color-code-text);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] ::-webkit-scrollbar-track {
|
||||
background: var(--color-scroll-track);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||
background: var(--color-scroll-thumb);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-scroll-thumb-hover);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -30,7 +127,7 @@ body,
|
||||
margin: 1rem 0;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
background: #fff;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
@@ -57,7 +154,7 @@ body,
|
||||
.markdown-body blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid #748ffc;
|
||||
border-left: 4px solid var(--color-info);
|
||||
background: #f8f9ff;
|
||||
color: #334155;
|
||||
}
|
||||
@@ -70,8 +167,8 @@ body,
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
background: var(--color-code-bg);
|
||||
color: var(--color-code-text);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -86,7 +183,7 @@ body,
|
||||
font-size: 0.95em;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.35rem;
|
||||
background: #f1f3f5;
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -96,14 +193,14 @@ body,
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--color-scroll-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
background: var(--color-scroll-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: var(--color-scroll-thumb-hover);
|
||||
}
|
||||
|
||||
254
frontend/src/assets/styles/scoped/App.css
Normal file
254
frontend/src/assets/styles/scoped/App.css
Normal file
@@ -0,0 +1,254 @@
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
z-index: 1100;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-navbar {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navbar-controls {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.breadcrumb-title {
|
||||
font-size: 1rem;
|
||||
color: #495057;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
z-index: 1200;
|
||||
max-width: min(92vw, 320px);
|
||||
}
|
||||
|
||||
.dropdown-menu-end {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-route-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-navbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"left user"
|
||||
"space space"
|
||||
"search search";
|
||||
row-gap: 0.5rem;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.navbar-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-user-menu {
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.nav-space-selector {
|
||||
grid-area: space;
|
||||
}
|
||||
|
||||
.nav-space-selector > .btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-search {
|
||||
grid-area: search;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-search .form-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-menu-toggle {
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 1095;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-button .mdi {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.col-auto .action-button {
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .sidebar-header {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .breadcrumb-title {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .breadcrumb-link {
|
||||
color: #7aa2f7;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .breadcrumb-separator {
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "./AdminModal.shared.css";
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "./AdminModal.shared.css";
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "./AdminModal.shared.css";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@import "./AdminModal.shared.css";
|
||||
213
frontend/src/assets/styles/scoped/components/CategoryTree.css
Normal file
213
frontend/src/assets/styles/scoped/components/CategoryTree.css
Normal file
@@ -0,0 +1,213 @@
|
||||
.category-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.category-header:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
min-width: 150px;
|
||||
padding: 0.35rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.category-content {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.task-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2f3d52;
|
||||
background: #f7f9ff;
|
||||
border: 1px solid #d9e3ff;
|
||||
}
|
||||
|
||||
.task-list-item:hover {
|
||||
background: #e9efff;
|
||||
}
|
||||
|
||||
.task-list-item span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.note-item span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #408aca;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.featured-icon {
|
||||
color: #f08c00;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-item.is-pinned {
|
||||
background: #dbf5ff;
|
||||
border: 1px solid #a8d1ff;
|
||||
}
|
||||
|
||||
.note-item.is-pinned:hover {
|
||||
background: #c5e9ff;
|
||||
}
|
||||
|
||||
.note-item.is-featured {
|
||||
background: #fff9db;
|
||||
border: 1px solid #ffd8a8;
|
||||
}
|
||||
|
||||
.note-item.is-featured:hover {
|
||||
background: #fff3c5;
|
||||
}
|
||||
|
||||
.subcategories {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .category-header:hover {
|
||||
background-color: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .menu-button {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .menu-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .menu-dropdown {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: #4a5568;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .menu-item {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .menu-item:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item:hover {
|
||||
background-color: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-list-item {
|
||||
background: #1f2a44;
|
||||
border-color: #334b7d;
|
||||
color: #bfceef;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-list-item:hover {
|
||||
background: #26365b;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item.is-pinned {
|
||||
background: #1a3a5c;
|
||||
border-color: #2d6a9f;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
|
||||
background: #1e4470;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item.is-featured {
|
||||
background: #3a2e0a;
|
||||
border-color: #7a5a0a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
|
||||
background: #453710;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
267
frontend/src/assets/styles/scoped/components/NoteEditor.css
Normal file
267
frontend/src/assets/styles/scoped/components/NoteEditor.css
Normal file
@@ -0,0 +1,267 @@
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.save-status.dirty {
|
||||
color: #b26a00;
|
||||
}
|
||||
|
||||
.save-status.saving {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.save-status.saved {
|
||||
color: #2b8a3e;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
min-height: 600px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-flags {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-input {
|
||||
margin: 0;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-label {
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
background-color: var(--color-bg);
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.task-mention-panel {
|
||||
margin-top: 0.45rem;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #fbfdff;
|
||||
padding: 0.5rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-mention-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-mention-option:hover {
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.task-picker {
|
||||
background: var(--color-surface);
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-picker-header {
|
||||
background: var(--color-bg);
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.task-picker-search {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.task-picker-list {
|
||||
overflow-y: auto;
|
||||
max-height: 520px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.task-picker-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
text-align: left;
|
||||
gap: 0.4rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.task-picker-item:hover {
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.task-picker-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-picker-empty {
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.task-picker-item small {
|
||||
font-size: 0.7rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.task-picker .btn-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.task-picker-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c7d8ff;
|
||||
background: #eef4ff;
|
||||
color: #2c4ea3;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-title) {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-status) {
|
||||
line-height: 1;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 0.16rem 0.42rem;
|
||||
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
|
||||
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
|
||||
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link:hover) {
|
||||
background: #dfeaff;
|
||||
border-color: #aac4ff;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link i) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .editor-toolbar {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .flag-check {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: #4a5568;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .preview-pane {
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-mention-panel {
|
||||
border-color: #3a4558;
|
||||
background: #1f2733;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-mention-option:hover {
|
||||
background: #2b3646;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker {
|
||||
border-color: var(--color-border) !important;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-search {
|
||||
background: var(--color-surface);
|
||||
border-bottom-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-search .form-control {
|
||||
background: #1f2430;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-item {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-item:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-picker-item small {
|
||||
color: #a8b4c7;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
|
||||
border-color: #35508b;
|
||||
background: #1b2a4a;
|
||||
color: #9ec0ff;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
|
||||
background: #22345c;
|
||||
border-color: #4566ad;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
|
||||
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
|
||||
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
|
||||
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
|
||||
}
|
||||
168
frontend/src/assets/styles/scoped/components/NoteViewer.css
Normal file
168
frontend/src/assets/styles/scoped/components/NoteViewer.css
Normal file
@@ -0,0 +1,168 @@
|
||||
.note-viewer {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #364fc7;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pinned-chip {
|
||||
color: #005f8f;
|
||||
background: #dbf5ff;
|
||||
border: 1px solid #a8d1ff;
|
||||
}
|
||||
|
||||
.featured-chip {
|
||||
color: #8d7619;
|
||||
border: 1px solid #ffd8a8;
|
||||
background: #fff9db;
|
||||
}
|
||||
|
||||
.public-chip {
|
||||
color: #0c5460;
|
||||
border: 1px solid #a5d8ff;
|
||||
background: #e7f5ff;
|
||||
}
|
||||
|
||||
.private-chip {
|
||||
color: #5f3dc4;
|
||||
border: 1px solid #d0bfff;
|
||||
background: #f3f0ff;
|
||||
}
|
||||
.protected-chip {
|
||||
color: #7f5539;
|
||||
border: 1px solid #e0c3a6;
|
||||
background: #fff4e6;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c7d8ff;
|
||||
background: #eef4ff;
|
||||
color: #2c4ea3;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-title) {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-status) {
|
||||
line-height: 1;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 0.16rem 0.42rem;
|
||||
border: 1px solid color-mix(in srgb, var(--task-status-color, #5c7bd9) 60%, var(--color-surface) 40%);
|
||||
background: color-mix(in srgb, var(--task-status-color, #5c7bd9) 18%, var(--color-surface) 82%);
|
||||
color: color-mix(in srgb, var(--task-status-color, #5c7bd9) 72%, #0f172a 28%);
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link:hover) {
|
||||
background: #dfeaff;
|
||||
border-color: #aac4ff;
|
||||
}
|
||||
|
||||
.markdown-body :deep(.task-inline-link i) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(li) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .note-meta {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .tag-chip {
|
||||
background: #1e2d5f;
|
||||
color: #93b4ff;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .pinned-chip {
|
||||
color: #7dd3fc;
|
||||
background: #1a3a5c;
|
||||
border-color: #2d6a9f;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .featured-chip {
|
||||
color: #fbbf24;
|
||||
background: #3a2e0a;
|
||||
border-color: #7a5a0a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .public-chip {
|
||||
color: #67e8f9;
|
||||
background: #0c2a3a;
|
||||
border-color: #1d6a7a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .private-chip {
|
||||
color: #c4b5fd;
|
||||
background: #2d1f5e;
|
||||
border-color: #5b3f9a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .protected-chip {
|
||||
color: #fdba74;
|
||||
background: #3a1f0a;
|
||||
border-color: #7a4f1a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link) {
|
||||
border-color: #35508b;
|
||||
background: #1b2a4a;
|
||||
color: #9ec0ff;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-link:hover) {
|
||||
background: #22345c;
|
||||
border-color: #4566ad;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .markdown-body :deep(.task-inline-status) {
|
||||
border-color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 65%, #1e293b 35%);
|
||||
background: color-mix(in srgb, var(--task-status-color, #7aa2f7) 26%, #111827 74%);
|
||||
color: color-mix(in srgb, var(--task-status-color, #7aa2f7) 70%, #dbeafe 30%);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
383
frontend/src/assets/styles/scoped/components/TaskBoard.css
Normal file
383
frontend/src/assets/styles/scoped/components/TaskBoard.css
Normal file
@@ -0,0 +1,383 @@
|
||||
.task-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-board-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-lane {
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(180deg, #fcfdff 0%, #f5f8fc 100%);
|
||||
}
|
||||
|
||||
.lane-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.4rem 0.45rem;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid #e4e9f0;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.status-item.is-drag-over {
|
||||
border-color: #7aa2f7;
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #74839a;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.task-status-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-group {
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 12px;
|
||||
overflow: visible;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.status-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-left: 6px solid transparent;
|
||||
background: #f8fbff;
|
||||
border-bottom: 1px solid #edf2f8;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.status-group-title-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-group-title {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-group-count {
|
||||
color: #5f6f87;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-empty {
|
||||
padding: 0.75rem 0.85rem;
|
||||
color: #7a8799;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-tree-row {
|
||||
border-bottom: 1px solid #edf2f8;
|
||||
}
|
||||
|
||||
.task-tree-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-tree-row.level-1 .task-row {
|
||||
padding-left: 2.1rem;
|
||||
}
|
||||
|
||||
.task-tree-row.level-2 .task-row {
|
||||
padding-left: 3.5rem;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
text-align: left;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
.status-group-header {
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
}
|
||||
|
||||
.status-group > .task-tree-row:last-child .task-row,
|
||||
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row,
|
||||
.status-group > .task-tree-row:last-child > div > .task-tree-row:last-child > div > .task-tree-row:last-child .task-row {
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
width: 1.25rem;
|
||||
color: #5f6f87;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-main strong,
|
||||
.task-main small {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-status-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.status-trigger {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-trigger:hover {
|
||||
background: #eef3f9;
|
||||
}
|
||||
|
||||
.status-trigger-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--color-surface);
|
||||
box-shadow: 0 0 0 1px rgba(67, 81, 98, 0.25);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-popup {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.3rem);
|
||||
min-width: 190px;
|
||||
background: #151a22;
|
||||
border: 1px solid #2a3343;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 28px rgba(5, 9, 15, 0.35);
|
||||
padding: 0.35rem;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #e8edf5;
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-option:hover,
|
||||
.status-option.selected {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.status-option-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.status-option-label {
|
||||
font-size: 0.86rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-option-check {
|
||||
color: #e8edf5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-color-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.task-filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-popup {
|
||||
right: -0.2rem;
|
||||
min-width: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
:root[data-bs-theme="dark"] .status-lane {
|
||||
background: linear-gradient(180deg, #1e2330 0%, #1a1d27 100%);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-item {
|
||||
background: #252b38;
|
||||
border-color: var(--color-border);
|
||||
color: #c8d3e6;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-item.is-drag-over {
|
||||
border-color: #7aa2f7;
|
||||
background: #1e2d4a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .drag-handle {
|
||||
color: #5f6f87;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-group {
|
||||
background: #1e2230;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-group-header {
|
||||
background: #232840;
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-group-title {
|
||||
color: #c8d3e6;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-group-count {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-empty {
|
||||
color: #5f6f87;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-tree-row {
|
||||
border-bottom-color: #2e3444;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-row {
|
||||
background: #1e2230;
|
||||
color: #c8d3e6;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-row:hover {
|
||||
background: #252d40;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .tree-toggle {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .task-main small {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-trigger:hover {
|
||||
background: #2e3448;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .status-trigger-dot {
|
||||
border-color: #1e2230;
|
||||
box-shadow: 0 0 0 1px rgba(180, 195, 220, 0.2);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-state {
|
||||
color: #7a8fa8;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
220
frontend/src/assets/styles/scoped/components/WorkspaceList.css
Normal file
220
frontend/src/assets/styles/scoped/components/WorkspaceList.css
Normal file
@@ -0,0 +1,220 @@
|
||||
.workspace-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-workspace-state {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 48vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border: 1px dashed #cfd6e4;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #f8f9fc 0%, #eef3fb 100%);
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.empty-workspace-icon {
|
||||
font-size: 5.25rem;
|
||||
line-height: 1;
|
||||
color: #60789a;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-workspace-title {
|
||||
margin: 0;
|
||||
color: #23364f;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-workspace-message {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 460px;
|
||||
color: #4f637d;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.content-title {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #408aca;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.featured-icon {
|
||||
color: #f08c00;
|
||||
font-size: 0.95em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
color: #5568a8;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-card.is-pinned {
|
||||
background: #dbf5ff;
|
||||
border-color: #a8d1ff;
|
||||
}
|
||||
|
||||
.content-card.is-featured {
|
||||
border-color: #ffd8a8;
|
||||
background: #fff9db;
|
||||
}
|
||||
|
||||
.content-card.is-task-list {
|
||||
border-color: #d9e3ff;
|
||||
background: #f7f9ff;
|
||||
}
|
||||
|
||||
.list-footer {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.workspace-list--list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.workspace-list--list .content-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.workspace-list--list .content-card:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background-color: #eef2ff;
|
||||
border-color: var(--color-primary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.workspace-list--list .content-title {
|
||||
flex: 0 0 220px;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-list--list .content-preview {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.workspace-list--list .content-card > small {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.empty-workspace-state {
|
||||
min-height: 40vh;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.empty-workspace-icon {
|
||||
font-size: 4.3rem;
|
||||
}
|
||||
|
||||
.empty-workspace-title {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-workspace-state {
|
||||
border-color: var(--color-border);
|
||||
background: linear-gradient(180deg, #1e2430 0%, var(--color-surface) 100%);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-workspace-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .empty-workspace-message {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card {
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .workspace-list--list .content-card:hover {
|
||||
background-color: #2a2f3a;
|
||||
border-color: #7aa2f7;
|
||||
border-left-color: #7aa2f7;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-title {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-preview {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card.is-pinned {
|
||||
background: #1a3a5c;
|
||||
border-color: #2d6a9f;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card.is-featured {
|
||||
background: #3a2e0a;
|
||||
border-color: #7a5a0a;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .content-card.is-task-list {
|
||||
background: #1f2a44;
|
||||
border-color: #334b7d;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .list-icon {
|
||||
color: #bfceef;
|
||||
}
|
||||
234
frontend/src/assets/styles/scoped/pages/Admin.css
Normal file
234
frontend/src/assets/styles/scoped/pages/Admin.css
Normal file
@@ -0,0 +1,234 @@
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link {
|
||||
border-radius: 0.6rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link:hover {
|
||||
background: #eef2f7;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link.active {
|
||||
background: #212529;
|
||||
color: var(--color-surface);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.users-list .list-group-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-meta-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.user-meta-value {
|
||||
color: #495057;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.user-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-shell {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(82vw, 320px);
|
||||
z-index: 1410;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-row-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root[data-bs-theme="dark"] .admin-topbar {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .admin-sidebar {
|
||||
background: var(--color-surface);
|
||||
border-right-color: var(--color-border);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .admin-nav .nav-link {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
|
||||
background: var(--color-text);
|
||||
color: #1a1d23;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .user-meta-value {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .admin-section {
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
|
||||
116
frontend/src/assets/styles/scoped/pages/Login.css
Normal file
116
frontend/src/assets/styles/scoped/pages/Login.css
Normal file
@@ -0,0 +1,116 @@
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--color-surface);
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(53, 84, 209, 0.12);
|
||||
color: #2f4ac1;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 2.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 0.65rem;
|
||||
min-height: 48px;
|
||||
border-color: #d6dbe4;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
min-height: 48px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-provider-btn {
|
||||
min-height: 48px;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.oauth-divider::before,
|
||||
.oauth-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.oauth-divider span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.auth-switch-link {
|
||||
color: #4b5565;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.login-page {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 14px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
frontend/src/assets/styles/scoped/pages/PublicSpace.css
Normal file
71
frontend/src/assets/styles/scoped/pages/PublicSpace.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.public-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.public-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.public-sidebar {
|
||||
width: 280px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: #dbe4ff;
|
||||
border-color: #748ffc;
|
||||
color: #364fc7;
|
||||
}
|
||||
|
||||
.note-item.is-featured {
|
||||
background: var(--color-surface)4e6;
|
||||
border-color: #ffd8a8;
|
||||
}
|
||||
|
||||
.note-item.is-featured:hover {
|
||||
background: #ffe8cc;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.public-sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
.public-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1095;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.public-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
frontend/src/assets/styles/scoped/pages/Register.css
Normal file
93
frontend/src/assets/styles/scoped/pages/Register.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.register-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: var(--color-surface);
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(53, 84, 209, 0.12);
|
||||
color: #2f4ac1;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 2.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 0.65rem;
|
||||
min-height: 48px;
|
||||
border-color: #d6dbe4;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
min-height: 48px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-switch-link {
|
||||
color: #4b5565;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.register-page {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
border-radius: 14px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
32
frontend/src/assets/styles/shared/danger-zone.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.danger-zone {
|
||||
padding: 1rem;
|
||||
border: 1px solid #f3b5b5;
|
||||
border-radius: 0.75rem;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.danger-zone-title {
|
||||
color: #9f1c1c;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.danger-zone-copy {
|
||||
color: #7a2727;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .danger-zone {
|
||||
background: #2d1a1a;
|
||||
border-color: #7a3030;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .danger-zone-title {
|
||||
color: #fc8181;
|
||||
}
|
||||
|
||||
:root[data-bs-theme="dark"] .danger-zone-copy {
|
||||
color: #fca5a5;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const hydrateForm = () => {
|
||||
form.value = {
|
||||
name: props.group?.name || "",
|
||||
description: props.group?.description || "",
|
||||
permissionsText: (props.group?.permissions || []).join("\n"),
|
||||
permissionsText: (props.group?.permissions || []).join("/n"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -93,33 +93,7 @@ const handleSubmit = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/components/AdminGroupModal.css"></style>
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.permissions-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,6 +60,20 @@
|
||||
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'edit'" class="col-12">
|
||||
<DangerZonePanel
|
||||
class="mt-4"
|
||||
title-id="danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Permanently delete this provider configuration. This action cannot be undone."
|
||||
>
|
||||
<button class="btn btn-danger" type="button" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Provider
|
||||
</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -78,6 +92,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -92,9 +107,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
const emit = defineEmits(["close", "submit", "delete"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
@@ -160,28 +179,4 @@ const handleSubmit = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/AdminProviderModal.css"></style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||
<div class="modal fade show d-block admin-modal" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
@@ -85,24 +85,39 @@
|
||||
<div v-if="success" class="alert alert-success mt-3 mb-0">{{ success }}</div>
|
||||
|
||||
<hr />
|
||||
<div class="border border-danger rounded p-3 mt-3">
|
||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
||||
<DangerZonePanel
|
||||
class="mt-4"
|
||||
title-id="danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||
>
|
||||
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||
</button>
|
||||
</div>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show admin-modal-backdrop"></div>
|
||||
</teleport>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showDeleteConfirmModal"
|
||||
:title="deleteConfirmTitle"
|
||||
:message="deleteConfirmMessage"
|
||||
:busy="deleteConfirmBusy"
|
||||
@close="closeDeleteConfirmModal"
|
||||
@confirm="confirmDeleteAction"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import apiClient from "../services/apiClient";
|
||||
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
space: {
|
||||
@@ -133,6 +148,20 @@ const error = ref("");
|
||||
const success = ref("");
|
||||
const newMember = ref({ user_id: "" });
|
||||
const deleting = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const deleteConfirmBusy = ref(false);
|
||||
const deleteConfirmIntent = ref({
|
||||
type: "",
|
||||
payload: null,
|
||||
});
|
||||
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
if (deleteConfirmIntent.value.type === "member") {
|
||||
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||
return `Remove member "${memberName}" from this space?`;
|
||||
}
|
||||
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||
});
|
||||
|
||||
const formatDate = (iso) => (iso ? new Date(iso).toLocaleDateString() : "-");
|
||||
|
||||
@@ -208,9 +237,20 @@ const addMember = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member) => {
|
||||
const memberName = member?.username || member?.user_id;
|
||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
||||
const removeMember = (member) => {
|
||||
if (!member?.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmIntent.value = {
|
||||
type: "member",
|
||||
payload: member,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const removeMemberConfirmed = async (member) => {
|
||||
if (!member?.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,10 +276,15 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const deleteSpace = async () => {
|
||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
const requestDeleteSpace = () => {
|
||||
deleteConfirmIntent.value = {
|
||||
type: "space",
|
||||
payload: props.space,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const deleteSpaceConfirmed = async () => {
|
||||
deleting.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
@@ -247,35 +292,51 @@ const deleteSpace = async () => {
|
||||
emit("deleted", props.space);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete space.";
|
||||
throw e;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteConfirmModal = () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
};
|
||||
|
||||
const confirmDeleteAction = async () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = deleteConfirmIntent.value;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmBusy.value = true;
|
||||
try {
|
||||
if (type === "member") {
|
||||
await removeMemberConfirmed(payload);
|
||||
} else if (type === "space") {
|
||||
await deleteSpaceConfirmed();
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
} finally {
|
||||
deleteConfirmBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/AdminSpaceModal.css"></style>
|
||||
|
||||
@@ -83,29 +83,7 @@ const handleSubmit = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal {
|
||||
z-index: 2000;
|
||||
overflow-y: auto;
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/components/AdminUserModal.css"></style>
|
||||
|
||||
.admin-modal-backdrop {
|
||||
z-index: 1990;
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-modal {
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-modal .modal-dialog {
|
||||
margin: 0.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="category-tree">
|
||||
<div v-for="category in categories" :key="category.id" class="category-item">
|
||||
<div class="category-header" @click="handleCategoryClick(category)">
|
||||
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length">
|
||||
<span class="expand-icon" v-if="category.subcategories?.length || category.notes?.length || category.task_lists?.length">
|
||||
<i :class="expandedCategories[category.id] ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span v-else class="expand-icon"> </span>
|
||||
@@ -20,6 +20,11 @@
|
||||
</div>
|
||||
|
||||
<div v-if="expandedCategories[category.id]" class="category-content">
|
||||
<div v-for="taskList in category.task_lists || []" :key="taskList.id" class="task-list-item" @click.stop="onSelectTaskList(taskList)">
|
||||
<i class="mdi mdi-format-list-checkbox me-1" aria-hidden="true"></i>
|
||||
<span>{{ taskList.name }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="note in sortedNotes(category.notes)"
|
||||
:key="note.id"
|
||||
@@ -41,6 +46,7 @@
|
||||
:on-add-subcategory="onAddSubcategory"
|
||||
:on-edit-category="onEditCategory"
|
||||
:on-delete-category="onDeleteCategory"
|
||||
:on-select-task-list="onSelectTaskList"
|
||||
:can-create-categories="canCreateCategories"
|
||||
:can-edit-categories="canEditCategories"
|
||||
:can-delete-categories="canDeleteCategories"
|
||||
@@ -80,6 +86,10 @@ const props = defineProps({
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onSelectTaskList: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
canCreateCategories: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -141,138 +151,4 @@ const handleDeleteCategory = (category) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.category-header:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.menu-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
min-width: 150px;
|
||||
padding: 0.35rem;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.category-content {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.note-item span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
color: #408aca;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.featured-icon {
|
||||
color: #f08c00;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-item.is-pinned {
|
||||
background: #dbf5ff;
|
||||
border: 1px solid #a8d1ff;
|
||||
}
|
||||
|
||||
.note-item.is-pinned:hover {
|
||||
background: #c5e9ff;
|
||||
}
|
||||
|
||||
.note-item.is-featured {
|
||||
background: #fff9db;
|
||||
border: 1px solid #ffd8a8;
|
||||
}
|
||||
|
||||
.note-item.is-featured:hover {
|
||||
background: #fff6c5;
|
||||
}
|
||||
|
||||
.subcategories {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/CategoryTree.css"></style>
|
||||
|
||||
62
frontend/src/components/ConfirmActionModal.vue
Normal file
62
frontend/src/components/ConfirmActionModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div v-if="visible" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title d-flex align-items-center gap-2 mb-0">
|
||||
<i class="mdi mdi-alert-outline text-danger" aria-hidden="true"></i>
|
||||
<span>{{ title }}</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" :disabled="busy" @click="emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted mb-0">{{ message }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" :disabled="busy" @click="emit('close')">{{ cancelLabel }}</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="busy" @click="emit('confirm')">
|
||||
{{ busy ? busyLabel : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="visible" class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "Confirm Deletion",
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: "Are you sure you want to continue?",
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: "Delete",
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: "Cancel",
|
||||
},
|
||||
busyLabel: {
|
||||
type: String,
|
||||
default: "Deleting...",
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "confirm"]);
|
||||
</script>
|
||||
@@ -90,4 +90,5 @@ const handleSubmit = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
|
||||
|
||||
@@ -141,16 +141,7 @@ const handleCreate = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.note-flags {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/components/CreateNoteModal.css"></style>
|
||||
|
||||
|
||||
|
||||
.flag-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,4 +49,5 @@ const handleCreate = () => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
|
||||
|
||||
84
frontend/src/components/CreateTaskListModal.vue
Normal file
84
frontend/src/components/CreateTaskListModal.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="closeModal">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create New Task List</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="mb-3">
|
||||
<label for="taskListName" class="form-label">Task List Name</label>
|
||||
<input id="taskListName" v-model="form.name" type="text" class="form-control" maxlength="120" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="taskListCategory" class="form-label">Category</label>
|
||||
<select id="taskListCategory" v-model="form.category_id" class="form-select">
|
||||
<option :value="null">No category</option>
|
||||
<option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
||||
{{ category.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
defaultCategoryId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "create"]);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
category_id: null,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.defaultCategoryId,
|
||||
(defaultCategoryId) => {
|
||||
form.value.category_id = defaultCategoryId || null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = form.value.name.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("create", {
|
||||
name,
|
||||
category_id: form.value.category_id || null,
|
||||
});
|
||||
|
||||
form.value = {
|
||||
name: "",
|
||||
category_id: props.defaultCategoryId || null,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
24
frontend/src/components/DangerZonePanel.vue
Normal file
24
frontend/src/components/DangerZonePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<section class="danger-zone" :aria-labelledby="titleId">
|
||||
<h3 :id="titleId" class="danger-zone-title mb-2">{{ title }}</h3>
|
||||
<p class="danger-zone-copy mb-3">{{ description }}</p>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
titleId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "Danger Zone",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "This action is permanent and cannot be undone.",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
317
frontend/src/components/EditTaskListModal.vue
Normal file
317
frontend/src/components/EditTaskListModal.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Task List</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Task List Details -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="editTaskListName">Name</label>
|
||||
<input id="editTaskListName" v-model="listForm.name" type="text" class="form-control" maxlength="120" />
|
||||
|
||||
<label class="form-label mt-3" for="editTaskListCategory">Category</label>
|
||||
<select id="editTaskListCategory" v-model="listForm.category_id" class="form-select">
|
||||
<option :value="null">No category</option>
|
||||
<option v-for="cat in categoryOptions" :key="cat.id" :value="cat.id">{{ cat.label }}</option>
|
||||
</select>
|
||||
|
||||
<button type="button" class="btn btn-primary mt-3" @click="saveListDetails">Save Details</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Status Progression -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Status Progression</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openCreateStatusModal">Add Status</button>
|
||||
</div>
|
||||
<div class="status-list">
|
||||
<div
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="status-item"
|
||||
:class="{ 'is-drag-over': dragOverStatusId === status.id }"
|
||||
draggable="true"
|
||||
@dragstart="onStatusDragStart(status.id)"
|
||||
@dragover.prevent="onStatusDragOver(status.id)"
|
||||
@dragleave="onStatusDragLeave(status.id)"
|
||||
@drop.prevent="onStatusDrop(status.id)"
|
||||
@dragend="onStatusDragEnd"
|
||||
>
|
||||
<span class="drag-handle" aria-hidden="true">
|
||||
<i class="mdi mdi-drag-horizontal-variant"></i>
|
||||
</span>
|
||||
<span class="status-dot" :style="{ backgroundColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-name">{{ status.name }}</span>
|
||||
<div class="status-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="openEditStatusModal(status)">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<DangerZonePanel
|
||||
v-if="canDeleteTaskList"
|
||||
title-id="edit-task-list-danger-zone"
|
||||
title="Danger Zone"
|
||||
description="Delete this task list, all associated tasks, and statuses permanently."
|
||||
>
|
||||
<button type="button" class="btn btn-danger" @click="emit('delete-task-list', taskList)">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Task List
|
||||
</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
|
||||
<!-- Status Create/Edit Sub-Modal -->
|
||||
<div v-if="showStatusModal" class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" style="z-index: 1060" @click.self="closeStatusModal">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ statusMode === "create" ? "Create Task Status" : "Edit Task Status" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeStatusModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label" for="editStatusName">Status Name</label>
|
||||
<input id="editStatusName" v-model="statusForm.name" type="text" class="form-control" maxlength="100" placeholder="e.g. Blocked" />
|
||||
|
||||
<label class="form-label mt-3" for="editStatusColor">Status Color</label>
|
||||
<div class="status-color-row">
|
||||
<input id="editStatusColor" v-model="statusForm.color" type="color" class="form-control form-control-color" title="Choose status color" />
|
||||
<input v-model="statusForm.color" type="text" class="form-control" placeholder="#7c8596" maxlength="20" />
|
||||
</div>
|
||||
|
||||
<DangerZonePanel
|
||||
v-if="statusMode === 'edit'"
|
||||
class="mt-4"
|
||||
title-id="edit-status-danger-zone"
|
||||
title="Danger Zone"
|
||||
description="Deleting this status is permanent and cannot be undone."
|
||||
>
|
||||
<button type="button" class="btn btn-outline-danger" @click="deleteStatusFromModal">Delete Status</button>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="closeStatusModal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @click="submitStatusForm">
|
||||
{{ statusMode === "create" ? "Create" : "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showStatusModal" class="modal-backdrop fade show" style="z-index: 1055"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
taskList: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canDeleteTaskList: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "update-task-list", "reorder-status", "create-status", "rename-status", "delete-status", "delete-task-list"]);
|
||||
|
||||
const listForm = ref({ name: "", category_id: null });
|
||||
|
||||
watch(
|
||||
() => props.taskList,
|
||||
(tl) => {
|
||||
listForm.value = {
|
||||
name: tl?.name || "",
|
||||
category_id: tl?.category_id || null,
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const saveListDetails = () => {
|
||||
const name = listForm.value.name?.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
emit("update-task-list", {
|
||||
name,
|
||||
category_id: listForm.value.category_id || null,
|
||||
});
|
||||
};
|
||||
|
||||
// Status drag-and-drop reorder
|
||||
const draggedStatusId = ref("");
|
||||
const dragOverStatusId = ref("");
|
||||
|
||||
const onStatusDragStart = (id) => {
|
||||
draggedStatusId.value = id;
|
||||
};
|
||||
const onStatusDragOver = (id) => {
|
||||
dragOverStatusId.value = id;
|
||||
};
|
||||
const onStatusDragLeave = (id) => {
|
||||
if (dragOverStatusId.value === id) {
|
||||
dragOverStatusId.value = "";
|
||||
}
|
||||
};
|
||||
const onStatusDrop = (targetId) => {
|
||||
if (!draggedStatusId.value || draggedStatusId.value === targetId) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
const ordered = props.statuses.map((s) => s.id);
|
||||
const fromIndex = ordered.indexOf(draggedStatusId.value);
|
||||
const targetIndex = ordered.indexOf(targetId);
|
||||
if (fromIndex < 0 || targetIndex < 0) {
|
||||
onStatusDragEnd();
|
||||
return;
|
||||
}
|
||||
ordered.splice(fromIndex, 1);
|
||||
const insertIndex = ordered.indexOf(targetId);
|
||||
ordered.splice(insertIndex, 0, draggedStatusId.value);
|
||||
emit("reorder-status", ordered);
|
||||
onStatusDragEnd();
|
||||
};
|
||||
const onStatusDragEnd = () => {
|
||||
draggedStatusId.value = "";
|
||||
dragOverStatusId.value = "";
|
||||
};
|
||||
|
||||
// Status create/edit modal
|
||||
const showStatusModal = ref(false);
|
||||
const statusMode = ref("create");
|
||||
const editingStatusId = ref("");
|
||||
const statusForm = ref({ name: "", color: "#7c8596" });
|
||||
|
||||
const openCreateStatusModal = () => {
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = { name: "", color: "#7c8596" };
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const openEditStatusModal = (status) => {
|
||||
statusMode.value = "edit";
|
||||
editingStatusId.value = status.id;
|
||||
statusForm.value = { name: status.name || "", color: status.color || "#7c8596" };
|
||||
showStatusModal.value = true;
|
||||
};
|
||||
|
||||
const closeStatusModal = () => {
|
||||
showStatusModal.value = false;
|
||||
statusMode.value = "create";
|
||||
editingStatusId.value = "";
|
||||
statusForm.value = { name: "", color: "#7c8596" };
|
||||
};
|
||||
|
||||
const submitStatusForm = () => {
|
||||
const name = statusForm.value.name?.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const color = statusForm.value.color?.trim() || "";
|
||||
if (statusMode.value === "create") {
|
||||
emit("create-status", { name, color });
|
||||
} else if (editingStatusId.value) {
|
||||
emit("rename-status", { id: editingStatusId.value, name, color });
|
||||
}
|
||||
closeStatusModal();
|
||||
};
|
||||
|
||||
const deleteStatusFromModal = () => {
|
||||
if (statusMode.value !== "edit" || !editingStatusId.value) {
|
||||
return;
|
||||
}
|
||||
emit("delete-status", {
|
||||
id: editingStatusId.value,
|
||||
name: statusForm.value.name?.trim() || "",
|
||||
color: statusForm.value.color?.trim() || "",
|
||||
});
|
||||
closeStatusModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.status-item.is-drag-over {
|
||||
border-color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-color-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-color-row .form-control-color {
|
||||
width: 40px;
|
||||
padding: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -78,7 +78,7 @@
|
||||
<i :class="fileIcon(obj)" style="font-size: 1rem; width: 1.1rem; flex-shrink: 0" aria-hidden="true"></i>
|
||||
<span class="flex-grow-1 text-truncate" style="font-size: 0.82rem">{{ displayName(obj) }}</span>
|
||||
<span v-if="!obj.is_folder && obj.size > 0" class="text-muted flex-shrink-0" style="font-size: 0.68rem">{{ formatSize(obj.size) }}</span>
|
||||
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="deleteItem(obj)">
|
||||
<button class="btn-delete btn btn-sm btn-link p-0 text-danger ms-1" :title="obj.is_folder ? 'Delete folder' : 'Delete file'" @click.stop="requestDeleteItem(obj)">
|
||||
<i class="mdi mdi-trash-can-outline" style="font-size: 0.85rem" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -87,11 +87,14 @@
|
||||
<!-- Hidden file input -->
|
||||
<input ref="fileInputRef" type="file" multiple class="d-none" @change="handleFilePick" />
|
||||
</div>
|
||||
|
||||
<ConfirmActionModal :visible="showDeleteConfirmModal" title="Delete Item" :message="deleteConfirmMessage" :busy="deletingItem" @close="closeDeleteConfirmModal" @confirm="confirmDeleteItem" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import apiClient from "../services/apiClient";
|
||||
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
spaceId: {
|
||||
@@ -117,6 +120,9 @@ const showNewFolderInput = ref(false);
|
||||
const newFolderName = ref("");
|
||||
const fileInputRef = ref(null);
|
||||
const newFolderInputRef = ref(null);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const pendingDeleteItem = ref(null);
|
||||
const deletingItem = ref(false);
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!currentPrefix.value) return [];
|
||||
@@ -215,9 +221,37 @@ const createFolder = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (obj) => {
|
||||
const label = displayName(obj);
|
||||
if (!confirm(`Delete "${label}"?${obj.is_folder ? "\n\nThis will delete all files inside the folder." : ""}`)) return;
|
||||
const requestDeleteItem = (obj) => {
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDeleteItem.value = obj;
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const closeDeleteConfirmModal = () => {
|
||||
if (deletingItem.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
pendingDeleteItem.value = null;
|
||||
};
|
||||
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
const obj = pendingDeleteItem.value;
|
||||
const label = obj ? displayName(obj) : "this item";
|
||||
return obj?.is_folder ? `Delete "${label}"? This will delete all files inside the folder.` : `Delete "${label}"?`;
|
||||
});
|
||||
|
||||
const confirmDeleteItem = async () => {
|
||||
const obj = pendingDeleteItem.value;
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingItem.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (obj.is_folder) {
|
||||
@@ -227,8 +261,12 @@ const deleteItem = async (obj) => {
|
||||
await apiClient.delete(`/api/v1/spaces/${props.spaceId}/files/object`, { params: { key: obj.key } });
|
||||
}
|
||||
await loadFiles();
|
||||
showDeleteConfirmModal.value = false;
|
||||
pendingDeleteItem.value = null;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Delete failed";
|
||||
} finally {
|
||||
deletingItem.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,48 +322,4 @@ watch(showNewFolderInput, async (v) => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-explorer {
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-explorer-header {
|
||||
font-size: 0.8rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
color: #333;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background-color: #f0f4ff;
|
||||
}
|
||||
|
||||
.drag-active {
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.file-item:hover .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/FileExplorer.css"></style>
|
||||
|
||||
@@ -187,79 +187,7 @@ const createProvider = async () => {
|
||||
onMounted(loadProviders);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop-custom {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/components/ManageAuthProvidersModal.css"></style>
|
||||
|
||||
.modal-panel {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(92vh, 980px);
|
||||
background: #fff;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.provider-modal-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-modal-close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-modal-body {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-backdrop-custom {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-modal-header,
|
||||
.provider-modal-body {
|
||||
padding-left: 0.85rem;
|
||||
padding-right: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
<i class="mdi mdi-folder-open-outline me-1" aria-hidden="true"></i>
|
||||
Files
|
||||
</button>
|
||||
<button
|
||||
v-if="spaceId"
|
||||
class="btn btn-sm"
|
||||
:class="showTaskPicker ? 'btn-secondary' : 'btn-outline-secondary'"
|
||||
:title="showTaskPicker ? 'Hide task picker' : 'Browse & insert task mentions'"
|
||||
@click="toggleTaskPicker"
|
||||
>
|
||||
<i class="mdi mdi-checkbox-marked-circle-outline me-1" aria-hidden="true"></i>
|
||||
Tasks
|
||||
</button>
|
||||
<span class="save-status ms-auto" :class="saveState">{{ saveStatusLabel }}</span>
|
||||
</div>
|
||||
|
||||
@@ -25,19 +35,52 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div :class="showFileExplorer ? 'col-12 col-md-5' : 'col-12 col-md-6'">
|
||||
<div :class="editorColumnClass">
|
||||
<textarea ref="contentTextareaRef" v-model="editingNote.content" class="form-control editor-textarea" placeholder="Write your note in markdown..." @input="autoSave"></textarea>
|
||||
<div v-if="showTaskMention" class="task-mention-panel">
|
||||
<div class="small text-muted mb-1">Link task for "{{ taskMentionQuery }}"</div>
|
||||
<button v-for="task in taskMentionResults" :key="task.id" class="task-mention-option" @click="selectMentionTask(task)">
|
||||
<span>{{ task.title }}</span>
|
||||
<small>Depth {{ task.depth + 1 }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
||||
<div class="preview-pane border rounded p-3">
|
||||
<div :class="previewColumnClass">
|
||||
<div class="preview-pane border rounded p-3" @click="onPreviewClick">
|
||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showFileExplorer" class="col-12 col-md-3 mt-3 mt-md-0">
|
||||
<div v-if="showFileExplorer" :class="fileExplorerColumnClass">
|
||||
<FileExplorer v-model="fileExplorerPrefix" :space-id="spaceId" @insert="insertAtCursor" />
|
||||
</div>
|
||||
|
||||
<div v-if="showTaskPicker" :class="taskPickerColumnClass">
|
||||
<div class="task-picker border rounded">
|
||||
<div class="task-picker-header px-2 py-1 border-bottom d-flex align-items-center gap-2">
|
||||
<i class="mdi mdi-checkbox-marked-circle-outline text-muted" aria-hidden="true"></i>
|
||||
<span class="small fw-semibold">Space Tasks</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted ms-auto" title="Refresh" @click="refreshTaskPicker">
|
||||
<i class="mdi mdi-refresh" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="task-picker-search p-2 border-bottom">
|
||||
<input v-model="taskPickerQuery" type="text" class="form-control form-control-sm" placeholder="Search tasks by title..." />
|
||||
</div>
|
||||
<div v-if="taskPickerLoading" class="task-picker-empty text-muted small">
|
||||
<i class="mdi mdi-loading mdi-spin me-1" aria-hidden="true"></i>
|
||||
Loading tasks...
|
||||
</div>
|
||||
<div v-else-if="!taskPickerItems.length" class="task-picker-empty text-muted small">No tasks found.</div>
|
||||
<div v-else class="task-picker-list">
|
||||
<button v-for="task in taskPickerItems" :key="task.id" class="task-picker-item" @click="insertTaskMention(task)">
|
||||
<span class="task-picker-title">{{ task.title }}</span>
|
||||
<small>{{ task.picker_status_name }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -82,24 +125,33 @@
|
||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||
</div>
|
||||
|
||||
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
|
||||
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
|
||||
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
|
||||
<button class="btn btn-danger" type="button" @click="confirmDelete">
|
||||
<DangerZonePanel v-if="canDelete && editingNote.id" class="mt-4" title-id="danger-zone-title" title="Danger Zone" description="Deleting this note is permanent and cannot be undone.">
|
||||
<button class="btn btn-danger" type="button" @click="requestDelete">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
Delete Note
|
||||
</button>
|
||||
</section>
|
||||
</DangerZonePanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showDeleteConfirmModal"
|
||||
title="Delete Note"
|
||||
message="Are you sure you want to delete this note? This action cannot be undone."
|
||||
@close="showDeleteConfirmModal = false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useSettingsStore } from "../stores/settingsStore";
|
||||
import { useSpaceStore } from "../stores/spaceStore";
|
||||
import { renderMarkdown } from "../utils/markdown.js";
|
||||
import FileExplorer from "./FileExplorer.vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
@@ -120,8 +172,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||
const emit = defineEmits(["save", "delete", "cancel", "open-linked-task"]);
|
||||
const settingsStore = useSettingsStore();
|
||||
const spaceStore = useSpaceStore();
|
||||
const publicSharingEnabled = ref(true);
|
||||
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
||||
|
||||
@@ -135,12 +188,111 @@ const notePassword = ref("");
|
||||
const saveTimeout = ref(null);
|
||||
const saveState = ref("saved");
|
||||
const saveStateTimeout = ref(null);
|
||||
const taskMentionQuery = ref("");
|
||||
const taskMentionResults = ref([]);
|
||||
const showTaskMention = ref(false);
|
||||
const linkedTasks = ref([]);
|
||||
const showTaskPicker = ref(false);
|
||||
const taskPickerQuery = ref("");
|
||||
const taskPickerLoading = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
|
||||
const hasAuxPanels = computed(() => showFileExplorer.value || showTaskPicker.value);
|
||||
const hasTwoAuxPanels = computed(() => showFileExplorer.value && showTaskPicker.value);
|
||||
|
||||
const editorColumnClass = computed(() => {
|
||||
if (hasTwoAuxPanels.value) {
|
||||
return "col-12 col-xl-4";
|
||||
}
|
||||
return hasAuxPanels.value ? "col-12 col-md-5" : "col-12 col-md-6";
|
||||
});
|
||||
|
||||
const previewColumnClass = computed(() => {
|
||||
if (hasTwoAuxPanels.value) {
|
||||
return "col-12 col-xl-4 mt-3 mt-xl-0";
|
||||
}
|
||||
return hasAuxPanels.value ? "col-12 col-md-4 mt-3 mt-md-0" : "col-12 col-md-6 mt-3 mt-md-0";
|
||||
});
|
||||
|
||||
const fileExplorerColumnClass = computed(() => {
|
||||
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
|
||||
});
|
||||
|
||||
const taskPickerColumnClass = computed(() => {
|
||||
return hasTwoAuxPanels.value ? "col-12 col-md-6 col-xl-2 mt-3 mt-xl-0" : "col-12 col-md-3 mt-3 mt-md-0";
|
||||
});
|
||||
|
||||
const taskStatusNameById = computed(() => {
|
||||
const map = new Map();
|
||||
for (const status of spaceStore.taskStatuses || []) {
|
||||
if (status?.id) {
|
||||
map.set(status.id, status.name || "");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const taskPickerItems = computed(() => {
|
||||
const query = taskPickerQuery.value.trim().toLowerCase();
|
||||
const allTasks = [...(spaceStore.tasks || [])]
|
||||
.map((task) => ({
|
||||
...task,
|
||||
picker_status_name: task.status_name || task.status?.name || taskStatusNameById.value.get(task.status_id) || "Unknown",
|
||||
}))
|
||||
.sort((a, b) => (a.title || "").localeCompare(b.title || ""));
|
||||
if (!query) {
|
||||
return allTasks;
|
||||
}
|
||||
return allTasks.filter((task) => (task.title || "").toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = renderMarkdown(editingNote.value.content || "");
|
||||
const html = renderMarkdown(enrichTaskMentions(editingNote.value.content || ""));
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
|
||||
|
||||
const taskByTitle = computed(() => {
|
||||
const map = new Map();
|
||||
for (const task of linkedTasks.value || []) {
|
||||
const key = normalizeTaskTitle(task.title);
|
||||
if (!key || map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, task);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const enrichTaskMentions = (content) => {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
|
||||
const title = (rawTitle || "").trim();
|
||||
if (!title) {
|
||||
return full;
|
||||
}
|
||||
|
||||
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
|
||||
if (!linkedTask?.id) {
|
||||
return full;
|
||||
}
|
||||
|
||||
const statusName = (linkedTask.status_name || "Unknown").trim();
|
||||
const safeTitle = title.replaceAll("&", "&").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(() => {
|
||||
switch (saveState.value) {
|
||||
case "dirty":
|
||||
@@ -155,12 +307,21 @@ const saveStatusLabel = computed(() => {
|
||||
|
||||
watch(
|
||||
() => props.note,
|
||||
(newNote) => {
|
||||
async (newNote) => {
|
||||
editingNote.value = { ...newNote };
|
||||
tagsInput.value = newNote.tags?.join(", ") || "";
|
||||
passwordAction.value = "keep";
|
||||
notePassword.value = "";
|
||||
saveState.value = "saved";
|
||||
if (props.spaceId && newNote?.id) {
|
||||
try {
|
||||
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, newNote.id);
|
||||
} catch {
|
||||
linkedTasks.value = [];
|
||||
}
|
||||
} else {
|
||||
linkedTasks.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -211,21 +372,33 @@ const saveNote = () => {
|
||||
notePassword.value = "";
|
||||
}
|
||||
markSavedSoon();
|
||||
// Auto-link any @task(Title) mentions present in the saved content
|
||||
syncTaskMentionLinks(note.content || "");
|
||||
};
|
||||
|
||||
const autoSave = () => {
|
||||
saveState.value = "dirty";
|
||||
clearTimeout(saveTimeout.value);
|
||||
saveTimeout.value = setTimeout(saveNote, 3000);
|
||||
detectTaskMention();
|
||||
};
|
||||
|
||||
const requestDelete = () => {
|
||||
if (!props.canDelete) {
|
||||
return;
|
||||
}
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!props.canDelete) {
|
||||
return;
|
||||
}
|
||||
if (confirm("Are you sure you want to delete this note?")) {
|
||||
emit("delete", editingNote.value.id);
|
||||
if (!editingNote.value?.id) {
|
||||
return;
|
||||
}
|
||||
showDeleteConfirmModal.value = false;
|
||||
emit("delete", editingNote.value.id);
|
||||
};
|
||||
|
||||
/** Insert markdown snippet at the textarea cursor position. */
|
||||
@@ -249,6 +422,152 @@ const insertAtCursor = (snippet) => {
|
||||
});
|
||||
};
|
||||
|
||||
const detectTaskMention = async () => {
|
||||
const content = editingNote.value.content || "";
|
||||
const match = content.match(/@task\s+([^\n]{1,40})$/i);
|
||||
if (!match || !props.spaceId) {
|
||||
showTaskMention.value = false;
|
||||
taskMentionResults.value = [];
|
||||
taskMentionQuery.value = "";
|
||||
return;
|
||||
}
|
||||
const query = match[1].trim();
|
||||
taskMentionQuery.value = query;
|
||||
if (!query) {
|
||||
showTaskMention.value = false;
|
||||
taskMentionResults.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
taskMentionResults.value = await spaceStore.searchTasks(props.spaceId, query);
|
||||
showTaskMention.value = taskMentionResults.value.length > 0;
|
||||
} catch {
|
||||
taskMentionResults.value = [];
|
||||
showTaskMention.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const replaceTaskMentionText = (title) => {
|
||||
editingNote.value.content = (editingNote.value.content || "").replace(/@task\s+([^\n]{1,40})$/i, `@task(${title})`);
|
||||
};
|
||||
|
||||
const selectMentionTask = async (task) => {
|
||||
replaceTaskMentionText(task.title);
|
||||
showTaskMention.value = false;
|
||||
taskMentionResults.value = [];
|
||||
if (!props.spaceId || !editingNote.value.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await spaceStore.linkTaskToNote(props.spaceId, task.id, editingNote.value.id);
|
||||
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||
} catch {
|
||||
alert("Unable to link task to this note.");
|
||||
}
|
||||
autoSave();
|
||||
};
|
||||
|
||||
const insertTaskMention = (task) => {
|
||||
if (!task?.title) {
|
||||
return;
|
||||
}
|
||||
insertAtCursor(`@task(${task.title})`);
|
||||
};
|
||||
|
||||
const refreshTaskPicker = async () => {
|
||||
if (!props.spaceId) {
|
||||
return;
|
||||
}
|
||||
taskPickerLoading.value = true;
|
||||
try {
|
||||
await Promise.all([spaceStore.fetchTasks(props.spaceId), spaceStore.fetchTaskStatuses(props.spaceId)]);
|
||||
} finally {
|
||||
taskPickerLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskPicker = async () => {
|
||||
showTaskPicker.value = !showTaskPicker.value;
|
||||
if (showTaskPicker.value && !spaceStore.tasks.length) {
|
||||
await refreshTaskPicker();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse all @task(Title) mentions in content and ensure each is linked.
|
||||
* Called after every real save so new mentions are linked automatically.
|
||||
*/
|
||||
const syncTaskMentionLinks = async (content) => {
|
||||
if (!props.spaceId || !editingNote.value.id) {
|
||||
return;
|
||||
}
|
||||
const mentionTitles = new Set();
|
||||
const rx = /@task\(([^)]+)\)/gi;
|
||||
let m;
|
||||
while ((m = rx.exec(content)) !== null) {
|
||||
const title = (m[1] || "").trim();
|
||||
if (title) {
|
||||
mentionTitles.add(title.toLowerCase());
|
||||
}
|
||||
}
|
||||
if (!mentionTitles.size) {
|
||||
return;
|
||||
}
|
||||
let current;
|
||||
try {
|
||||
current = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const linkedTitles = new Set((current || []).map((t) => (t.title || "").toLowerCase()));
|
||||
const toLink = [...mentionTitles].filter((title) => !linkedTitles.has(title));
|
||||
if (!toLink.length) {
|
||||
linkedTasks.value = current;
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
toLink.map(async (title) => {
|
||||
try {
|
||||
const results = await spaceStore.searchTasks(props.spaceId, title);
|
||||
const exact = results.find((t) => (t.title || "").toLowerCase() === title);
|
||||
if (exact) {
|
||||
await spaceStore.linkTaskToNote(props.spaceId, exact.id, editingNote.value.id);
|
||||
}
|
||||
} catch {
|
||||
// best-effort — skip silently
|
||||
}
|
||||
}),
|
||||
);
|
||||
try {
|
||||
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const onPreviewClick = (event) => {
|
||||
const anchor = event.target?.closest?.("a");
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute("href") || "";
|
||||
if (!href.startsWith("#task:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const taskId = href.slice("#task:".length);
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedTask = (linkedTasks.value || []).find((task) => task.id === taskId);
|
||||
if (matchedTask) {
|
||||
emit("open-linked-task", matchedTask);
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(saveTimeout.value);
|
||||
clearTimeout(saveStateTimeout.value);
|
||||
@@ -257,93 +576,17 @@ onBeforeUnmount(() => {
|
||||
onMounted(async () => {
|
||||
await settingsStore.loadFeatureFlags();
|
||||
publicSharingEnabled.value = settingsStore.publicSharingEnabled;
|
||||
if (props.spaceId && editingNote.value?.id) {
|
||||
try {
|
||||
linkedTasks.value = await spaceStore.fetchTasksForNote(props.spaceId, editingNote.value.id);
|
||||
} catch {
|
||||
linkedTasks.value = [];
|
||||
}
|
||||
}
|
||||
if (props.spaceId && !spaceStore.tasks.length) {
|
||||
await refreshTaskPicker();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.save-status.dirty {
|
||||
color: #b26a00;
|
||||
}
|
||||
|
||||
.save-status.saving {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.save-status.saved {
|
||||
color: #2b8a3e;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
font-family: "Courier New", monospace;
|
||||
min-height: 600px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.note-flags {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 999px;
|
||||
background: #f8f9fa;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-input {
|
||||
margin: 0;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flag-check-label {
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
background-color: #f8f9fa;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
padding: 1rem;
|
||||
border: 1px solid #f3b5b5;
|
||||
border-radius: 0.75rem;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.danger-zone-title {
|
||||
color: #9f1c1c;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.danger-zone-copy {
|
||||
color: #7a2727;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/NoteEditor.css"></style>
|
||||
|
||||
@@ -1,221 +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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
<div class="markdown-body" v-html="renderedMarkdown" @click="onMarkdownClick"></div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -46,13 +46,61 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
linkedTasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["open-linked-task"]);
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
const html = renderMarkdown(props.note.content || "");
|
||||
const html = renderMarkdown(enrichTaskMentions(props.note.content || ""));
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
|
||||
const normalizeTaskTitle = (value) => (value || "").trim().toLowerCase();
|
||||
|
||||
const taskByTitle = computed(() => {
|
||||
const map = new Map();
|
||||
for (const task of props.linkedTasks || []) {
|
||||
const key = normalizeTaskTitle(task.title);
|
||||
if (!key || map.has(key)) {
|
||||
continue;
|
||||
}
|
||||
map.set(key, task);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const enrichTaskMentions = (content) => {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content.replace(/@task\(([^)]+)\)/gi, (full, rawTitle) => {
|
||||
const title = (rawTitle || "").trim();
|
||||
if (!title) {
|
||||
return full;
|
||||
}
|
||||
|
||||
const linkedTask = taskByTitle.value.get(normalizeTaskTitle(title));
|
||||
if (!linkedTask?.id) {
|
||||
return full;
|
||||
}
|
||||
|
||||
const statusName = (linkedTask.status_name || "Unknown").trim();
|
||||
const safeTitle = title.replaceAll("&", "&").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 categoryId = props.note.category_id;
|
||||
if (!categoryId) {
|
||||
@@ -79,101 +127,29 @@ const categoryLabel = computed(() => {
|
||||
});
|
||||
|
||||
const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
||||
|
||||
const onMarkdownClick = (event) => {
|
||||
const anchor = event.target?.closest?.("a");
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute("href") || "";
|
||||
if (!href.startsWith("#task:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const taskId = href.slice("#task:".length);
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedTask = (props.linkedTasks || []).find((task) => task.id === taskId);
|
||||
if (matchedTask) {
|
||||
emit("open-linked-task", matchedTask);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.note-viewer {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #364fc7;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pinned-chip {
|
||||
color: #005f8f;
|
||||
background: #dbf5ff;
|
||||
border: 1px solid #a8d1ff;
|
||||
}
|
||||
|
||||
.featured-chip {
|
||||
color: #8d7619;
|
||||
|
||||
border: 1px solid #ffd8a8;
|
||||
background: #fff9db;
|
||||
}
|
||||
|
||||
.public-chip {
|
||||
color: #0c5460;
|
||||
border: 1px solid #a5d8ff;
|
||||
background: #e7f5ff;
|
||||
}
|
||||
|
||||
.private-chip {
|
||||
color: #5f3dc4;
|
||||
border: 1px solid #d0bfff;
|
||||
background: #f3f0ff;
|
||||
}
|
||||
.protected-chip {
|
||||
color: #7f5539;
|
||||
border: 1px solid #e0c3a6;
|
||||
background: #fff4e6;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(li) {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/NoteViewer.css"></style>
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
<header class="search-results-header">
|
||||
<h2>Search Results</h2>
|
||||
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
|
||||
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p>
|
||||
<p v-else class="search-meta">Type in the top bar and press Enter to search notes and task lists.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!query" class="empty-state">
|
||||
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>Start your search</h3>
|
||||
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p>
|
||||
<p>Use a title, content keyword, or tag to find matching notes and task lists in the selected space.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="totalResults === 0" class="empty-state">
|
||||
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
|
||||
<h3>No matching notes</h3>
|
||||
<h3>No matching results</h3>
|
||||
<p>Try different keywords or a shorter phrase.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" />
|
||||
<WorkspaceList :items="paginatedItems" :view-mode="viewMode" @select-note="emit('select-note', $event)" @select-task-list="emit('select-task-list', $event)" />
|
||||
|
||||
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
|
||||
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
|
||||
@@ -32,14 +32,14 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import NoteList from "./NoteList.vue";
|
||||
import WorkspaceList from "./WorkspaceList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
query: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
notes: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -57,9 +57,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select-note", "page-change"]);
|
||||
const emit = defineEmits(["select-note", "select-task-list", "page-change"]);
|
||||
|
||||
const totalResults = computed(() => props.notes.length);
|
||||
const totalResults = computed(() => props.items.length);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
|
||||
|
||||
const normalizedPage = computed(() => {
|
||||
@@ -69,9 +69,9 @@ const normalizedPage = computed(() => {
|
||||
return Math.min(props.currentPage, totalPages.value);
|
||||
});
|
||||
|
||||
const paginatedNotes = computed(() => {
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (normalizedPage.value - 1) * props.pageSize;
|
||||
return props.notes.slice(start, start + props.pageSize);
|
||||
return props.items.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
const goToPage = (page) => {
|
||||
@@ -82,73 +82,4 @@ const goToPage = (page) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-results-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-results-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-results-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #223149;
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #5b6f8b;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
color: #4f637d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 48vh;
|
||||
border: 1px dashed #cfdae9;
|
||||
border-radius: 14px;
|
||||
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4.2rem;
|
||||
color: #60789a;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0;
|
||||
color: #223149;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: #5b6f8b;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pagination-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/components/SearchResultsPage.css"></style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<teleport v-if="!showDeleteConfirmModal" to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
@@ -76,13 +76,17 @@
|
||||
|
||||
<template v-if="canDeleteSpace">
|
||||
<hr />
|
||||
<div class="border border-danger rounded p-3 mt-3">
|
||||
<h6 class="text-danger mb-1">Danger Zone</h6>
|
||||
<p class="text-muted small mb-3">Permanently delete this space and all its notes, categories, and members. This cannot be undone.</p>
|
||||
<button class="btn btn-danger btn-sm" :disabled="deleting" @click="deleteSpace">
|
||||
<DangerZonePanel
|
||||
class="mt-4"
|
||||
title-id="danger-zone-title"
|
||||
title="Danger Zone"
|
||||
description="Permanently delete this space and all its notes, categories, and members. This cannot be undone."
|
||||
>
|
||||
<button class="btn btn-danger" type="button" :disabled="deleting" @click="requestDeleteSpace">
|
||||
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||
{{ deleting ? "Deleting..." : "Delete Space" }}
|
||||
</button>
|
||||
</div>
|
||||
</DangerZonePanel>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
|
||||
@@ -93,12 +97,23 @@
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showDeleteConfirmModal"
|
||||
:title="deleteConfirmTitle"
|
||||
:message="deleteConfirmMessage"
|
||||
:busy="deleteConfirmBusy"
|
||||
@close="closeDeleteConfirmModal"
|
||||
@confirm="confirmDeleteAction"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import apiClient from "../services/apiClient";
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
import ConfirmActionModal from "./ConfirmActionModal.vue";
|
||||
import DangerZonePanel from "./DangerZonePanel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
space: {
|
||||
@@ -130,6 +145,20 @@ const success = ref("");
|
||||
const memberForm = ref({ user_id: "" });
|
||||
const canViewMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.view"));
|
||||
const canManageMembers = computed(() => authStore.hasSpacePermission(props.space, "settings.member.manage"));
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const deleteConfirmBusy = ref(false);
|
||||
const deleteConfirmIntent = ref({
|
||||
type: "",
|
||||
payload: null,
|
||||
});
|
||||
const deleteConfirmTitle = computed(() => (deleteConfirmIntent.value.type === "member" ? "Remove Member" : "Delete Space"));
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
if (deleteConfirmIntent.value.type === "member") {
|
||||
const memberName = deleteConfirmIntent.value.payload?.username || deleteConfirmIntent.value.payload?.user_id || "this member";
|
||||
return `Remove member "${memberName}" from this space?`;
|
||||
}
|
||||
return `Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.space,
|
||||
@@ -224,13 +253,24 @@ const addMember = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (member) => {
|
||||
const removeMember = (member) => {
|
||||
if (!canManageMembers.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberName = member?.username || member?.user_id;
|
||||
if (!member?.user_id || !confirm(`Remove member "${memberName}" from this space?`)) {
|
||||
if (!member?.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmIntent.value = {
|
||||
type: "member",
|
||||
payload: member,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const removeMemberConfirmed = async (member) => {
|
||||
if (!member?.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,10 +291,15 @@ if (canViewMembers.value) {
|
||||
Promise.all([loadMembers(), loadUserOptions()]);
|
||||
}
|
||||
|
||||
const deleteSpace = async () => {
|
||||
if (!confirm(`Permanently delete space "${props.space.name}"? All notes, categories, and members will be removed. This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
const requestDeleteSpace = () => {
|
||||
deleteConfirmIntent.value = {
|
||||
type: "space",
|
||||
payload: props.space,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const deleteSpaceConfirmed = async () => {
|
||||
deleting.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
@@ -262,8 +307,49 @@ const deleteSpace = async () => {
|
||||
emit("deleted", props.space);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete space.";
|
||||
throw e;
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteConfirmModal = () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
};
|
||||
|
||||
const confirmDeleteAction = async () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = deleteConfirmIntent.value;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmBusy.value = true;
|
||||
try {
|
||||
if (type === "member") {
|
||||
await removeMemberConfirmed(payload);
|
||||
} else if (type === "space") {
|
||||
await deleteSpaceConfirmed();
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
} finally {
|
||||
deleteConfirmBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
298
frontend/src/components/TaskBoard.vue
Normal file
298
frontend/src/components/TaskBoard.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<section class="task-board">
|
||||
<div class="task-board-header">
|
||||
<div class="task-title-wrap">
|
||||
<h4 class="mb-0">Tasks</h4>
|
||||
<p class="text-muted small mb-0">Track work with ordered statuses.</p>
|
||||
</div>
|
||||
<button v-if="selectedTaskList" class="btn btn-sm btn-outline-secondary" @click="emit('edit-task-list')"><i class="mdi mdi-cog-outline me-1" aria-hidden="true"></i>Edit Task List</button>
|
||||
</div>
|
||||
|
||||
<div class="task-filters">
|
||||
<select v-model="filterStatus" class="form-select" @change="emitFilters">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="status in statuses" :key="status.id" :value="status.id">
|
||||
{{ status.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="filterParent" class="form-select" @change="emitFilters">
|
||||
<option value="">Any parent</option>
|
||||
<option value="root">Top-level only</option>
|
||||
<option v-for="task in parentTaskOptions" :key="task.id" :value="task.id">
|
||||
{{ task.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="task-status-groups">
|
||||
<div v-if="!tasks.length" class="empty-state">No tasks matched these filters.</div>
|
||||
|
||||
<section v-for="section in statusSections" :key="section.status.id" class="status-group">
|
||||
<header class="status-group-header" :style="statusHeaderStyle(section.status)">
|
||||
<div class="status-group-title-wrap">
|
||||
<span class="status-group-dot" :style="{ backgroundColor: section.status.color || '#7c8596' }"></span>
|
||||
<span class="status-group-title">{{ section.status.name }}</span>
|
||||
</div>
|
||||
<span class="status-group-count">{{ section.parentTasks.length }}</span>
|
||||
</header>
|
||||
|
||||
<div v-if="!section.parentTasks.length" class="status-empty">No tasks in this status.</div>
|
||||
|
||||
<div v-for="parentTask in section.parentTasks" :key="parentTask.id" class="task-tree-row level-0">
|
||||
<div
|
||||
class="task-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('select-task', parentTask)"
|
||||
@keydown.enter="emit('select-task', parentTask)"
|
||||
@keydown.space.prevent="emit('select-task', parentTask)"
|
||||
>
|
||||
<span class="tree-toggle" @click.stop="toggleExpanded(parentTask)">
|
||||
<i v-if="hasChildren(parentTask)" :class="isExpanded(parentTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
|
||||
</span>
|
||||
<span class="task-main">
|
||||
<strong>{{ parentTask.title }}</strong>
|
||||
<small class="text-muted">{{ parentTask.description || "No description" }}</small>
|
||||
</span>
|
||||
<div class="task-status-menu" @click.stop>
|
||||
<button type="button" class="status-trigger" :title="`Status: ${statusName(parentTask.status_id)}`" @click="toggleStatusMenu(parentTask.id)">
|
||||
<span class="status-trigger-dot" :style="statusDotStyle(parentTask.status_id)"></span>
|
||||
</button>
|
||||
<div v-if="isStatusMenuOpen(parentTask.id)" class="status-popup">
|
||||
<button
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
type="button"
|
||||
class="status-option"
|
||||
:class="{ selected: parentTask.status_id === status.id }"
|
||||
@click="onTaskStatusChange(parentTask, status.id)"
|
||||
>
|
||||
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-option-label">{{ status.name }}</span>
|
||||
<i v-if="parentTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(parentTask.id)">
|
||||
<div v-for="childTask in childrenFor(parentTask.id)" :key="childTask.id" class="task-tree-row level-1">
|
||||
<div
|
||||
class="task-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('select-task', childTask)"
|
||||
@keydown.enter="emit('select-task', childTask)"
|
||||
@keydown.space.prevent="emit('select-task', childTask)"
|
||||
>
|
||||
<span class="tree-toggle" @click.stop="toggleExpanded(childTask)">
|
||||
<i v-if="hasChildren(childTask)" :class="isExpanded(childTask.id) ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
|
||||
</span>
|
||||
<span class="task-main">
|
||||
<strong>{{ childTask.title }}</strong>
|
||||
<small class="text-muted">{{ childTask.description || "No description" }}</small>
|
||||
</span>
|
||||
<div class="task-status-menu" @click.stop>
|
||||
<button type="button" class="status-trigger" :title="`Status: ${statusName(childTask.status_id)}`" @click="toggleStatusMenu(childTask.id)">
|
||||
<span class="status-trigger-dot" :style="statusDotStyle(childTask.status_id)"></span>
|
||||
</button>
|
||||
<div v-if="isStatusMenuOpen(childTask.id)" class="status-popup">
|
||||
<button
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
type="button"
|
||||
class="status-option"
|
||||
:class="{ selected: childTask.status_id === status.id }"
|
||||
@click="onTaskStatusChange(childTask, status.id)"
|
||||
>
|
||||
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-option-label">{{ status.name }}</span>
|
||||
<i v-if="childTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(childTask.id)">
|
||||
<div v-for="grandchildTask in childrenFor(childTask.id)" :key="grandchildTask.id" class="task-tree-row level-2">
|
||||
<div
|
||||
class="task-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('select-task', grandchildTask)"
|
||||
@keydown.enter="emit('select-task', grandchildTask)"
|
||||
@keydown.space.prevent="emit('select-task', grandchildTask)"
|
||||
>
|
||||
<span class="tree-toggle"></span>
|
||||
<span class="task-main">
|
||||
<strong>{{ grandchildTask.title }}</strong>
|
||||
<small class="text-muted">{{ grandchildTask.description || "No description" }}</small>
|
||||
</span>
|
||||
<div class="task-status-menu" @click.stop>
|
||||
<button type="button" class="status-trigger" :title="`Status: ${statusName(grandchildTask.status_id)}`" @click="toggleStatusMenu(grandchildTask.id)">
|
||||
<span class="status-trigger-dot" :style="statusDotStyle(grandchildTask.status_id)"></span>
|
||||
</button>
|
||||
<div v-if="isStatusMenuOpen(grandchildTask.id)" class="status-popup">
|
||||
<button
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
type="button"
|
||||
class="status-option"
|
||||
:class="{ selected: grandchildTask.status_id === status.id }"
|
||||
@click="onTaskStatusChange(grandchildTask, status.id)"
|
||||
>
|
||||
<span class="status-option-dot" :style="{ borderColor: status.color || '#7c8596' }"></span>
|
||||
<span class="status-option-label">{{ status.name }}</span>
|
||||
<i v-if="grandchildTask.status_id === status.id" class="mdi mdi-check status-option-check" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
tasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedTaskList: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["create-task", "select-task", "filter-change", "update-task-status", "edit-task-list"]);
|
||||
|
||||
const filterStatus = ref("");
|
||||
const filterParent = ref("");
|
||||
const expandedTaskIds = ref({});
|
||||
const openStatusMenuTaskId = ref("");
|
||||
|
||||
const parentTaskOptions = computed(() => props.tasks.filter((task) => task.depth < 2));
|
||||
const tasksById = computed(() => {
|
||||
const map = new Map();
|
||||
for (const task of props.tasks) {
|
||||
map.set(task.id, task);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const tasksByParentId = computed(() => {
|
||||
const map = new Map();
|
||||
for (const task of props.tasks) {
|
||||
if (!task.parent_task_id) {
|
||||
continue;
|
||||
}
|
||||
const existing = map.get(task.parent_task_id) || [];
|
||||
existing.push(task);
|
||||
map.set(task.parent_task_id, existing);
|
||||
}
|
||||
for (const [key, children] of map) {
|
||||
map.set(
|
||||
key,
|
||||
[...children].sort((a, b) => (a.title || "").localeCompare(b.title || "")),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const parentTasks = computed(() => props.tasks.filter((task) => !task.parent_task_id || !tasksById.value.has(task.parent_task_id)));
|
||||
|
||||
const statusSections = computed(() =>
|
||||
props.statuses.map((status) => ({
|
||||
status,
|
||||
parentTasks: parentTasks.value.filter((task) => task.status_id === status.id),
|
||||
})),
|
||||
);
|
||||
|
||||
const emitFilters = () => {
|
||||
emit("filter-change", {
|
||||
statusId: filterStatus.value || null,
|
||||
parentTaskId: filterParent.value || null,
|
||||
});
|
||||
};
|
||||
|
||||
const statusHeaderStyle = (status) => {
|
||||
const color = status.color || "#7c8596";
|
||||
return {
|
||||
borderColor: color,
|
||||
};
|
||||
};
|
||||
|
||||
const statusColor = (statusId) => props.statuses.find((status) => status.id === statusId)?.color || "#7c8596";
|
||||
|
||||
const statusName = (statusId) => props.statuses.find((status) => status.id === statusId)?.name || "Unknown";
|
||||
|
||||
const statusDotStyle = (statusId) => ({
|
||||
backgroundColor: statusColor(statusId),
|
||||
});
|
||||
|
||||
const isStatusMenuOpen = (taskId) => openStatusMenuTaskId.value === taskId;
|
||||
|
||||
const toggleStatusMenu = (taskId) => {
|
||||
openStatusMenuTaskId.value = openStatusMenuTaskId.value === taskId ? "" : taskId;
|
||||
};
|
||||
|
||||
const closeStatusMenu = () => {
|
||||
openStatusMenuTaskId.value = "";
|
||||
};
|
||||
|
||||
const onDocumentClick = () => {
|
||||
closeStatusMenu();
|
||||
};
|
||||
|
||||
const childrenFor = (parentId) => tasksByParentId.value.get(parentId) || [];
|
||||
|
||||
const hasChildren = (task) => childrenFor(task.id).length > 0;
|
||||
|
||||
const isExpanded = (taskId) => !!expandedTaskIds.value[taskId];
|
||||
|
||||
const toggleExpanded = (task) => {
|
||||
if (!hasChildren(task)) {
|
||||
return;
|
||||
}
|
||||
|
||||
expandedTaskIds.value = {
|
||||
...expandedTaskIds.value,
|
||||
[task.id]: !expandedTaskIds.value[task.id],
|
||||
};
|
||||
};
|
||||
|
||||
const onTaskStatusChange = (task, statusId) => {
|
||||
if (!task?.id || !statusId || task.status_id === statusId) {
|
||||
closeStatusMenu();
|
||||
return;
|
||||
}
|
||||
emit("update-task-status", {
|
||||
taskId: task.id,
|
||||
currentStatusId: task.status_id,
|
||||
targetStatusId: statusId,
|
||||
});
|
||||
closeStatusMenu();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskBoard.css"></style>
|
||||
137
frontend/src/components/TaskDetailModal.vue
Normal file
137
frontend/src/components/TaskDetailModal.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog" aria-modal="true" @click.self="emit('close')">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ localTask.id ? "Task Detail" : "Create Task" }}</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="emit('close')"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label class="form-label">Title</label>
|
||||
<input v-model="localTask.title" class="form-control" type="text" maxlength="255" />
|
||||
|
||||
<label class="form-label mt-3">Description</label>
|
||||
<textarea v-model="localTask.description" class="form-control" rows="5" maxlength="2000"></textarea>
|
||||
|
||||
<label class="form-label mt-3">Parent Task</label>
|
||||
<select v-model="localTask.parent_task_id" class="form-select">
|
||||
<option value="">No parent (top level)</option>
|
||||
<option v-for="option in parentTaskOptions" :key="option.id" :value="option.id">{{ option.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<label class="form-label">Status</label>
|
||||
<select v-model="localTask.status_id" class="form-select">
|
||||
<option v-for="status in statuses" :key="status.id" :value="status.id">{{ status.name }}</option>
|
||||
</select>
|
||||
|
||||
<div class="status-progress mt-3">
|
||||
<div v-for="status in statuses" :key="status.id" class="progress-step" :class="stepClass(status)">
|
||||
<span class="dot" :style="{ borderColor: status.color || '#7c8596', backgroundColor: isReached(status) ? status.color || '#7c8596' : 'transparent' }"></span>
|
||||
<span>{{ status.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'backward' })">Revert</button>
|
||||
<button class="btn btn-outline-primary" :disabled="!localTask.id" @click="emit('transition', { taskId: localTask.id, direction: 'forward' })">Advance</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Subtasks</h6>
|
||||
<div v-if="!subtasks.length" class="text-muted small">No subtasks yet.</div>
|
||||
<button v-for="subtask in subtasks" :key="subtask.id" class="subtask-row" @click="emit('open-task', subtask)">
|
||||
<span>{{ subtask.title }}</span>
|
||||
<small>L{{ subtask.depth + 1 }}</small>
|
||||
</button>
|
||||
<button v-if="canAddSubtask" class="btn btn-sm btn-outline-primary mt-2" @click="emit('create-subtask', localTask)">Add Subtask</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="emit('close')">Close</button>
|
||||
<button v-if="localTask.id" type="button" class="btn btn-danger" @click="emit('delete-task', localTask)">Delete</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveTask">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
statuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
parentTaskOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
subtasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save-task", "delete-task", "transition", "create-subtask", "open-task"]);
|
||||
|
||||
const localTask = ref({});
|
||||
|
||||
watch(
|
||||
() => props.task,
|
||||
(value) => {
|
||||
localTask.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
task_list_id: "",
|
||||
status_id: props.statuses[0]?.id || "",
|
||||
parent_task_id: "",
|
||||
note_links: [],
|
||||
...value,
|
||||
task_list_id: value?.task_list_id || "",
|
||||
parent_task_id: value?.parent_task_id || "",
|
||||
note_links: value?.note_links || [],
|
||||
};
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const canAddSubtask = computed(() => !!localTask.value.id && (localTask.value.depth ?? 0) < 2);
|
||||
|
||||
const isReached = (status) => {
|
||||
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||
return status.order <= current;
|
||||
};
|
||||
|
||||
const stepClass = (status) => {
|
||||
const current = props.statuses.find((item) => item.id === localTask.value.status_id)?.order ?? 0;
|
||||
return {
|
||||
current: status.order === current,
|
||||
done: status.order < current,
|
||||
};
|
||||
};
|
||||
|
||||
const saveTask = () => {
|
||||
emit("save-task", {
|
||||
...localTask.value,
|
||||
task_list_id: localTask.value.task_list_id || null,
|
||||
parent_task_id: localTask.value.parent_task_id || null,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/TaskDetailModal.css"></style>
|
||||
80
frontend/src/components/WorkspaceList.vue
Normal file
80
frontend/src/components/WorkspaceList.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="workspace-list" :class="{ 'workspace-list--list': viewMode === 'list' }">
|
||||
<div v-if="items.length === 0" class="empty-workspace-state" role="status" aria-live="polite">
|
||||
<i class="mdi mdi-view-grid-outline empty-workspace-icon" aria-hidden="true"></i>
|
||||
<h3 class="empty-workspace-title">Nothing Here Yet</h3>
|
||||
<p class="empty-workspace-message">This view has no notes or task lists yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="item in items" :key="`${item.kind}-${item.id}`" class="content-card" :class="contentCardClass(item)" @click="openItem(item)">
|
||||
<h5 class="content-title">
|
||||
<template v-if="item.kind === 'note'">
|
||||
<i v-if="item.is_pinned" class="mdi mdi-pin pin-icon" aria-hidden="true"></i>
|
||||
<i v-else-if="item.is_favorite || item.is_featured" class="mdi mdi-star featured-icon" aria-hidden="true"></i>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="mdi mdi-format-list-checkbox list-icon" aria-hidden="true"></i>
|
||||
{{ item.name }}
|
||||
</template>
|
||||
</h5>
|
||||
<p class="content-preview">{{ getDescription(item) }}</p>
|
||||
<small class="text-muted">Updated: {{ formatDate(item.updated_at) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="canLoadMore" class="list-footer">
|
||||
<button class="btn btn-outline-secondary" :disabled="isLoadingMore" @click.stop="emit('loadMore')">
|
||||
{{ isLoadingMore ? "Loading..." : "Load more" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canLoadMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["selectNote", "selectTaskList", "loadMore"]);
|
||||
|
||||
const openItem = (item) => {
|
||||
if (item.kind === "task-list") {
|
||||
emit("selectTaskList", item);
|
||||
return;
|
||||
}
|
||||
emit("selectNote", item);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => new Date(dateString).toLocaleDateString();
|
||||
|
||||
const getDescription = (item) => {
|
||||
const description = (item?.description || "").trim();
|
||||
if (description) {
|
||||
return description;
|
||||
}
|
||||
return item.kind === "task-list" ? "Open this task list to manage tasks." : "No description";
|
||||
};
|
||||
|
||||
const contentCardClass = (item) => ({
|
||||
"is-pinned": item.kind === "note" && item.is_pinned,
|
||||
"is-featured": item.kind === "note" && (item.is_favorite || item.is_featured),
|
||||
"is-task-list": item.kind === "task-list",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/scoped/components/WorkspaceList.css"></style>
|
||||
145
frontend/src/components/app/AppModalHost.vue
Normal file
145
frontend/src/components/app/AppModalHost.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<CreateSpaceModal v-if="showCreateSpaceModal" @close="emit('close-create-space')" @create="emit('create-space', $event)" />
|
||||
<CreateCategoryModal
|
||||
v-if="showCreateCategoryModal"
|
||||
:category="editingCategory"
|
||||
:parent-options="categoryParentOptions"
|
||||
:parent-id="categoryModalParentId"
|
||||
@close="emit('close-create-category')"
|
||||
@submit="emit('submit-category', $event)"
|
||||
/>
|
||||
<CreateNoteModal
|
||||
v-if="showCreateNoteModal"
|
||||
:category-options="categoryOptions"
|
||||
:default-category-id="selectedCategoryId"
|
||||
@close="emit('close-create-note')"
|
||||
@create="emit('create-note', $event)"
|
||||
/>
|
||||
<CreateTaskListModal
|
||||
v-if="showCreateTaskListModal"
|
||||
:category-options="categoryOptions"
|
||||
:default-category-id="selectedCategoryId"
|
||||
@close="emit('close-create-task-list')"
|
||||
@create="emit('create-task-list', $event)"
|
||||
/>
|
||||
<SpaceSettingsModal
|
||||
v-if="showSpaceSettingsModal && currentSpace && canManageSpaceSettings"
|
||||
:space="currentSpace"
|
||||
@close="emit('close-space-settings')"
|
||||
@saved="emit('saved-space', $event)"
|
||||
@deleted="emit('deleted-space', $event)"
|
||||
/>
|
||||
<TaskDetailModal
|
||||
v-if="showTaskModal"
|
||||
:task="taskModalDraft || {}"
|
||||
:statuses="taskStatuses"
|
||||
:parent-task-options="taskParentOptions"
|
||||
:subtasks="taskDetailSubtasks"
|
||||
@close="emit('close-task-modal')"
|
||||
@save-task="emit('save-task', $event)"
|
||||
@delete-task="emit('delete-task', $event)"
|
||||
@transition="emit('transition-task', $event)"
|
||||
@create-subtask="emit('create-subtask', $event)"
|
||||
@open-task="emit('open-task', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CreateSpaceModal from "../CreateSpaceModal.vue";
|
||||
import CreateCategoryModal from "../CreateCategoryModal.vue";
|
||||
import CreateNoteModal from "../CreateNoteModal.vue";
|
||||
import CreateTaskListModal from "../CreateTaskListModal.vue";
|
||||
import SpaceSettingsModal from "../SpaceSettingsModal.vue";
|
||||
import TaskDetailModal from "../TaskDetailModal.vue";
|
||||
|
||||
defineProps({
|
||||
showCreateSpaceModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showCreateCategoryModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editingCategory: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
categoryParentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
categoryModalParentId: {
|
||||
type: [String, Number, null],
|
||||
default: null,
|
||||
},
|
||||
showCreateNoteModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedCategoryId: {
|
||||
type: [String, Number, null],
|
||||
default: null,
|
||||
},
|
||||
showCreateTaskListModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSpaceSettingsModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentSpace: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
canManageSpaceSettings: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showTaskModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
taskModalDraft: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
taskStatuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
taskParentOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
taskDetailSubtasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"close-create-space",
|
||||
"create-space",
|
||||
"close-create-category",
|
||||
"submit-category",
|
||||
"close-create-note",
|
||||
"create-note",
|
||||
"close-create-task-list",
|
||||
"create-task-list",
|
||||
"close-space-settings",
|
||||
"saved-space",
|
||||
"deleted-space",
|
||||
"close-task-modal",
|
||||
"save-task",
|
||||
"delete-task",
|
||||
"transition-task",
|
||||
"create-subtask",
|
||||
"open-task",
|
||||
]);
|
||||
</script>
|
||||
160
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
160
frontend/src/components/app/AppWorkspaceContent.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="content p-4">
|
||||
<TaskBoard
|
||||
v-if="activeView === 'tasks'"
|
||||
:tasks="tasks"
|
||||
:statuses="taskStatuses"
|
||||
:selected-task-list="selectedTaskList"
|
||||
@select-task="emit('select-task', $event)"
|
||||
@filter-change="emit('filter-change', $event)"
|
||||
@update-task-status="emit('update-task-status', $event)"
|
||||
@edit-task-list="emit('edit-task-list')"
|
||||
/>
|
||||
<SearchResultsPage
|
||||
v-else-if="isSearchRoute"
|
||||
:items="searchItems"
|
||||
:query="searchQuery"
|
||||
:current-page="searchPage"
|
||||
:page-size="searchPageSize"
|
||||
:view-mode="noteViewMode"
|
||||
@select-note="emit('select-note', $event)"
|
||||
@select-task-list="emit('select-task-list', $event)"
|
||||
@page-change="emit('page-change', $event)"
|
||||
/>
|
||||
<NoteEditor
|
||||
v-else-if="selectedNote && isEditingNote"
|
||||
:note="selectedNote"
|
||||
:category-options="categoryOptions"
|
||||
:can-delete="canDeleteNotes"
|
||||
:space-id="currentSpaceId"
|
||||
@save="emit('save-note', $event)"
|
||||
@delete="emit('delete-note', $event)"
|
||||
@cancel="emit('cancel-edit-note')"
|
||||
@open-linked-task="emit('open-linked-task', $event)"
|
||||
/>
|
||||
<NoteViewer
|
||||
v-else-if="selectedNote"
|
||||
:note="selectedNote"
|
||||
:category-options="categoryOptions"
|
||||
:space-id="currentSpaceId"
|
||||
:linked-tasks="linkedTasksForSelectedNote"
|
||||
@open-linked-task="emit('open-linked-task', $event)"
|
||||
/>
|
||||
<WorkspaceList
|
||||
v-else
|
||||
:items="displayedItems"
|
||||
:can-load-more="canLoadMoreMainNotes"
|
||||
:is-loading-more="isLoadingMoreMainNotes"
|
||||
:view-mode="noteViewMode"
|
||||
@select-note="emit('select-note', $event)"
|
||||
@select-task-list="emit('select-task-list', $event)"
|
||||
@load-more="emit('load-more')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TaskBoard from "../TaskBoard.vue";
|
||||
import SearchResultsPage from "../SearchResultsPage.vue";
|
||||
import NoteEditor from "../NoteEditor.vue";
|
||||
import NoteViewer from "../NoteViewer.vue";
|
||||
import WorkspaceList from "../WorkspaceList.vue";
|
||||
|
||||
defineProps({
|
||||
activeView: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
tasks: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
taskStatuses: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedTaskList: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
canDeleteTasks: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSearchRoute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
searchPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
searchPageSize: {
|
||||
type: Number,
|
||||
default: 12,
|
||||
},
|
||||
noteViewMode: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
},
|
||||
selectedNote: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isEditingNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
categoryOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canDeleteNotes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentSpaceId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
linkedTasksForSelectedNote: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
displayedItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
canLoadMoreMainNotes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoadingMoreMainNotes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"select-task",
|
||||
"filter-change",
|
||||
"update-task-status",
|
||||
"edit-task-list",
|
||||
"select-note",
|
||||
"select-task-list",
|
||||
"page-change",
|
||||
"save-note",
|
||||
"delete-note",
|
||||
"cancel-edit-note",
|
||||
"open-linked-task",
|
||||
"load-more",
|
||||
]);
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@ import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "@mdi/font/css/materialdesignicons.min.css";
|
||||
import "highlight.js/styles/github-dark.min.css";
|
||||
import "./assets/styles/main.css";
|
||||
import "./assets/styles/shared/danger-zone.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="user-row-actions">
|
||||
<div class="d-flex gap-2 user-actions-stack">
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openEditUserModal(u)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="deleteUser(u)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="requestDeleteUser(u)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @click="openEditGroupModal(group)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="deleteGroup(group)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-danger" :disabled="group.is_system" @click="requestDeleteGroup(group)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +153,12 @@
|
||||
<div v-else class="list-group">
|
||||
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i
|
||||
class="mdi"
|
||||
:class="provider.is_active ? 'mdi-check-circle text-success' : 'mdi-close-circle text-secondary'"
|
||||
:title="provider.is_active ? 'Provider enabled' : 'Provider disabled'"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="fw-semibold">{{ provider.name }}</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@@ -277,8 +283,19 @@
|
||||
:mode="providerModalMode"
|
||||
:provider="selectedProvider"
|
||||
:submitting="submittingProviderModal"
|
||||
:deleting="deletingProviderModal"
|
||||
@close="closeProviderModal"
|
||||
@submit="submitProviderModal"
|
||||
@delete="requestDeleteProvider"
|
||||
/>
|
||||
|
||||
<ConfirmActionModal
|
||||
:visible="showDeleteConfirmModal"
|
||||
:title="deleteConfirmTitle"
|
||||
:message="deleteConfirmMessage"
|
||||
:busy="deleteConfirmBusy"
|
||||
@close="closeDeleteConfirmModal"
|
||||
@confirm="confirmDeleteAction"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -290,6 +307,7 @@ import AdminSpaceModal from "../components/AdminSpaceModal.vue";
|
||||
import AdminGroupModal from "../components/AdminGroupModal.vue";
|
||||
import AdminUserModal from "../components/AdminUserModal.vue";
|
||||
import AdminProviderModal from "../components/AdminProviderModal.vue";
|
||||
import ConfirmActionModal from "../components/ConfirmActionModal.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const activeTab = ref("users");
|
||||
@@ -335,6 +353,13 @@ const showProviderModal = ref(false);
|
||||
const providerModalMode = ref("create");
|
||||
const selectedProvider = ref(null);
|
||||
const submittingProviderModal = ref(false);
|
||||
const deletingProviderModal = ref(false);
|
||||
const showDeleteConfirmModal = ref(false);
|
||||
const deleteConfirmBusy = ref(false);
|
||||
const deleteConfirmIntent = ref({
|
||||
type: "",
|
||||
payload: null,
|
||||
});
|
||||
|
||||
const loadingFeatureFlags = ref(false);
|
||||
const savingFeatureFlags = ref(false);
|
||||
@@ -356,6 +381,47 @@ const clearMessages = () => {
|
||||
successMessage.value = "";
|
||||
};
|
||||
|
||||
const deleteConfirmTitle = computed(() => {
|
||||
if (deleteConfirmIntent.value.type === "user") {
|
||||
return "Delete User";
|
||||
}
|
||||
if (deleteConfirmIntent.value.type === "group") {
|
||||
return "Delete Group";
|
||||
}
|
||||
if (deleteConfirmIntent.value.type === "provider") {
|
||||
return "Delete Identity Provider";
|
||||
}
|
||||
return "Confirm Deletion";
|
||||
});
|
||||
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
if (deleteConfirmIntent.value.type === "user") {
|
||||
const username = deleteConfirmIntent.value.payload?.username || "this user";
|
||||
return `Delete user "${username}"? This action cannot be undone.`;
|
||||
}
|
||||
if (deleteConfirmIntent.value.type === "group") {
|
||||
const name = deleteConfirmIntent.value.payload?.name || "this group";
|
||||
return `Delete group "${name}"? This action cannot be undone.`;
|
||||
}
|
||||
if (deleteConfirmIntent.value.type === "provider") {
|
||||
const name = deleteConfirmIntent.value.payload?.name || "this identity provider";
|
||||
return `Delete identity provider "${name}"? This action cannot be undone.`;
|
||||
}
|
||||
return "Are you sure you want to continue?";
|
||||
});
|
||||
|
||||
const closeDeleteConfirmModal = () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleDateString();
|
||||
@@ -429,8 +495,20 @@ const submitUserModal = async ({ group_ids }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteUser = (user) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmIntent.value = {
|
||||
type: "user",
|
||||
payload: user,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const deleteUser = async (user) => {
|
||||
if (!confirm(`Delete user "${user.username}"? This action cannot be undone.`)) {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,7 +599,7 @@ const deleteGroup = async (group) => {
|
||||
if (group.is_system) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete group "${group.name}"? This action cannot be undone.`)) {
|
||||
if (!group?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -532,9 +610,22 @@ const deleteGroup = async (group) => {
|
||||
await Promise.all([loadGroups(), loadUsers()]);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete group.";
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeleteGroup = (group) => {
|
||||
if (!group?.id || group.is_system) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmIntent.value = {
|
||||
type: "group",
|
||||
payload: group,
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const loadSpaces = async () => {
|
||||
loadingSpaces.value = true;
|
||||
clearMessages();
|
||||
@@ -584,6 +675,7 @@ const openEditProviderModal = (provider) => {
|
||||
const closeProviderModal = () => {
|
||||
showProviderModal.value = false;
|
||||
submittingProviderModal.value = false;
|
||||
deletingProviderModal.value = false;
|
||||
selectedProvider.value = null;
|
||||
};
|
||||
|
||||
@@ -612,7 +704,7 @@ const loadProviders = async () => {
|
||||
loadingProviders.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
const res = await apiClient.get("/api/v1/auth/providers");
|
||||
const res = await apiClient.get("/api/v1/admin/auth/providers");
|
||||
providers.value = res.data.providers || [];
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to load providers.";
|
||||
@@ -621,18 +713,66 @@ const loadProviders = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProvider = async (provider) => {
|
||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||
const requestDeleteProvider = (provider) => {
|
||||
if (!provider?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeProviderModal();
|
||||
deleteConfirmIntent.value = {
|
||||
type: "provider",
|
||||
payload: { ...provider },
|
||||
};
|
||||
showDeleteConfirmModal.value = true;
|
||||
};
|
||||
|
||||
const deleteProviderFromModal = async (provider) => {
|
||||
if (!provider?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingProviderModal.value = true;
|
||||
clearMessages();
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
||||
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
||||
successMessage.value = `Provider "${provider.name}" deleted.`;
|
||||
closeProviderModal();
|
||||
} catch (e) {
|
||||
error.value = e.response?.data || "Failed to delete provider.";
|
||||
throw e;
|
||||
} finally {
|
||||
deletingProviderModal.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteAction = async () => {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = deleteConfirmIntent.value;
|
||||
if (!type || !payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmBusy.value = true;
|
||||
try {
|
||||
if (type === "user") {
|
||||
await deleteUser(payload);
|
||||
} else if (type === "group") {
|
||||
await deleteGroup(payload);
|
||||
} else if (type === "provider") {
|
||||
await deleteProviderFromModal(payload);
|
||||
}
|
||||
|
||||
showDeleteConfirmModal.value = false;
|
||||
deleteConfirmIntent.value = {
|
||||
type: "",
|
||||
payload: null,
|
||||
};
|
||||
} finally {
|
||||
deleteConfirmBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -700,205 +840,4 @@ onMounted(async () => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link {
|
||||
border-radius: 0.6rem;
|
||||
color: #495057;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link:hover {
|
||||
background: #eef2f7;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.admin-nav .nav-link.active {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.users-list .list-group-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.user-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.user-meta-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #6c757d;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.user-meta-value {
|
||||
color: #495057;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.user-meta-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-meta-item-groups {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.admin-shell {
|
||||
display: block;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(82vw, 320px);
|
||||
z-index: 1410;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.admin-sidebar-inner {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.user-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.user-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-row-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-actions-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../assets/styles/scoped/pages/Admin.css"></style>
|
||||
|
||||
1910
frontend/src/pages/Dashboard.vue
Normal file
1910
frontend/src/pages/Dashboard.vue
Normal file
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>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/pages/Login.css"></style>
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(53, 84, 209, 0.12);
|
||||
color: #2f4ac1;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 2.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 0.65rem;
|
||||
min-height: 48px;
|
||||
border-color: #d6dbe4;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
min-height: 48px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-provider-btn {
|
||||
min-height: 48px;
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.oauth-divider::before,
|
||||
.oauth-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.oauth-divider span {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.auth-switch-link {
|
||||
color: #4b5565;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.login-page {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border-radius: 14px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -322,74 +322,7 @@ watch(
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.public-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/pages/PublicSpace.css"></style>
|
||||
|
||||
.public-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.public-sidebar {
|
||||
width: 280px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: #dbe4ff;
|
||||
border-color: #748ffc;
|
||||
color: #364fc7;
|
||||
}
|
||||
|
||||
.note-item.is-featured {
|
||||
background: #fff4e6;
|
||||
border-color: #ffd8a8;
|
||||
}
|
||||
|
||||
.note-item.is-featured:hover {
|
||||
background: #ffe8cc;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.public-sidebar-backdrop {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1090;
|
||||
}
|
||||
|
||||
.public-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1095;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.public-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,96 +110,7 @@ onMounted(async () => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
background: radial-gradient(circle at 10% 10%, rgba(255, 255, 255, 0.2), transparent 45%), linear-gradient(135deg, #3554d1 0%, #4f46a5 100%);
|
||||
}
|
||||
<style scoped src="../assets/styles/scoped/pages/Register.css"></style>
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 22px 48px rgba(16, 24, 40, 0.22);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(53, 84, 209, 0.12);
|
||||
color: #2f4ac1;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-size: 2.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
text-align: center;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2f3237;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 0.65rem;
|
||||
min-height: 48px;
|
||||
border-color: #d6dbe4;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
min-height: 48px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-switch-link {
|
||||
color: #4b5565;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.register-page {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
border-radius: 14px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user