Compare commits

..

8 commits

82 changed files with 8026 additions and 128 deletions

View file

@ -17,6 +17,16 @@ GRAFANA_PASSWORD=change-this-password-in-production
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0
# MinIO Configuration (Object Storage)
# MinIO server stays hidden - Flask proxies all requests
# MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication)
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_USE_SSL=false
# Note: Thumbnails are served via /api/files/:id/proxy-thumbnail (no auth)
# Full files require JWT authentication via /api/files/:id/download
# Optional: External Services # Optional: External Services
# REDIS_URL=redis://localhost:6379/0 # REDIS_URL=redis://localhost:6379/0
# SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com

4
.gitignore vendored
View file

@ -83,4 +83,6 @@ htmlcov/
celerybeat-schedule celerybeat-schedule
backend/app/static backend/app/static
.clinerules

View file

@ -0,0 +1,338 @@
# Epic & Wiki Feature Implementation Summary
## Overview
This document summarizes the implementation of the Epic and Wiki features for the Kanban application.
## What Was Implemented
### Backend (Flask)
#### 1. Database Models
**Epic Model** (`backend/app/models/epic.py`)
- Rich text content support (JSON for Slate.js)
- Hierarchical structure (parent_epic_id for nesting)
- Color coding
- Position ordering
- Board-level scoping
- Soft delete support (closed field)
- Depth limit for hierarchy control
**Wiki Model** (`backend/app/models/wiki.py`)
- Rich text content (JSON for Slate.js)
- URL-friendly slugs
- Summary field
- Categories for organization
- Tags support
- Created by / Updated by tracking
- Board-level scoping
**Card Model Update** (`backend/app/models/card.py`)
- Added `epic_id` foreign key to link cards to epics
**Association Table** (`wiki_entity_links`)
- Many-to-many relationship between Wikis and entity types
- Supports linking Wikis to Cards, Epics, and future entity types
#### 2. Database Migration
- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py`
- Adds all new tables and relationships
### Frontend (React/TypeScript)
#### 1. TypeScript Types (`frontend/src/types/epic.ts`)
```typescript
- Epic interface
- Wiki interface
- CreateEpicRequest / UpdateEpicRequest
- CreateWikiRequest / UpdateWikiRequest
- WikiEntityLink interface
```
#### 2. Components
**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`)
- Slate.js-based rich text editor
- Editable with proper styling
- Dark mode support
- Placeholder support
- Read-only mode option
**RichTextContent** (`frontend/src/components/RichTextContent.tsx`)
- Read-only renderer for Slate.js content
- Supports paragraphs, lists, blockquotes
- Text formatting (bold, italic, underline, code)
- Dark mode styling
#### 3. Custom Hook
**useEpics** (`frontend/src/hooks/useEpics.ts`)
- Fetch all epics for a board
- Create new epic
- Update existing epic
- Delete epic
- Integrated with global loader and toast notifications
- Error handling with user-friendly messages
#### 4. API Integration
Updated `useApi` hook with epic methods:
```typescript
- getEpics(boardId)
- createEpic(boardId, epicData)
- getEpic(epicId)
- updateEpic(epicId, epicData)
- deleteEpic(epicId)
- addEpicToCard(cardId, epicId)
- removeEpicFromCard(cardId, epicId)
```
## Key Design Decisions
### 1. Epic Hierarchy
- **Decision**: Self-referential foreign key (`parent_epic_id`)
- **Rationale**: Allows flexible nesting of epics with arbitrary depth
- **Feature**: `depth_limit` field to control maximum nesting depth
### 2. Rich Text Storage
- **Decision**: Store as JSON (compatible with Slate.js)
- **Rationale**:
- Slate.js natively uses JSON format
- No serialization/deserialization overhead
- Easy to query and modify content structure
### 3. Wiki vs Document Naming
- **Decision**: Use "Wiki" instead of "Document"
- **Rationale**: Avoids confusion with file attachments
- **Meaning**: Wiki implies knowledge base / documentation repository
### 4. Entity Linking Strategy
- **Decision**: Polymorphic association table (`wiki_entity_links`)
- **Rationale**:
- Single table handles all entity types
- Easy to add new entity types in future
- Avoids circular imports and complex schema changes
### 5. Epic-Card Relationship
- **Decision**: One-way reference (Card → Epic)
- **Rationale**:
- Simpler than many-to-many (epics contain cards)
- Cards can belong to one epic at a time
- Consistent with Jira's parent/child pattern
## Architecture
### Backend Data Flow
```
Board (1) ───────┬─────── (1) Epic
│ │
│ │─── (0..*) Card (via epic_id)
└───────────────┬─────── (0..*) Wiki
└─── wiki_entity_links ───┬── Card
├── Epic
└── (future entities)
```
### Frontend Component Structure
```
BoardEpics (page)
├── EpicList
│ └── EpicCard
└── CreateEpicModal
├── EpicForm
│ ├── EpicNameInput
│ ├── EpicDescriptionInput
│ └── RichTextEditor (content)
└── ColorPicker
```
## Next Steps
### Immediate Actions Required
1. **Run Database Migration**
```bash
cd backend
flask db upgrade
```
2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`)
- CRUD operations for Epics
- Epic-Card linking endpoints
- Wiki CRUD operations
- Wiki-Entity linking endpoints
3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`)
- Marshmallow schemas for serialization
- Input validation
4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`)
- Implement epic list view
- Add create epic modal
- Add epic detail view
- Include RichTextEditor for epic content
5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`)
- Add epic selector dropdown
- Display linked epic information
- Add wiki links
### Future Enhancements
1. **Wiki Features**
- Wiki page with sidebar navigation
- Markdown export/import
- Wiki search functionality
- Version history
2. **Epic Features**
- Epic progress tracking (cards completed vs total)
- Epic metrics dashboard
- Epic drag-and-drop reordering
- Epic timeline view
3. **UI/UX Improvements**
- Epic color picker with presets
- Epic hierarchy tree view
- Inline card epic assignment
- Epic templates
4. **Analytics**
- Epic completion rate
- Time spent on epic
- Epic size distribution
- Wiki usage statistics
## Database Schema
### Epic Table
```sql
CREATE TABLE epics (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
content JSONB,
color VARCHAR(7),
closed BOOLEAN DEFAULT FALSE,
pos FLOAT,
depth_limit INTEGER,
board_id INTEGER REFERENCES boards(id),
parent_epic_id INTEGER REFERENCES epics(id),
date_last_activity TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Table
```sql
CREATE TABLE wikis (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE,
content JSONB NOT NULL,
summary TEXT,
category VARCHAR(100),
board_id INTEGER REFERENCES boards(id),
created_by INTEGER REFERENCES users(id),
updated_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Wiki Entity Links Table
```sql
CREATE TABLE wiki_entity_links (
wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
linked_by INTEGER REFERENCES users(id),
PRIMARY KEY (wiki_id, entity_type, entity_id)
);
```
## API Endpoints (To Be Implemented)
### Epic Endpoints
```
GET /api/boards/<board_id>/epics
POST /api/boards/<board_id>/epics
GET /api/epics/<epic_id>
PUT /api/epics/<epic_id>
DELETE /api/epics/<epic_id>
```
### Epic-Card Linking
```
POST /api/cards/<card_id>/epics
DELETE /api/cards/<card_id>/epics/<epic_id>
```
### Wiki Endpoints
```
GET /api/boards/<board_id>/wikis
POST /api/boards/<board_id>/wikis
GET /api/wikis/<wiki_id>
PUT /api/wikis/<wiki_id>
DELETE /api/wikis/<wiki_id>
GET /api/wikis/<wiki_id>/content
```
### Wiki-Entity Linking
```
POST /api/wikis/<wiki_id>/links
DELETE /api/wikis/<wiki_id>/links/<entity_type>/<entity_id>
```
## Testing Considerations
### Unit Tests
- Epic model creation and relationships
- Wiki model creation and relationships
- Epic hierarchy validation
- Wiki slug generation
- Rich text content serialization
### Integration Tests
- Epic CRUD operations
- Wiki CRUD operations
- Epic-Card linking
- Wiki-Entity linking
- Epic hierarchy operations
### Frontend Tests
- RichTextEditor component
- RichTextContent component
- useEpics hook
- Epic list rendering
- Epic creation form
## Notes
- **Slate.js Content**: Content is stored as raw Slate.js JSON format, which includes element types, text nodes, and formatting information
- **Hierarchical Epics**: While supported, UI for nesting epics is not yet implemented
- **Wiki Slugs**: Slugs should be auto-generated from wiki names and checked for uniqueness
- **Color Codes**: Epic colors should be valid hex codes (e.g., "#ef4444")
- **Position**: Epic positions use float values for flexible reordering (similar to Trello)
## Related Files
### Backend
- `backend/app/models/epic.py` - Epic model
- `backend/app/models/wiki.py` - Wiki model
- `backend/app/models/card.py` - Updated Card model
- `backend/app/models/__init__.py` - Model imports
- `backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py` - Migration
### Frontend
- `frontend/src/types/epic.ts` - TypeScript interfaces
- `frontend/src/components/RichTextEditor.tsx` - Editor component
- `frontend/src/components/RichTextContent.tsx` - Content renderer
- `frontend/src/hooks/useEpics.ts` - Epic custom hook
- `frontend/src/hooks/useApi.ts` - Updated API methods

238
EPIC_WIKI_MODELS_SUMMARY.md Normal file
View file

@ -0,0 +1,238 @@
# Epic and Wiki Models - Implementation Summary
## Overview
This document summarizes the implementation of Epic and Wiki models for the Kanban application.
## What Was Implemented
### 1. Epic Model (`backend/app/models/epic.py`)
- Tracks large features across multiple cards
- Hierarchical structure (parent-child epics)
- Rich text content support (Slate.js JSON)
- Color-coded badges for visual identification
- Metrics tracking (card count)
**Key Fields:**
- `id`, `name`, `description`, `content` (JSONB)
- `color` (hex code for epic badge)
- `closed`, `pos`, `depth_limit` (default 5)
- `board_id`, `parent_epic_id`
- `date_last_activity`, `created_at`, `updated_at`
- `metrics` (JSONB - stores card_count)
**Relationships:**
- Board: One-to-many (Board has many Epics)
- Cards: One-to-many (Epic has many Cards)
- Parent Epic: Self-referential (hierarchical)
- File Attachments: Polymorphic (like Cards)
### 2. Wiki Model (`backend/app/models/wiki.py`)
- Reusable rich text content within a board
- Board-scoped (not global across all boards)
- Polymorphic links to entities (Card, Epic, etc.)
- Categorization and tagging support
**Key Fields:**
- `id`, `name`, `slug` (URL-friendly)
- `content` (JSONB - rich text)
- `summary`, `category`, `tags` (JSONB)
- `board_id`, `created_by`, `updated_by`
- `created_at`, `updated_at`
**Relationships:**
- Board: One-to-many (Board has many Wikis)
- Entities: Many-to-many polymorphic (via wiki_entity_links)
### 3. Card Model Updates (`backend/app/models/card.py`)
- Added `epic_id` foreign key (nullable)
- Updated `to_dict()` to include `epic_id`
- One-to-one relationship: Card belongs to one Epic
### 4. Association Table (`wiki_entity_links`)
- Polymorphic many-to-many table
- Links wikis to any entity type
- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by`
## Database Schema
### New Tables
1. **epics** - Epic records
2. **wikis** - Wiki content records
3. **wiki_entity_links** - Wiki-to-entity associations
### Modified Tables
1. **cards** - Added `epic_id` foreign key
### Relationships Diagram
```
Board (1) ----< (N) Epic
Epic (1) ----< (N) Card (each card belongs to one epic)
Epic (1) ----< (N) Epic (parent-child hierarchy)
Board (1) ----< (N) Wiki
Wiki (M) ----> (M) Entity (polymorphic: Card, Epic)
```
## Database Migration
**File:** `backend/migrations/versions/add_epic_and_wiki_models.py`
**Creates:**
- `epics` table with indexes on `board_id`, `closed`, `name`
- `wikis` table with indexes on `board_id`, `name`, `slug`
- `wiki_entity_links` table with composite primary key
- `epic_id` column in `cards` table with index and foreign key
**To apply migration:**
```bash
cd backend
flask db upgrade
```
**To rollback:**
```bash
flask db downgrade
```
## Model Exports
Updated `backend/app/models/__init__.py` to include:
- `from app.models.epic import Epic`
- `from app.models.wiki import Wiki`
Added to `__all__` list: `"Epic"`, `"Wiki"`
## Design Decisions
### Why "Wiki" instead of "Document"?
- Avoids confusion with file attachments
- Emphasizes reusable knowledge content
- Better semantic meaning for rich text resources
### One-to-Many Epic-Card Relationship
- Simpler, clearer ownership
- Each card belongs to one epic
- Easier to query and display
### Board-Scoped Wikis
- Wikis belong to a specific board
- Not global across all boards
- Better organization and access control
### Epic Hierarchy Depth
- Default depth limit: 5 levels
- Configurable per epic
- Backend should enforce when creating child epics
### Simplified Metrics
- Currently only tracks `card_count`
- Stored in JSONB field: `{"card_count": 10}`
- Easy to extend with more metrics later
## Next Steps
### Backend Implementation
1. ✅ Create models - **DONE**
2. ✅ Create database migration - **DONE**
3. ⏭️ Create schemas for serialization
4. ⏭️ Create API routes (CRUD operations)
5. ⏭️ Create services for business logic
6. ⏭️ Add validation for epic depth limit
7. ⏭️ Update epic metrics when cards change
8. ⏭️ Write tests for models and routes
### Frontend Implementation
1. ⏭️ Update TypeScript types
2. ⏭️ Create Epic page/component
3. ⏭️ Create Wiki page/component
4. ⏭️ Add epic dropdown to card detail
5. ⏭️ Create epic list on board detail
6. ⏭️ Implement rich text editor (Slate.js)
7. ⏭️ Add wiki linking UI
### API Endpoints (Future)
#### Epic Endpoints
```
GET /api/boards/{board_id}/epics # List all epics for board
POST /api/boards/{board_id}/epics # Create epic
GET /api/epics/{epic_id} # Get epic details with cards
PUT /api/epics/{epic_id} # Update epic
DELETE /api/epics/{epic_id} # Delete epic
POST /api/epics/{epic_id}/cards # Create card directly in epic
GET /api/epics/{epic_id}/tree # Get epic hierarchy tree
```
#### Wiki Endpoints
```
GET /api/boards/{board_id}/wikis # List all wikis for board
POST /api/boards/{board_id}/wikis # Create wiki
GET /api/wikis/{wiki_id} # Get wiki details
PUT /api/wikis/{wiki_id} # Update wiki
DELETE /api/wikis/{wiki_id} # Delete wiki
POST /api/wikis/{wiki_id}/links # Link wiki to entity
DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity
GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity
```
#### Card Endpoints (Updated)
```
PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink)
GET /api/cards/{card_id}/epic # Get card's epic
```
## Files Created/Modified
### Created
- `backend/app/models/epic.py`
- `backend/app/models/wiki.py`
- `backend/migrations/versions/add_epic_and_wiki_models.py`
### Modified
- `backend/app/models/card.py` (added epic_id)
- `backend/app/models/__init__.py` (added Epic, Wiki imports)
## Testing the Implementation
1. **Apply migration:**
```bash
cd backend
source venv/bin/activate
flask db upgrade
```
2. **Verify tables created:**
```bash
flask dbcurrent
# Should show: add_epic_and_wiki_models
```
3. **Test in Python shell:**
```python
from app import create_app, db
from app.models import Epic, Wiki, Card, Board
app = create_app()
with app.app_context():
# Create an epic
epic = Epic(name="My Epic", board_id=1, color="#3b82f6")
db.session.add(epic)
db.session.commit()
# Create a wiki
wiki = Wiki(name="Security Guide", board_id=1, content={})
db.session.add(wiki)
db.session.commit()
print(f"Epic created: {epic.to_dict()}")
print(f"Wiki created: {wiki.to_dict()}")
```
## Notes
- All models follow the existing project patterns
- Uses `db` from `app` module (not `flask_sqlalchemy` directly)
- Proper foreign key constraints with CASCADE/SET NULL
- Timestamps use UTC timezone
- JSONB fields for flexible data storage
- Indexed for optimal query performance

View file

@ -21,6 +21,10 @@ install: ## Install dependencies
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
cd frontend && npm install cd frontend && npm install
dev-backend-install:
@echo "Installing backend dependencies..."
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
dev-services: ## Start development services (postgres & redis only) dev-services: ## Start development services (postgres & redis only)
@echo "Starting development services (postgres & redis)..." @echo "Starting development services (postgres & redis)..."
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
@ -62,6 +66,9 @@ restart: ## Restart all services
logs: ## Show logs from all services logs: ## Show logs from all services
docker compose logs -f docker compose logs -f
dev-logs: ## Show logs from all services
docker compose -f docker-compose.dev.yml logs
test: ## Run all tests test: ## Run all tests
@echo "Running backend tests..." @echo "Running backend tests..."
cd backend && . venv/bin/activate && pytest cd backend && . venv/bin/activate && pytest

View file

@ -9,3 +9,8 @@ TEST_DATABASE_URL=sqlite:///test.db
# Celery Configuration # Celery Configuration
CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Minio configuration
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin

View file

@ -40,6 +40,13 @@ def create_app(config_name=None):
app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}} app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}
) )
# Import storage extension
from app.services.storage.storage_extension import storage
if not (app.config.get("TESTING") or app.config.get("USE_MOCK_STORAGE")):
# Initialize storage extension (MinIO)
storage.init_app(app)
# Initialize Celery # Initialize Celery
from app.celery import init_celery from app.celery import init_celery

View file

@ -46,11 +46,31 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
# MinIO Configuration for Development
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class TestingConfig(Config): class TestingConfig(Config):
"""Testing configuration""" """Testing configuration"""
TESTING = True TESTING = True
USE_MOCK_STORAGE = True # Use in-memory mock storage for tests
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL")
WTF_CSRF_ENABLED = False WTF_CSRF_ENABLED = False
@ -63,6 +83,31 @@ class TestingConfig(Config):
"pool_pre_ping": True, # Verify connections before using "pool_pre_ping": True, # Verify connections before using
} }
# MinIO configuration (not used in tests due to USE_MOCK_STORAGE=True)
MINIO_ENDPOINT = "minio:9000"
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true"
# Bucket names (required for FileService even with mock storage)
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
# File upload limits
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed file types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
class ProductionConfig(Config): class ProductionConfig(Config):
"""Production configuration""" """Production configuration"""
@ -75,6 +120,31 @@ class ProductionConfig(Config):
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
# MinIO Configuration
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin")
MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true"
# MinIO Buckets
MINIO_IMAGES_BUCKET = "kanban-images"
MINIO_DOCUMENTS_BUCKET = "kanban-documents"
MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails"
# File Upload Limits (in bytes)
MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB
MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB
# Allowed File Types
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
]
ALLOWED_DOCUMENT_TYPES = ["application/pdf"]
config_by_name = { config_by_name = {
"dev": DevelopmentConfig, "dev": DevelopmentConfig,

View file

@ -3,7 +3,8 @@ from functools import wraps
from flask import abort, g from flask import abort, g
from flask_jwt_extended import get_jwt_identity from flask_jwt_extended import get_jwt_identity
from app.models import Board, Card, CheckItem, Checklist, Comment, List from app.models import (Board, Card, CheckItem, Checklist, Comment,
FileAttachment, List)
def get_current_user_id(): def get_current_user_id():
@ -157,3 +158,166 @@ def load_comment_owned(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def load_file_owned(f):
"""
Loads a FileAttachment ensuring it belongs to the user.
Aborts with 404 if not found or not owned.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Filter by ID and user ID
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible(f):
"""
Loads a FileAttachment ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_id = kwargs.get("file_id")
# Try to find file uploaded by user
attachment = FileAttachment.query.filter_by(
id=file_id, uploaded_by=user_id
).first()
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments
card_attachment = (
FileAttachment.query.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached
# to a Comment that belongs to user's board
if not attachment:
# For Comment attachments
comment_attachment = (
FileAttachment.query.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.id == file_id,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function
def load_file_accessible_by_uuid(f):
"""
Loads a FileAttachment by UUID ensuring the user can access it.
User can access if:
1. They uploaded the file, OR
2. The file is attached to an entity they own (via Board ownership)
Aborts with 404 if not found or not accessible.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = get_current_user_id()
file_uuid = kwargs.get("file_uuid")
# Try to find file uploaded by user
attachment = FileAttachment.query.filter_by(
uuid=file_uuid, uploaded_by=user_id
).first()
# If not found, check if attached to a Card that belongs to user's board
if not attachment:
# For Card attachments
card_attachment = (
FileAttachment.query.join(
Card,
(FileAttachment.attachable_type == "Card")
& (FileAttachment.attachable_id == Card.id),
)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if card_attachment:
attachment = card_attachment
# If still not found, check if attached to a
# Comment that belongs to user's board
if not attachment:
# For Comment attachments
comment_attachment = (
FileAttachment.query.join(
Comment,
(FileAttachment.attachable_type == "Comment")
& (FileAttachment.attachable_id == Comment.id),
)
.join(Card)
.join(Board)
.filter(
FileAttachment.uuid == file_uuid,
Board.user_id == user_id,
)
.first()
)
if comment_attachment:
attachment = comment_attachment
if not attachment:
abort(404)
kwargs["file"] = attachment
return f(*args, **kwargs)
return decorated_function

View file

@ -4,11 +4,14 @@ from app.models.card_label import CardLabel
from app.models.check_item import CheckItem from app.models.check_item import CheckItem
from app.models.checklist import Checklist from app.models.checklist import Checklist
from app.models.comment import Comment from app.models.comment import Comment
from app.models.epic import Epic
from app.models.file_attachment import FileAttachment
from app.models.label import Label from app.models.label import Label
from app.models.list_model import List from app.models.list_model import List
from app.models.order import Order, OrderItem from app.models.order import Order, OrderItem
from app.models.product import Product from app.models.product import Product
from app.models.user import User from app.models.user import User
from app.models.wiki import Wiki
__all__ = [ __all__ = [
"User", "User",
@ -23,4 +26,7 @@ __all__ = [
"Checklist", "Checklist",
"CheckItem", "CheckItem",
"Comment", "Comment",
"FileAttachment",
"Epic",
"Wiki",
] ]

View file

@ -32,6 +32,9 @@ class Card(db.Model):
nullable=False, nullable=False,
index=True, index=True,
) )
epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True
)
# Timestamps # Timestamps
date_last_activity = db.Column(db.DateTime) date_last_activity = db.Column(db.DateTime)
@ -57,6 +60,14 @@ class Card(db.Model):
comments = db.relationship( comments = db.relationship(
"Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic" "Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic"
) )
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Card.id,
FileAttachment.attachable_type == 'Card')""",
cascade="all, delete-orphan",
lazy="dynamic",
)
def to_dict(self): def to_dict(self):
"""Convert card to dictionary""" """Convert card to dictionary"""
@ -71,6 +82,7 @@ class Card(db.Model):
"id_short": self.id_short, "id_short": self.id_short,
"board_id": self.board_id, "board_id": self.board_id,
"list_id": self.list_id, "list_id": self.list_id,
"epic_id": self.epic_id,
"date_last_activity": self.date_last_activity.isoformat() "date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity if self.date_last_activity
else None, else None,

View file

@ -30,6 +30,17 @@ class Comment(db.Model):
onupdate=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC),
) )
# Relationships
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Comment.id,
FileAttachment.attachable_type == 'Comment')""",
cascade="all, delete-orphan",
lazy="dynamic",
overlaps="attachments",
)
def to_dict(self): def to_dict(self):
"""Convert comment to dictionary""" """Convert comment to dictionary"""
return { return {

View file

@ -0,0 +1,80 @@
from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
class Epic(db.Model):
"""Epic model for tracking large features across multiple cards"""
__tablename__ = "epics"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
content = db.Column(JSONB) # Rich text content (Slate.js JSON)
color = db.Column(db.String(7)) # Hex color for epic badge
closed = db.Column(db.Boolean, default=False, index=True)
pos = db.Column(db.Float) # Position for sorting in epic list
depth_limit = db.Column(db.Integer, default=5) # Max nesting depth
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
parent_epic_id = db.Column(
db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL")
)
# Timestamps
date_last_activity = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# JSON fields for metrics
metrics = db.Column(JSONB) # {"card_count": 10}
# Relationships
board = db.relationship("Board", backref="epics")
parent_epic = db.relationship("Epic", remote_side=[id], backref="child_epics")
cards = db.relationship("Card", backref="epic", cascade="all, delete-orphan")
attachments = db.relationship(
"FileAttachment",
foreign_keys="FileAttachment.attachable_id",
primaryjoin="""and_(FileAttachment.attachable_id == Epic.id,
FileAttachment.attachable_type == 'Epic')""",
cascade="all, delete-orphan",
lazy="dynamic",
)
def to_dict(self):
"""Convert epic to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"content": self.content,
"color": self.color,
"closed": self.closed,
"pos": self.pos,
"depth_limit": self.depth_limit,
"board_id": self.board_id,
"parent_epic_id": self.parent_epic_id,
"date_last_activity": self.date_last_activity.isoformat()
if self.date_last_activity
else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"metrics": self.metrics or {"card_count": 0},
}
def __repr__(self):
return f"<Epic {self.name}>"

View file

@ -0,0 +1,75 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import Index
from app import db
class FileAttachment(db.Model):
"""Polymorphic file attachment model for Cards, Comments, and other entities"""
__tablename__ = "file_attachments"
id = db.Column(db.Integer, primary_key=True)
uuid = db.Column(
db.String(36), nullable=False, unique=True, default=lambda: str(uuid.uuid4())
)
filename = db.Column(db.String(255), nullable=False)
original_name = db.Column(db.String(255), nullable=False)
file_type = db.Column(db.String(50), nullable=False) # 'image', 'pdf', 'document'
mime_type = db.Column(db.String(100), nullable=False)
file_size = db.Column(db.Integer, nullable=False)
# MinIO storage information
minio_bucket = db.Column(db.String(100), nullable=False)
minio_object_name = db.Column(db.String(255), nullable=False, unique=True)
# Thumbnail information (optional, for images)
thumbnail_minio_object_name = db.Column(db.String(255))
thumbnail_minio_bucket = db.Column(db.String(100))
# Polymorphic relationship - can attach to different entity types
attachable_type = db.Column(
db.String(50), nullable=False
) # 'Card', 'Comment', 'Epic'
attachable_id = db.Column(db.Integer, nullable=False) # ID of the attached entity
# Upload metadata
uploaded_by = db.Column(
db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
# Relationships
uploader = db.relationship("User", backref="uploaded_files")
# Indexes for efficient queries
__table_args__ = (
Index("ix_file_attachments_attachable", "attachable_type", "attachable_id"),
Index("ix_file_attachments_user", "uploaded_by"),
Index("ix_file_attachments_uuid", "uuid"),
)
def to_dict(self):
"""Convert file attachment to dictionary"""
return {
"id": self.id,
"uuid": self.uuid,
"filename": self.filename,
"original_name": self.original_name,
"file_type": self.file_type,
"mime_type": self.mime_type,
"file_size": self.file_size,
"minio_bucket": self.minio_bucket,
"minio_object_name": self.minio_object_name,
"thumbnail_minio_object_name": self.thumbnail_minio_object_name,
"thumbnail_minio_bucket": self.thumbnail_minio_bucket,
"attachable_type": self.attachable_type,
"attachable_id": self.attachable_id,
"uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f"<FileAttachment {self.original_name} ({self.file_type})>"

View file

@ -0,0 +1,88 @@
from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB
from app import db
wiki_entity_links = db.Table(
"wiki_entity_links",
db.Column(
"wiki_id",
db.Integer,
db.ForeignKey("wikis.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column("entity_type", db.String(50), nullable=False), # 'Card', 'Epic'
db.Column("entity_id", db.Integer, nullable=False), # ID of the linked entity
db.Column("created_at", db.DateTime, default=lambda: datetime.now(UTC)),
db.Column("linked_by", db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")),
)
class Wiki(db.Model):
"""Wiki model for reusable rich text content within a board"""
__tablename__ = "wikis"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
slug = db.Column(db.String(255), index=True) # URL-friendly identifier
content = db.Column(JSONB, nullable=False) # Rich text content (Slate.js JSON)
summary = db.Column(db.Text) # Brief description/abstract
category = db.Column(db.String(100)) # Optional categorization
# Foreign keys
board_id = db.Column(
db.Integer,
db.ForeignKey("boards.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
updated_by = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="SET NULL"),
)
# Timestamps
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# JSON fields
tags = db.Column(JSONB) # List of tags for organization: ["security", "api"]
# Relationships
board = db.relationship("Board", backref="wikis")
creator = db.relationship(
"User", foreign_keys=[created_by], backref="created_wikis"
)
updater = db.relationship(
"User", foreign_keys=[updated_by], backref="updated_wikis"
)
def to_dict(self):
"""Convert wiki to dictionary"""
return {
"id": self.id,
"name": self.name,
"slug": self.slug,
"content": self.content,
"summary": self.summary,
"category": self.category,
"board_id": self.board_id,
"tags": self.tags or [],
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f"<Wiki {self.name}>"

View file

@ -6,8 +6,8 @@ kanban_bp = Blueprint("kanban", __name__)
# Import all route modules to register their routes to this blueprint # Import all route modules to register their routes to this blueprint
# fmt: off # fmt: off
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402 from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402
lists) files, labels, lists, wikis)
# fmt: on # fmt: on

View file

@ -0,0 +1,158 @@
"""Routes for Epic CRUD operations."""
from flask import request
from flask_jwt_extended import jwt_required
from flask_pydantic import validate
from app import db
from app.models import Board, Card, Epic
from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest
from . import kanban_bp
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["GET"])
@jwt_required()
def get_board_epics(board_id):
"""Get all epics for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all()
return [EpicResponse.model_validate(epic).model_dump() for epic in epics], 200
@kanban_bp.route("/boards/<int:board_id>/epics", methods=["POST"])
@jwt_required()
@validate(body=EpicCreateRequest)
def create_epic(board_id, body: EpicCreateRequest):
"""Create a new epic for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
epic = Epic(
board_id=board_id,
name=body.name,
description=body.description,
content=body.content,
color=body.color,
closed=False,
pos=body.pos,
depth_limit=body.depth_limit,
parent_epic_id=body.parent_epic_id,
metrics={"card_count": 0, "progress": 0},
)
db.session.add(epic)
db.session.commit()
return EpicResponse.model_validate(epic).model_dump(), 201
@kanban_bp.route("/epics/<int:epic_id>", methods=["GET"])
@jwt_required()
def get_epic(epic_id):
"""Get a specific epic with details."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Get cards for this epic
cards = Card.query.filter_by(epic_id=epic_id).all()
epic_dict = EpicResponse.model_validate(epic).model_dump()
epic_dict["cards"] = [card.to_dict() for card in cards]
return epic_dict, 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["PUT"])
@jwt_required()
@validate(body=EpicUpdateRequest)
def update_epic(epic_id, body: EpicUpdateRequest):
"""Update an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Update fields
if body.name is not None:
epic.name = body.name
if body.description is not None:
epic.description = body.description
if body.content is not None:
epic.content = body.content
if body.color is not None:
epic.color = body.color
if body.closed is not None:
epic.closed = body.closed
if body.pos is not None:
epic.pos = body.pos
if body.depth_limit is not None:
epic.depth_limit = body.depth_limit
if body.parent_epic_id is not None:
epic.parent_epic_id = body.parent_epic_id
db.session.commit()
return EpicResponse.model_validate(epic).model_dump(), 200
@kanban_bp.route("/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def delete_epic(epic_id):
"""Delete an epic."""
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
# Unlink all cards from this epic
Card.query.filter_by(epic_id=epic_id).update({"epic_id": None})
# Delete epic
db.session.delete(epic)
db.session.commit()
return {"message": "Epic deleted successfully"}, 200
@kanban_bp.route("/cards/<int:card_id>/epics", methods=["POST"])
@jwt_required()
def add_epic_to_card(card_id):
"""Link an epic to a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
data = request.get_json()
epic_id = data.get("epic_id")
if not epic_id:
return {"error": "epic_id is required"}, 400
epic = db.session.get(Epic, epic_id)
if not epic:
return {"error": "Epic not found"}, 404
card.epic_id = epic_id
db.session.commit()
return card.to_dict(), 200
@kanban_bp.route("/cards/<int:card_id>/epics/<int:epic_id>", methods=["DELETE"])
@jwt_required()
def remove_epic_from_card(card_id, epic_id):
"""Unlink an epic from a card."""
card = db.session.get(Card, card_id)
if not card:
return {"error": "Card not found"}, 404
if card.epic_id != epic_id:
return {"error": "Card is not linked to this epic"}, 400
card.epic_id = None
db.session.commit()
return {"message": "Epic unlinked from card successfully"}, 200

View file

@ -0,0 +1,299 @@
"""Routes for file upload and management"""
from io import BytesIO
from flask import Response, request
from flask_jwt_extended import jwt_required
from werkzeug.datastructures import FileStorage
from app.decorators import (get_current_user_id, load_card_owned,
load_comment_owned, load_file_accessible_by_uuid)
from app.schemas import (FileAttachmentWithUrlResponse, FileDeleteResponse,
MultipleFilesResponse)
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.file_service import FileService
from . import kanban_bp
@kanban_bp.route("/files/<string:file_uuid>", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def get_file(file_uuid, file):
"""Get file information with UUID-based URLs"""
attachment_service = AttachmentService()
file_info = attachment_service.get_attachment_with_url(file.id)
return file_info, 200
@kanban_bp.route("/files/<string:file_uuid>", methods=["DELETE"])
@jwt_required()
@load_file_accessible_by_uuid
def delete_file(file_uuid, file):
"""Delete a file"""
attachment_service = AttachmentService()
success = attachment_service.delete_attachment(file.id)
if not success:
return {"error": "Failed to delete file"}, 500
return FileDeleteResponse(message="File deleted", file_id=file.id).model_dump(), 200
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["GET"])
@jwt_required()
@load_card_owned
def get_card_attachments(card_id, card):
"""Get all attachments for a card"""
attachment_service = AttachmentService()
attachments = attachment_service.get_card_attachments(card_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/cards/<int:card_id>/attachments", methods=["POST"])
@jwt_required()
@load_card_owned
def upload_card_attachment(card_id, card):
"""Upload a file to a specific card"""
return _upload_file(entity_type="Card", entity_id=card_id)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["GET"])
@jwt_required()
@load_comment_owned
def get_comment_attachments(comment_id, comment):
"""Get all attachments for a comment"""
attachment_service = AttachmentService()
attachments = attachment_service.get_comment_attachments(comment_id)
# Get URLs for each attachment
files_with_urls = []
for attachment in attachments:
file_info = attachment_service.get_attachment_with_url(attachment.id)
files_with_urls.append(file_info)
return (
MultipleFilesResponse(
files=files_with_urls,
count=len(attachments),
).model_dump(),
200,
)
@kanban_bp.route("/comments/<int:comment_id>/attachments", methods=["POST"])
@jwt_required()
@load_comment_owned
def upload_comment_attachment(comment_id, comment):
"""Upload a file to a specific comment"""
return _upload_file(entity_type="Comment", entity_id=comment_id)
@kanban_bp.route("/files/upload", methods=["POST"])
@jwt_required()
def upload_file():
"""Upload a file to an entity (Card, Comment, etc.)"""
# Validate request
entity_type = request.form.get("entity_type")
entity_id = request.form.get("entity_id")
if not entity_type or not entity_id:
return {"error": "entity_type and entity_id are required"}, 400
# Validate entity_type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
return {
"error": f"Invalid entity_type: {entity_type}. Must be one of {valid_types}"
}, 400
return _upload_file(entity_type=entity_type, entity_id=int(entity_id))
def _upload_file(entity_type: str, entity_id: int):
"""
Helper function to handle file upload
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
tuple: (response_dict, status_code)
"""
# Get file from request
if "file" not in request.files:
return {"error": "No file provided"}, 400
file: FileStorage = request.files["file"]
if file.filename == "":
return {"error": "No file selected"}, 400
# Get user ID
user_id = get_current_user_id()
# Create BytesIO from file
file_data = BytesIO()
file.save(file_data)
file_size = len(file_data.getvalue())
# Get MIME type
mime_type = file.mimetype or "application/octet-stream"
# Upload file
attachment_service = AttachmentService()
attachment = attachment_service.attach_file_to_entity(
entity_type=entity_type,
entity_id=entity_id,
file_data=file_data,
filename=file.filename,
content_type=mime_type,
file_size=file_size,
uploaded_by=user_id,
)
# Get attachment with URLs for response
file_info = attachment_service.get_attachment_with_url(attachment.id)
return FileAttachmentWithUrlResponse(**file_info).model_dump(), 201
@kanban_bp.route("/files/<string:file_uuid>/download", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_file(file_uuid, file):
"""
Download a file through secure Flask proxy using UUID
This route serves as a proxy to MinIO, hiding the MinIO server
from the frontend and ensuring authentication/authorization.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)
@kanban_bp.route("/files/<string:file_uuid>/thumbnail", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def download_thumbnail(file_uuid, file):
"""
Download a file thumbnail through secure Flask proxy using UUID
This route serves as a proxy to MinIO for thumbnails,
ensuring authentication with JWT.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: Thumbnail image data with proper headers
"""
# Check if thumbnail exists
if not file.thumbnail_minio_bucket or not file.thumbnail_minio_object_name:
return {"error": "Thumbnail not available"}, 404
file_service = FileService()
# Download thumbnail from MinIO
thumbnail_data = file_service.storage.download_file(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Get thumbnail info for proper headers
thumb_info = file_service.storage.get_file_info(
file.thumbnail_minio_bucket,
file.thumbnail_minio_object_name,
)
# Create Flask Response with thumbnail data
return Response(
thumbnail_data.getvalue(),
mimetype="image/jpeg",
headers={
"Content-Length": str(thumb_info["size"]),
"Cache-Control": "private, max-age=3600",
},
)
@kanban_bp.route("/files/<string:file_uuid>/view", methods=["GET"])
@jwt_required()
@load_file_accessible_by_uuid
def view_file(file_uuid, file):
"""
View a file inline through secure Flask proxy using UUID
Similar to download, but uses inline disposition so browsers
display the file instead of downloading it.
Args:
file_uuid: UUID of the file attachment
file: FileAttachment (injected by @load_file_accessible_by_uuid decorator)
Returns:
Response: File data with proper headers
"""
file_service = FileService()
# Download file from MinIO
file_data = file_service.storage.download_file(
file.minio_bucket, file.minio_object_name
)
# Get file info for proper headers
file_info = file_service.storage.get_file_info(
file.minio_bucket, file.minio_object_name
)
# Create Flask Response with file data (inline disposition)
return Response(
file_data.getvalue(),
mimetype=file.mime_type,
headers={
"Content-Disposition": f'inline; filename="{file.original_name}"',
"Content-Length": str(file_info["size"]),
"Cache-Control": "private, max-age=3600", # Cache for 1 hour
},
)

View file

@ -0,0 +1,127 @@
"""Routes for Wiki CRUD operations."""
import re
from flask_jwt_extended import get_jwt_identity, jwt_required
from flask_pydantic import validate
from app import db
from app.models import Board, Wiki
from app.schemas import WikiCreateRequest, WikiResponse, WikiUpdateRequest
from . import kanban_bp
def generate_slug(name):
"""Generate URL-friendly slug from name."""
slug = name.lower()
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"\s+", "-", slug)
slug = slug.strip("-")
return slug
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["GET"])
@jwt_required()
def get_board_wikis(board_id):
"""Get all wikis for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all()
return [WikiResponse.model_validate(wiki).model_dump() for wiki in wikis], 200
@kanban_bp.route("/boards/<int:board_id>/wikis", methods=["POST"])
@jwt_required()
@validate(body=WikiCreateRequest)
def create_wiki(board_id, body: WikiCreateRequest):
"""Create a new wiki for a board."""
board = db.session.get(Board, board_id)
if not board:
return {"error": "Board not found"}, 404
# Generate slug if not provided
slug = body.slug
if not slug:
slug = generate_slug(body.name)
# Ensure uniqueness
counter = 1
original_slug = slug
while Wiki.query.filter_by(slug=slug).first():
slug = f"{original_slug}-{counter}"
counter += 1
wiki = Wiki(
board_id=board_id,
name=body.name,
slug=slug,
content=body.content,
summary=body.summary,
category=body.category,
tags=body.tags,
created_by=get_jwt_identity(),
updated_by=get_jwt_identity(),
)
db.session.add(wiki)
db.session.commit()
return WikiResponse.model_validate(wiki).model_dump(), 201
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["GET"])
@jwt_required()
def get_wiki(wiki_id):
"""Get a specific wiki with details."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
return WikiResponse.model_validate(wiki).model_dump(), 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["PUT"])
@jwt_required()
@validate(body=WikiUpdateRequest)
def update_wiki(wiki_id, body: WikiUpdateRequest):
"""Update a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Update fields
if body.name is not None:
wiki.name = body.name
if body.slug is not None:
wiki.slug = body.slug
if body.content is not None:
wiki.content = body.content
if body.summary is not None:
wiki.summary = body.summary
if body.category is not None:
wiki.category = body.category
if body.tags is not None:
wiki.tags = body.tags
wiki.updated_by = get_jwt_identity()
db.session.commit()
return WikiResponse.model_validate(wiki).model_dump(), 200
@kanban_bp.route("/wikis/<int:wiki_id>", methods=["DELETE"])
@jwt_required()
def delete_wiki(wiki_id):
"""Delete a wiki."""
wiki = db.session.get(Wiki, wiki_id)
if not wiki:
return {"error": "Wiki not found"}, 404
# Delete wiki (cascades to wiki_entity_links)
db.session.delete(wiki)
db.session.commit()
return {"message": "Wiki deleted successfully"}, 200

View file

@ -7,9 +7,17 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse
from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse
from app.schemas.comment import (CommentCreateRequest, CommentResponse, from app.schemas.comment import (CommentCreateRequest, CommentResponse,
CommentWithUserResponse) CommentWithUserResponse)
from app.schemas.epic import (EpicCreateRequest, EpicResponse,
EpicUpdateRequest, EpicWithCardsResponse)
from app.schemas.file_attachment import (FileAttachmentWithUrlResponse,
FileDeleteResponse, FileUploadRequest,
MultipleFilesResponse,
ValidationError)
from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.label import LabelCreateRequest, LabelResponse
from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.list_model import ListCreateRequest, ListResponse
from app.schemas.product import ProductCreateRequest, ProductResponse from app.schemas.product import ProductCreateRequest, ProductResponse
from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink,
WikiLinkRequest, WikiResponse, WikiUpdateRequest)
__all__ = [ __all__ = [
# Product schemas # Product schemas
@ -36,7 +44,25 @@ __all__ = [
# Comment schemas # Comment schemas
"CommentCreateRequest", "CommentCreateRequest",
"CommentResponse", "CommentResponse",
# File Attachment schemas
"FileAttachmentWithUrlResponse",
"FileDeleteResponse",
"FileUploadRequest",
"MultipleFilesResponse",
"ValidationError",
# Composite schemas
"BoardWithDetailsResponse", "BoardWithDetailsResponse",
"CardWithDetailsResponse", "CardWithDetailsResponse",
"CommentWithUserResponse", "CommentWithUserResponse",
# Epic schemas
"EpicCreateRequest",
"EpicResponse",
"EpicUpdateRequest",
"EpicWithCardsResponse",
# Wiki schemas
"WikiCreateRequest",
"WikiResponse",
"WikiUpdateRequest",
"WikiEntityLink",
"WikiLinkRequest",
] ]

132
backend/app/schemas/epic.py Normal file
View file

@ -0,0 +1,132 @@
"""Pydantic schemas for Epic model"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
class EpicCreateRequest(BaseModel):
"""Schema for creating a new epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"pos": 1.0,
"depth_limit": 5,
"parent_epic_id": None,
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Epic name")
description: Optional[str] = Field(None, description="Epic description")
content: Optional[Any] = Field(None, description="Rich text content")
color: Optional[str] = Field(None, max_length=7, description="Hex color code")
pos: Optional[float] = Field(None, description="Position for ordering")
depth_limit: Optional[int] = Field(
None, ge=1, le=10, description="Maximum depth for hierarchy"
)
parent_epic_id: Optional[int] = Field(
None, description="Parent epic ID for nesting"
)
class EpicResponse(BaseModel):
"""Schema for epic response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "User Authentication",
"description": "Implement login and registration",
"content": [
{"type": "paragraph", "children": [{"text": "Details..."}]}
],
"color": "#3b82f6",
"closed": False,
"pos": 1.0,
"depth_limit": 5,
"board_id": 1,
"parent_epic_id": None,
"metrics": {"card_count": 5, "progress": 60},
"date_last_activity": "2024-01-15T10:30:00",
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class EpicUpdateRequest(BaseModel):
"""Schema for updating an epic"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Epic Name",
"description": "Updated description",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"color": "#ef4444",
"closed": True,
"pos": 2.0,
"depth_limit": 3,
"parent_epic_id": 2,
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = Field(None, max_length=7)
closed: Optional[bool] = None
pos: Optional[float] = None
depth_limit: Optional[int] = Field(None, ge=1, le=10)
parent_epic_id: Optional[int] = None
class EpicWithCardsResponse(BaseModel):
"""Schema for epic response with cards"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: Optional[str] = None
content: Optional[Any] = None
color: Optional[str] = None
closed: bool
pos: Optional[float] = None
depth_limit: Optional[int] = None
board_id: int
parent_epic_id: Optional[int] = None
metrics: Optional[Dict[str, Any]] = None
date_last_activity: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
cards: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -0,0 +1,72 @@
"""Pydantic schemas for file attachments"""
from datetime import datetime
from typing import Optional
from pydantic import (BaseModel, ConfigDict, Field, field_serializer,
field_validator)
class FileUploadRequest(BaseModel):
"""Schema for file upload request"""
entity_type: str = Field(..., description="Type of entity (Card, Comment, etc.)")
entity_id: int = Field(..., description="ID of entity")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v: str) -> str:
"""Validate entity type is allowed"""
valid_types = ["Card", "Comment", "Epic", "Board"]
if v not in valid_types:
raise ValueError(f"Invalid entity_type: {v}. Must be one of {valid_types}")
return v
model_config = ConfigDict(from_attributes=True)
class FileAttachmentWithUrlResponse(BaseModel):
"""
Schema for file attachment with secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, etc.) are hidden.
"""
uuid: str
original_name: str
file_type: str
file_size: int
created_at: datetime
download_url: str
thumbnail_url: Optional[str] = None
view_url: str
mime_type: str
@field_serializer("created_at")
def serialize_created_at(self, value: datetime) -> str:
"""Serialize datetime to ISO format string"""
return value.isoformat() if value else None
model_config = ConfigDict(from_attributes=True)
class FileDeleteResponse(BaseModel):
"""Schema for file delete response"""
message: str = Field(..., description="Deletion status message")
file_id: int = Field(..., description="ID of deleted file")
class MultipleFilesResponse(BaseModel):
"""Schema for multiple files response"""
files: list[FileAttachmentWithUrlResponse]
count: int = Field(..., description="Number of files")
class ValidationError(BaseModel):
"""Schema for validation errors"""
error: str = Field(..., description="Error message")
details: Optional[dict] = None

167
backend/app/schemas/wiki.py Normal file
View file

@ -0,0 +1,167 @@
"""Pydantic schemas for Wiki model"""
from datetime import datetime
from typing import Any, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class WikiCreateRequest(BaseModel):
"""Schema for creating a new wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
}
}
)
name: str = Field(..., min_length=1, max_length=200, description="Wiki name")
slug: Optional[str] = Field(None, max_length=200, description="URL-friendly slug")
content: Any = Field(..., description="Rich text content")
summary: Optional[str] = Field(None, description="Brief summary")
category: Optional[str] = Field(None, max_length=100, description="Wiki category")
tags: Optional[List[str]] = Field(None, description="Wiki tags")
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiResponse(BaseModel):
"""Schema for wiki response"""
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Security Guidelines",
"slug": "security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Security rules..."}]}
],
"summary": "Brief security overview",
"category": "Documentation",
"tags": ["security", "guidelines"],
"board_id": 1,
"created_by": 1,
"updated_by": 1,
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-15T10:30:00",
}
},
)
id: int
name: str
slug: Optional[str] = None
content: Any
summary: Optional[str] = None
category: Optional[str] = None
tags: Optional[List[str]] = None
board_id: int
created_by: Optional[int] = None
updated_by: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class WikiUpdateRequest(BaseModel):
"""Schema for updating a wiki"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Security Guidelines",
"slug": "updated-security-guidelines",
"content": [
{"type": "paragraph", "children": [{"text": "Updated..."}]}
],
"summary": "Updated summary",
"category": "Updated Category",
"tags": ["security", "updated"],
}
}
)
name: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: Optional[Any] = None
summary: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
tags: Optional[List[str]] = None
@field_validator("slug")
@classmethod
def validate_slug(cls, v):
"""Validate slug is URL-friendly."""
if v and not v.islower():
raise ValueError("Slug must be lowercase")
if v and not all(c.isalnum() or c == "-" for c in v):
raise ValueError(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
return v
class WikiEntityLink(BaseModel):
"""Schema for wiki entity link response"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"wiki_id": 1,
"entity_type": "card",
"entity_id": 5,
"created_at": "2024-01-15T10:30:00",
"linked_by": 1,
}
}
)
wiki_id: int
entity_type: str
entity_id: int
created_at: Optional[datetime] = None
linked_by: Optional[int] = None
class WikiLinkRequest(BaseModel):
"""Schema for creating a wiki entity link"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"entity_type": "card",
"entity_id": 5,
}
}
)
entity_type: str = Field(..., description="Entity type (card, epic, list, board)")
entity_id: int = Field(..., description="Entity ID")
@field_validator("entity_type")
@classmethod
def validate_entity_type(cls, v):
"""Validate entity type is supported."""
valid_types = ["card", "epic", "list", "board"]
if v not in valid_types:
raise ValueError(f"entity_type must be one of: {', '.join(valid_types)}")
return v

View file

@ -0,0 +1,20 @@
"""Storage service module for file upload operations"""
from app.services.storage.attachment_service import AttachmentService
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import (FileProcessor, ImageProcessor,
PDFProcessor)
from app.services.storage.file_service import FileService
from app.services.storage.minio_client import MinIOStorageAdapter
from app.services.storage.storage_extension import storage
__all__ = [
"StorageAdapter",
"MinIOStorageAdapter",
"FileProcessor",
"ImageProcessor",
"PDFProcessor",
"FileService",
"AttachmentService",
"storage",
]

View file

@ -0,0 +1,231 @@
"""Service for managing file attachments to entities"""
from io import BytesIO
from typing import List, Optional
from flask import current_app
from app.models import Card, Comment, FileAttachment
from app.services.storage.file_service import FileService
class AttachmentService:
"""Service for managing file attachments to Cards, Comments, etc."""
def __init__(self, file_service: Optional[FileService] = None):
"""
Initialize AttachmentService with dependency injection
Args:
file_service: FileService instance
(defaults to new instance with dependency injection)
"""
self.file_service = file_service or FileService()
def attach_file_to_card(
self,
card_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a card
Args:
card_id: ID of the card
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify card exists
card = Card.query.get(card_id)
if not card:
raise ValueError(f"Card with ID {card_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Card",
attachable_id=card_id,
)
def attach_file_to_comment(
self,
comment_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to a comment
Args:
comment_id: ID of the comment
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Verify comment exists
comment = Comment.query.get(comment_id)
if not comment:
raise ValueError(f"Comment with ID {comment_id} not found")
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type="Comment",
attachable_id=comment_id,
)
def attach_file_to_entity(
self,
entity_type: str,
entity_id: int,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
) -> FileAttachment:
"""
Attach a file to any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
Returns:
FileAttachment: Created attachment record
"""
# Validate entity type
valid_types = ["Card", "Comment", "Epic", "Board"]
if entity_type not in valid_types:
raise ValueError(
f"Invalid entity type: {entity_type}. Must be one of {valid_types}"
)
# Upload file
return self.file_service.upload_file(
file_data=file_data,
filename=filename,
content_type=content_type,
file_size=file_size,
uploaded_by=uploaded_by,
attachable_type=entity_type,
attachable_id=entity_id,
)
def get_card_attachments(self, card_id: int) -> List[FileAttachment]:
"""
Get all attachments for a card
Args:
card_id: ID of the card
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Card", card_id)
def get_comment_attachments(self, comment_id: int) -> List[FileAttachment]:
"""
Get all attachments for a comment
Args:
comment_id: ID of the comment
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity("Comment", comment_id)
def get_entity_attachments(
self, entity_type: str, entity_id: int
) -> List[FileAttachment]:
"""
Get all attachments for any entity type
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return self.file_service.get_files_for_entity(entity_type, entity_id)
def get_attachment_with_url(self, attachment_id: int) -> dict:
"""
Get attachment with download URL
Args:
attachment_id: ID of the attachment
Returns:
dict: Attachment information with URLs
"""
return self.file_service.get_file_info(attachment_id)
def delete_attachment(self, attachment_id: int) -> bool:
"""
Delete an attachment
Args:
attachment_id: ID of the attachment
Returns:
bool: True if successful
"""
return self.file_service.delete_file(attachment_id)
def delete_entity_attachments(self, entity_type: str, entity_id: int) -> int:
"""
Delete all attachments for an entity (cascade delete)
Args:
entity_type: Type of entity (Card, Comment, etc.)
entity_id: ID of the entity
Returns:
int: Number of attachments deleted
"""
attachments = self.get_entity_attachments(entity_type, entity_id)
deleted_count = 0
for attachment in attachments:
if self.delete_attachment(attachment.id):
deleted_count += 1
current_app.logger.info(
f"Deleted {deleted_count} attachments for {entity_type} {entity_id}"
)
return deleted_count

View file

@ -0,0 +1,113 @@
"""Abstract base class for storage adapters"""
from abc import ABC, abstractmethod
from io import BytesIO
class StorageAdapter(ABC):
"""Abstract base class for storage adapters"""
@abstractmethod
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
) -> dict:
"""
Upload a file to storage
Args:
file_data: File data as BytesIO
bucket_name: Name of the bucket
object_name: Name of the object in storage
content_type: MIME type of the file
Returns:
dict: Upload result with metadata
"""
pass
@abstractmethod
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
"""
pass
@abstractmethod
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if successful
"""
pass
@abstractmethod
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
pass
@abstractmethod
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in storage
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
pass
@abstractmethod
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata
Args:
bucket_name: Name of the bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
"""
pass
@abstractmethod
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
pass

View file

@ -0,0 +1,264 @@
"""File processor classes for different file types"""
from abc import ABC, abstractmethod
from io import BytesIO
from typing import Optional, Tuple
from flask import current_app
from PIL import Image
class FileProcessor(ABC):
"""Abstract base class for file processors"""
@abstractmethod
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""
Validate file
Args:
file_data: File data as BytesIO
file_size: Size of the file in bytes
mime_type: MIME type of the file
Returns:
bool: True if valid
"""
pass
@abstractmethod
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""
Process file and return metadata
Args:
file_data: File data as BytesIO
mime_type: MIME type of the file
Returns:
dict: Processing results and metadata
"""
pass
@abstractmethod
def get_file_type(self) -> str:
"""Return the file type (image, pdf, document, etc.)"""
pass
class ImageProcessor(FileProcessor):
"""Processor for image files"""
def __init__(self):
self.supported_formats = {
"image/jpeg": "jpeg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg+xml",
}
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate image file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_IMAGE"]:
current_app.logger.error(
f"Image size {file_size} exceeds "
+ "maximum {current_app.config['MAX_UPLOAD_SIZE_IMAGE']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_IMAGE_TYPES"]:
current_app.logger.error(f"Unsupported image MIME type: {mime_type}")
return False
# Verify it's actually an image
try:
file_data.seek(0)
img = Image.open(file_data)
img.verify()
file_data.seek(0)
return True
except Exception as e:
current_app.logger.error(f"Image validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process image and extract metadata"""
file_data.seek(0)
img = Image.open(file_data)
metadata = {
"width": img.width,
"height": img.height,
"format": img.format,
"mode": img.mode,
"file_type": "image",
}
# Generate thumbnail
thumbnail_data = self._generate_thumbnail(img)
file_data.seek(0)
return {
"metadata": metadata,
"thumbnail_data": thumbnail_data,
}
def _generate_thumbnail(
self, img: Image.Image, size: Tuple[int, int] = (200, 200)
) -> Optional[BytesIO]:
"""
Generate thumbnail for image
Args:
img: PIL Image object
size: Thumbnail size (width, height)
Returns:
BytesIO: Thumbnail data or None
"""
try:
# Create thumbnail
img_copy = img.copy()
img_copy.thumbnail(size, Image.Resampling.LANCZOS)
# Convert RGBA to RGB if necessary (JPEG doesn't support transparency)
if img_copy.mode in ("RGBA", "LA", "P"):
# Create white background
background = Image.new("RGB", img_copy.size, (255, 255, 255))
if img_copy.mode == "P":
img_copy = img_copy.convert("RGBA")
if img_copy.mode in ("RGBA", "LA"):
background.paste(img_copy, mask=img_copy.split()[-1])
else:
background.paste(img_copy)
img_copy = background
elif img_copy.mode != "RGB":
img_copy = img_copy.convert("RGB")
# Save to BytesIO
thumb_data = BytesIO()
img_copy.save(thumb_data, format="JPEG", quality=85)
thumb_data.seek(0)
return thumb_data
except Exception as e:
current_app.logger.error(f"Thumbnail generation failed: {e}")
return None
def get_file_type(self) -> str:
return "image"
class PDFProcessor(FileProcessor):
"""Processor for PDF files"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate PDF file"""
# Check file size
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error(
f"PDF size {file_size} exceeds "
f"maximum {current_app.config['MAX_UPLOAD_SIZE_DOCUMENT']}"
)
return False
# Check MIME type
if mime_type not in current_app.config["ALLOWED_DOCUMENT_TYPES"]:
current_app.logger.error(f"Unsupported document MIME type: {mime_type}")
return False
# Verify it's actually a PDF
try:
file_data.seek(0)
header = file_data.read(4)
file_data.seek(0)
if header != b"%PDF":
current_app.logger.error("Invalid PDF header")
return False
return True
except Exception as e:
current_app.logger.error(f"PDF validation failed: {e}")
return False
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process PDF and extract metadata"""
file_data.seek(0)
# Basic PDF metadata extraction
# Note: For more detailed extraction, you might want to use PyPDF2 or similar
metadata = {
"file_type": "pdf",
}
return {
"metadata": metadata,
"thumbnail_data": None, # PDFs don't have thumbnails by default
}
def get_file_type(self) -> str:
return "pdf"
class DocumentProcessor(FileProcessor):
"""Processor for other document types (placeholder for future expansion)"""
def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool:
"""Validate document file"""
# Basic validation - can be extended for specific document types
if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]:
current_app.logger.error("Document size exceeds maximum")
return False
return True
def process(self, file_data: BytesIO, mime_type: str) -> dict:
"""Process document"""
return {
"metadata": {"file_type": "document"},
"thumbnail_data": None,
}
def get_file_type(self) -> str:
return "document"
class ProcessorFactory:
"""Factory for creating appropriate file processors"""
@staticmethod
def get_processor(mime_type: str) -> FileProcessor:
"""
Get appropriate processor for MIME type
Args:
mime_type: MIME type of the file
Returns:
FileProcessor: Appropriate processor instance
"""
if mime_type.startswith("image/"):
return ImageProcessor()
elif mime_type == "application/pdf":
return PDFProcessor()
else:
return DocumentProcessor()
@staticmethod
def get_file_type_from_mime(mime_type: str) -> str:
"""
Get file type category from MIME type
Args:
mime_type: MIME type of the file
Returns:
str: File type category
"""
if mime_type.startswith("image/"):
return "image"
elif mime_type == "application/pdf":
return "pdf"
else:
return "document"

View file

@ -0,0 +1,315 @@
"""Main file service for handling file uploads"""
from io import BytesIO
from typing import Any, Dict, Optional
from flask import current_app
from app import db
from app.models import FileAttachment
from app.services.storage.base import StorageAdapter
from app.services.storage.file_processor import ProcessorFactory
class FileService:
"""Service for handling file upload and storage operations"""
def __init__(self, storage_adapter: Optional[StorageAdapter] = None):
"""
Initialize FileService with dependency injection
Args:
storage_adapter: Storage adapter
(optional, defaults to factory-provided adapter)
"""
if storage_adapter:
self.storage = storage_adapter
else:
from app.services.storage.storage_factory import StorageFactory
self.storage = StorageFactory.get_storage()
def upload_file(
self,
file_data: BytesIO,
filename: str,
content_type: str,
file_size: int,
uploaded_by: int,
attachable_type: str,
attachable_id: int,
) -> FileAttachment:
"""
Upload a file and create FileAttachment record
Args:
file_data: File data as BytesIO
filename: Original filename
content_type: MIME type
file_size: File size in bytes
uploaded_by: User ID who uploaded the file
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
FileAttachment: Created attachment record
"""
# Get appropriate processor
processor = ProcessorFactory.get_processor(content_type)
# Validate file
if not processor.validate(file_data, file_size, content_type):
raise ValueError("File validation failed")
# Process file and get metadata (this also generates thumbnail)
file_data.seek(0) # Ensure file is at start
process_result = processor.process(file_data, content_type)
metadata = process_result["metadata"]
thumbnail_data = process_result["thumbnail_data"]
# Seek back to start for main file upload
file_data.seek(0)
# Determine bucket
bucket_name = self._get_bucket_for_type(content_type)
# Generate unique object name
object_name = self.storage.generate_unique_object_name(filename)
# Upload main file
self.storage.upload_file(
file_data, bucket_name, object_name, content_type, file_size
)
# Upload thumbnail if available
thumbnail_info = {}
if thumbnail_data:
try:
thumb_bucket = current_app.config["MINIO_THUMBNAILS_BUCKET"]
thumb_object_name = f"thumb_{object_name}"
# Get thumbnail length
thumbnail_data.seek(0, 2)
thumb_length = thumbnail_data.tell()
thumbnail_data.seek(0)
# Upload thumbnail
self.storage.upload_file(
thumbnail_data,
thumb_bucket,
thumb_object_name,
"image/jpeg",
length=thumb_length,
)
thumbnail_info = {
"thumbnail_minio_object_name": thumb_object_name,
"thumbnail_minio_bucket": thumb_bucket,
}
current_app.logger.info(
f"Thumbnail uploaded: {thumb_bucket}/{thumb_object_name}"
)
except Exception as e:
current_app.logger.error(f"Failed to upload thumbnail: {e}")
# Continue without thumbnail
# Create database record
attachment = FileAttachment(
filename=object_name,
original_name=filename,
file_type=metadata.get("file_type", "document"),
mime_type=content_type,
file_size=file_size,
minio_bucket=bucket_name,
minio_object_name=object_name,
uploaded_by=uploaded_by,
attachable_type=attachable_type,
attachable_id=attachable_id,
**thumbnail_info,
)
db.session.add(attachment)
db.session.commit()
current_app.logger.info(
f"File uploaded: {filename} -> {bucket_name}/{object_name}"
)
return attachment
def delete_file(self, attachment_id: int) -> bool:
"""
Delete a file and its attachment record
Args:
attachment_id: ID of the FileAttachment
Returns:
bool: True if successful
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
return False
# Delete from MinIO
try:
self.storage.delete_file(
attachment.minio_bucket, attachment.minio_object_name
)
# Delete thumbnail if exists
if (
attachment.thumbnail_minio_bucket
and attachment.thumbnail_minio_object_name
):
self.storage.delete_file(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
)
except Exception as e:
current_app.logger.error(f"Error deleting file from MinIO: {e}")
# Delete from database
db.session.delete(attachment)
db.session.commit()
current_app.logger.info(f"File deleted: {attachment.original_name}")
return True
def get_file_url(self, attachment_id: int, expires_in: int = 3600) -> str:
"""
Generate a presigned URL for file access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment:
raise ValueError("Attachment not found")
return self.storage.generate_presigned_url(
attachment.minio_bucket,
attachment.minio_object_name,
expires_in,
)
def get_thumbnail_url(
self, attachment_id: int, expires_in: int = 3600
) -> Optional[str]:
"""
Generate a presigned URL for thumbnail access
Args:
attachment_id: ID of the FileAttachment
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL or None if no thumbnail
"""
attachment = db.session.get(FileAttachment, attachment_id)
if not attachment or not attachment.thumbnail_minio_bucket:
return None
return self.storage.generate_presigned_url(
attachment.thumbnail_minio_bucket,
attachment.thumbnail_minio_object_name,
expires_in,
)
def _get_bucket_for_type(self, mime_type: str) -> str:
"""
Get appropriate bucket for MIME type
Args:
mime_type: MIME type of the file
Returns:
str: Bucket name
"""
if mime_type.startswith("image/"):
return current_app.config["MINIO_IMAGES_BUCKET"]
elif mime_type == "application/pdf":
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
else:
return current_app.config["MINIO_DOCUMENTS_BUCKET"]
def get_file_by_id(self, attachment_id: int) -> Optional[FileAttachment]:
"""
Get file attachment by ID
Args:
attachment_id: ID of the FileAttachment
Returns:
FileAttachment or None
"""
return db.session.get(FileAttachment, attachment_id)
def get_files_for_entity(
self, attachable_type: str, attachable_id: int
) -> list[FileAttachment]:
"""
Get all files attached to an entity
Args:
attachable_type: Type of entity (Card, Comment, etc.)
attachable_id: ID of the entity
Returns:
list: List of FileAttachment objects
"""
return FileAttachment.query.filter_by(
attachable_type=attachable_type, attachable_id=attachable_id
).all()
def get_file_info(self, attachment_id: int) -> Optional[Dict[str, Any]]:
"""
Get file information including UUID-based secure URLs
Security: Only exposes public-facing information.
Internal details (MinIO paths, IDs, entity references) are hidden.
Uses UUID for secure file access:
- UUIDs are non-sequential and hard to guess
- All endpoints require JWT authentication
- MinIO server stays hidden from public
- Frontend uses blob URLs for secure image display
Args:
attachment_id: ID of the FileAttachment
Returns:
dict: File information with UUID-based URLs
"""
attachment = self.get_file_by_id(attachment_id)
if not attachment:
return None
# Use UUID-based secure URLs (all require JWT authentication)
download_url = f"/files/{attachment.uuid}/download"
thumbnail_url = (
f"/files/{attachment.uuid}/thumbnail"
if attachment.thumbnail_minio_object_name
else None
)
view_url = f"/files/{attachment.uuid}/view"
# Only return public-facing fields (security: hide internal implementation)
return {
"uuid": attachment.uuid,
"original_name": attachment.original_name,
"file_type": attachment.file_type,
"file_size": attachment.file_size,
"mime_type": attachment.mime_type,
"created_at": attachment.created_at.isoformat()
if attachment.created_at
else None,
"download_url": download_url,
"thumbnail_url": thumbnail_url,
"view_url": view_url,
}

View file

@ -0,0 +1,222 @@
"""MinIO storage adapter implementation"""
import uuid
from io import BytesIO
from typing import Optional
from flask import current_app
from minio import Minio
from minio.error import S3Error
from app.services.storage.base import StorageAdapter
class MinIOStorageAdapter(StorageAdapter):
"""MinIO implementation of StorageAdapter"""
def __init__(self, minio_client: Optional[Minio] = None):
"""
Initialize MinIO adapter with client dependency
Args:
minio_client: MinIO client
instance (optional, defaults to extension's client)
"""
if minio_client is not None:
self.client = minio_client
else:
# Get client from storage extension (dependency injection)
from app.services.storage.storage_extension import storage
self.client = storage.get_client()
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: Optional[int] = None,
) -> dict:
"""
Upload a file to MinIO
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of the object in MinIO
content_type: MIME type of the file
length: File size in bytes (optional, will be determined if not provided)
Returns:
dict: Upload result with metadata
"""
try:
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Upload file
result = self.client.put_object(
bucket_name,
object_name,
file_data,
length=length,
content_type=content_type,
)
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": result.etag,
"version_id": result.version_id
if hasattr(result, "version_id")
else None,
}
except S3Error as e:
current_app.logger.error(f"MinIO upload error: {e}")
raise
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
BytesIO: File data
"""
try:
response = self.client.get_object(bucket_name, object_name)
return BytesIO(response.read())
except S3Error as e:
current_app.logger.error(f"MinIO download error: {e}")
raise
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if successful
"""
try:
self.client.remove_object(bucket_name, object_name)
return True
except S3Error as e:
current_app.logger.error(f"MinIO delete error: {e}")
return False
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a presigned URL for file access
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
expires_in: URL expiration time in seconds
Returns:
str: Presigned URL
"""
try:
url = self.client.presigned_get_object(
bucket_name, object_name, expires=expires_in
)
return url
except S3Error as e:
current_app.logger.error(f"MinIO presigned URL error: {e}")
raise
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
bool: True if file exists
"""
try:
self.client.stat_object(bucket_name, object_name)
return True
except S3Error as e:
if e.code == "NoSuchKey":
return False
current_app.logger.error(f"MinIO check exists error: {e}")
return False
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from MinIO
Args:
bucket_name: Name of bucket
object_name: Name of the object in MinIO
Returns:
dict: File metadata
"""
try:
stat = self.client.stat_object(bucket_name, object_name)
return {
"size": stat.size,
"last_modified": stat.last_modified,
"etag": stat.etag,
"content_type": stat.content_type,
}
except S3Error as e:
current_app.logger.error(f"MinIO get file info error: {e}")
raise
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of the bucket
"""
try:
if not self.client.bucket_exists(bucket_name):
self.client.make_bucket(bucket_name)
current_app.logger.info(f"Created bucket: {bucket_name}")
except S3Error as e:
current_app.logger.error(f"MinIO ensure bucket error: {e}")
raise
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name

View file

@ -0,0 +1,217 @@
"""Mock storage adapter for testing (in-memory storage)"""
import uuid
from datetime import datetime, timezone
from io import BytesIO
from app.services.storage.base import StorageAdapter
class MockStorageAdapter(StorageAdapter):
"""In-memory storage adapter for testing purposes"""
# Class-level storage to persist across instances
_files = {} # {(bucket, object_name): BytesIO}
_file_info = {} # {(bucket, object_name): metadata}
_buckets = set() # Track created buckets
def __init__(self):
"""Initialize mock storage (storage is shared at class level)"""
pass
def upload_file(
self,
file_data: BytesIO,
bucket_name: str,
object_name: str,
content_type: str,
length: int = None,
) -> dict:
"""
Upload a file to in-memory storage
Args:
file_data: File data as BytesIO
bucket_name: Name of bucket
object_name: Name of object in storage
content_type: MIME type of file
length: File size in bytes (optional)
Returns:
dict: Upload result with metadata
"""
# Ensure bucket exists
self.ensure_bucket_exists(bucket_name)
# Get file length if not provided
if length is None:
file_data.seek(0, 2) # Seek to end
length = file_data.tell()
file_data.seek(0) # Seek back to start
# Store file data (create a copy to avoid reference issues)
file_data.seek(0)
file_copy = BytesIO(file_data.read())
# Store in memory
key = (bucket_name, object_name)
self._files[key] = file_copy
# Store metadata
metadata = {
"size": length,
"content_type": content_type,
"last_modified": datetime.now(timezone.utc),
"etag": uuid.uuid4().hex,
}
self._file_info[key] = metadata
return {
"success": True,
"bucket": bucket_name,
"object_name": object_name,
"size": length,
"etag": metadata["etag"],
}
def download_file(self, bucket_name: str, object_name: str) -> BytesIO:
"""
Download a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
BytesIO: File data
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
raise ValueError(f"File {bucket_name}/{object_name} not found")
# Create a copy to avoid modifying the original
self._files[key].seek(0)
return BytesIO(self._files[key].read())
def delete_file(self, bucket_name: str, object_name: str) -> bool:
"""
Delete a file from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if successful, False if file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._files:
return False
# Remove from storage
del self._files[key]
del self._file_info[key]
return True
def generate_presigned_url(
self, bucket_name: str, object_name: str, expires_in: int = 3600
) -> str:
"""
Generate a mock presigned URL
In tests, this returns a fake URL that looks like a real MinIO URL
but doesn't actually need to work since we use Flask proxy routes.
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
expires_in: URL expiration time in seconds (ignored in mock)
Returns:
str: Mock presigned URL
"""
return f"http://minio:9000/{bucket_name}/{object_name}?X-Amz-Token=mock"
def file_exists(self, bucket_name: str, object_name: str) -> bool:
"""
Check if a file exists in in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
bool: True if file exists
"""
return (bucket_name, object_name) in self._files
def get_file_info(self, bucket_name: str, object_name: str) -> dict:
"""
Get file metadata from in-memory storage
Args:
bucket_name: Name of bucket
object_name: Name of the object in storage
Returns:
dict: File metadata
Raises:
ValueError: If file doesn't exist
"""
key = (bucket_name, object_name)
if key not in self._file_info:
raise ValueError(f"File {bucket_name}/{object_name} not found")
return self._file_info[key].copy()
def ensure_bucket_exists(self, bucket_name: str) -> None:
"""
Ensure a bucket exists, create if it doesn't
Args:
bucket_name: Name of bucket
"""
if bucket_name not in self._buckets:
self._buckets.add(bucket_name)
def generate_unique_object_name(self, original_filename: str) -> str:
"""
Generate a unique object name for a file
Args:
original_filename: Original filename
Returns:
str: Unique object name
"""
# Extract file extension
ext = (
original_filename.rsplit(".", 1)[1].lower()
if "." in original_filename
else ""
)
# Generate unique filename with UUID
unique_name = f"{uuid.uuid4().hex}"
return f"{unique_name}.{ext}" if ext else unique_name
@classmethod
def clear_all(cls) -> None:
"""Clear all files and buckets (useful for test cleanup)"""
cls._files.clear()
cls._file_info.clear()
cls._buckets.clear()
@classmethod
def get_all_files(cls) -> list:
"""
Get all stored files (useful for test assertions)
Returns:
list: List of (bucket, object_name) tuples
"""
return list(cls._files.keys())

View file

@ -0,0 +1,128 @@
"""Flask extension for MinIO storage client initialization"""
import logging
from flask import Flask
from minio import Minio
from minio.error import S3Error
class StorageExtension:
"""Flask extension for managing MinIO storage client"""
def __init__(self, app: Flask = None):
"""
Initialize StorageExtension
Args:
app: Flask application instance (optional)
"""
self.app = app
self.client = None
self.logger = logging.getLogger(__name__)
if app is not None:
self.init_app(app)
def init_app(self, app: Flask) -> None:
"""
Initialize the extension with Flask app
Args:
app: Flask application instance
"""
# Store extension on app
app.extensions = getattr(app, "extensions", {})
app.extensions["storage"] = self
# Store app reference for later use
self.app = app
# Initialize and validate MinIO client immediately at app startup
self._initialize_and_validate(app)
def _initialize_and_validate(self, app: Flask) -> None:
"""
Initialize MinIO client and validate connection
Args:
app: Flask application instance
"""
try:
# Create MinIO client
self.client = Minio(
app.config["MINIO_ENDPOINT"],
access_key=app.config["MINIO_ACCESS_KEY"],
secret_key=app.config["MINIO_SECRET_KEY"],
secure=app.config["MINIO_USE_SSL"],
)
# Validate connection by listing buckets
self.client.list_buckets()
app.logger.info("MinIO client initialized and validated successfully")
# Ensure all required buckets exist
self._ensure_all_buckets_exist(app)
except S3Error as e:
app.logger.error(f"Failed to initialize MinIO client: {e}")
raise RuntimeError(
f"Failed to connect to MinIO at {app.config['MINIO_ENDPOINT']}. "
"Please check your MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"and MINIO_SECRET_KEY configuration."
) from e
except KeyError as e:
app.logger.error(f"Missing MinIO configuration: {e}")
raise RuntimeError(
f"Missing required MinIO configuration: {e}. "
"Please ensure MINIO_ENDPOINT, MINIO_ACCESS_KEY, "
"MINIO_SECRET_KEY are set."
) from e
def _ensure_all_buckets_exist(self, app: Flask) -> None:
"""
Ensure all required buckets exist
Creates buckets if they don't exist
Args:
app: Flask application instance
"""
buckets = [
app.config["MINIO_IMAGES_BUCKET"],
app.config["MINIO_DOCUMENTS_BUCKET"],
app.config["MINIO_THUMBNAILS_BUCKET"],
]
for bucket_name in buckets:
try:
if not self.client.bucket_exists(bucket_name):
self.client.make_bucket(bucket_name)
app.logger.info(f"Created MinIO bucket: {bucket_name}")
else:
app.logger.debug(f"MinIO bucket already exists: {bucket_name}")
except S3Error as e:
app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}")
raise RuntimeError(
f"Failed to create MinIO bucket '{bucket_name}': {e}"
) from e
def get_client(self) -> Minio:
"""
Get the MinIO client instance
Returns:
Minio: Initialized MinIO client
Raises:
RuntimeError: If client has not been initialized
"""
if self.client is None:
raise RuntimeError(
"MinIO client has not been initialized. "
"Ensure the Flask app context is active "
"and before_first_request has run."
)
return self.client
# Create a singleton instance
storage = StorageExtension()

View file

@ -0,0 +1,56 @@
"""Storage factory for creating storage adapters"""
from flask import current_app
class StorageFactory:
"""Factory for creating storage adapter instances"""
@staticmethod
def get_storage():
"""
Get the appropriate storage adapter based on configuration
Returns:
StorageAdapter: Configured storage adapter instance
Factory Pattern Benefits:
- Centralized adapter selection logic
- Easy to add new storage types (S3, GCS, etc.)
- Single Responsibility Principle - FileService only handles file operations
- Easy to test by injecting mock adapters
"""
# Check if we should use mock storage
if current_app.config.get("USE_MOCK_STORAGE", False):
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
# Default to MinIO for production/development
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()
@staticmethod
def get_storage_for_testing():
"""
Get mock storage adapter explicitly for testing
Returns:
StorageAdapter: MockStorageAdapter instance
"""
from app.services.storage.mock_adapter import MockStorageAdapter
return MockStorageAdapter()
@staticmethod
def get_minio_storage():
"""
Get MinIO storage adapter explicitly
Returns:
StorageAdapter: MinIOStorageAdapter instance
"""
from app.services.storage.minio_client import MinIOStorageAdapter
return MinIOStorageAdapter()

View file

@ -0,0 +1,106 @@
"""add epic and wiki models
Revision ID: 6fc439155ced
Revises: e9515e29ef8b
Create Date: 2026-03-22 00:24:26.645867
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6fc439155ced'
down_revision = 'e9515e29ef8b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('epics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('closed', sa.Boolean(), nullable=True),
sa.Column('pos', sa.Float(), nullable=True),
sa.Column('depth_limit', sa.Integer(), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('parent_epic_id', sa.Integer(), nullable=True),
sa.Column('date_last_activity', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['parent_epic_id'], ['epics.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_epics_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_closed'), ['closed'], unique=False)
batch_op.create_index(batch_op.f('ix_epics_name'), ['name'], unique=False)
op.create_table('wikis',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=True),
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('board_id', sa.Integer(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_wikis_board_id'), ['board_id'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_wikis_slug'), ['slug'], unique=False)
op.create_table('wiki_entity_links',
sa.Column('wiki_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sa.String(length=50), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('linked_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['linked_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['wiki_id'], ['wikis.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('wiki_id')
)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('epic_id', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_epic_id'), ['epic_id'], unique=False)
batch_op.create_foreign_key(None, 'epics', ['epic_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_cards_epic_id'))
batch_op.drop_column('epic_id')
op.drop_table('wiki_entity_links')
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_slug'))
batch_op.drop_index(batch_op.f('ix_wikis_name'))
batch_op.drop_index(batch_op.f('ix_wikis_board_id'))
op.drop_table('wikis')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_name'))
batch_op.drop_index(batch_op.f('ix_epics_closed'))
batch_op.drop_index(batch_op.f('ix_epics_board_id'))
op.drop_table('epics')
# ### end Alembic commands ###

View file

@ -0,0 +1,54 @@
"""add file upload
Revision ID: c5b574480b80
Revises: 1c0b9dfbd933
Create Date: 2026-03-20 20:16:04.599906
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c5b574480b80'
down_revision = '1c0b9dfbd933'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('file_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_name', sa.String(length=255), nullable=False),
sa.Column('file_type', sa.String(length=50), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('minio_bucket', sa.String(length=100), nullable=False),
sa.Column('minio_object_name', sa.String(length=255), nullable=False),
sa.Column('thumbnail_minio_object_name', sa.String(length=255), nullable=True),
sa.Column('thumbnail_minio_bucket', sa.String(length=100), nullable=True),
sa.Column('attachable_type', sa.String(length=50), nullable=False),
sa.Column('attachable_id', sa.Integer(), nullable=False),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('minio_object_name')
)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.create_index('ix_file_attachments_attachable', ['attachable_type', 'attachable_id'], unique=False)
batch_op.create_index('ix_file_attachments_user', ['uploaded_by'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index('ix_file_attachments_user')
batch_op.drop_index('ix_file_attachments_attachable')
op.drop_table('file_attachments')
# ### end Alembic commands ###

View file

@ -0,0 +1,59 @@
"""Add UUID to file_attachments
Revision ID: e9515e29ef8b
Revises: c5b574480b80
Create Date: 2026-03-20 21:58:22.006727
"""
from alembic import op
import sqlalchemy as sa
import uuid
# revision identifiers, used by Alembic.
revision = 'e9515e29ef8b'
down_revision = 'c5b574480b80'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Step 1: Add UUID column as nullable
op.add_column('file_attachments', sa.Column('uuid', sa.String(length=36), nullable=True))
# Step 2: Populate UUID for existing rows
from sqlalchemy.sql import table, column, select, update
file_attachments_table = table('file_attachments',
column('id', sa.Integer),
column('uuid', sa.String(36))
)
connection = op.get_bind()
# Get all existing file attachments
result = connection.execute(select(file_attachments_table.c.id))
for row in result:
new_uuid = str(uuid.uuid4())
connection.execute(
file_attachments_table.update()
.where(file_attachments_table.c.id == row[0])
.values(uuid=new_uuid)
)
# Step 3: Make UUID not null and add constraints
op.alter_column('file_attachments', 'uuid', nullable=False)
op.create_index('ix_file_attachments_uuid', 'file_attachments', ['uuid'], unique=False)
op.create_unique_constraint('uq_file_attachments_uuid', 'file_attachments', ['uuid'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_file_attachments_uuid', 'file_attachments', type_='unique')
op.drop_index('ix_file_attachments_uuid', table_name='file_attachments')
op.drop_column('file_attachments', 'uuid')
# ### end Alembic commands ###

View file

@ -10,9 +10,11 @@ Werkzeug==3.0.1
SQLAlchemy==2.0.23 SQLAlchemy==2.0.23
celery[redis]==5.3.6 celery[redis]==5.3.6
pydantic==2.5.3 pydantic==2.5.3
minio==7.2.0
Pillow==10.1.0
pytest==7.4.3 pytest==7.4.3
pytest-flask==1.3.0 pytest-flask==1.3.0
pytest-cov==4.1.0 pytest-cov==4.1.0
pytest-mock==3.12.0 pytest-mock==3.12.0
factory-boy==3.3.0 factory-boy==3.3.0
faker==20.1.0 faker==20.1.0

View file

@ -1,11 +1,14 @@
"""Pytest configuration and fixtures""" """Pytest configuration and fixtures"""
import logging import logging
from io import BytesIO
import pytest import pytest
from faker import Faker from faker import Faker
from PIL import Image
from app import create_app, db from app import create_app, db
from app.models import Order, OrderItem, Product, User from app.models import (Board, Card, Comment, List, Order, OrderItem, Product,
User)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@ -225,3 +228,91 @@ def order(db_session, regular_user, products):
order.total_amount = total_amount order.total_amount = total_amount
db_session.commit() db_session.commit()
return order return order
# ============ File Upload Fixtures ============
@pytest.fixture
def test_image_file():
"""Create a test image file as BytesIO"""
# Create a simple red PNG image using PIL
img = Image.new("RGB", (10, 10), color="red")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_large_image_file():
"""Create a larger test image (50KB)"""
# Create a larger image using PIL
img = Image.new("RGB", (200, 200), color="blue")
img_io = BytesIO()
img.save(img_io, format="PNG")
img_io.seek(0)
return img_io
@pytest.fixture
def test_pdf_file():
"""Create a test PDF file as BytesIO"""
# Minimal valid PDF
pdf_data = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
return BytesIO(pdf_data)
@pytest.fixture
def test_board(db_session, regular_user):
"""Create a test board"""
board = Board(
user_id=regular_user.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
)
db_session.add(board)
db_session.commit()
return board
@pytest.fixture
def test_list(db_session, test_board):
"""Create a test list"""
lst = List(
board_id=test_board.id,
name=fake.sentence(nb_words=3)[:-1],
pos=0,
)
db_session.add(lst)
db_session.commit()
return lst
@pytest.fixture
def test_card(db_session, test_list):
"""Create a test card"""
card = Card(
list_id=test_list.id,
name=fake.sentence(nb_words=4)[:-1],
description=fake.paragraph(),
pos=0,
due=None,
board_id=test_list.board_id,
)
db_session.add(card)
db_session.commit()
return card
@pytest.fixture
def test_comment(db_session, test_card, regular_user):
"""Create a test comment"""
comment = Comment(
card_id=test_card.id,
user_id=regular_user.id,
text=fake.paragraph(),
)
db_session.add(comment)
db_session.commit()
return comment

View file

@ -0,0 +1,372 @@
"""Integration tests for file upload routes"""
import pytest
@pytest.mark.integration
def test_upload_image_to_card(client, auth_headers, test_card, test_image_file):
"""Test uploading an image to a card"""
data = {
"file": (test_image_file, "test.png", "image/png"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
# Only check public-facing fields
assert file_data["original_name"] == "test.png"
assert file_data["file_type"] == "image"
assert file_data["file_size"] > 0
assert file_data["mime_type"] == "image/png" # Mime type is now exposed
assert "uuid" in file_data
assert "download_url" in file_data
assert "thumbnail_url" in file_data
assert "view_url" in file_data
assert "created_at" in file_data
# Security: Verify internal details are NOT exposed
assert "id" not in file_data
assert "filename" not in file_data
assert "minio_bucket" not in file_data
assert "minio_object_name" not in file_data
assert "thumbnail_minio_bucket" not in file_data
assert "thumbnail_minio_object_name" not in file_data
assert "attachable_id" not in file_data
assert "attachable_type" not in file_data
assert "uploaded_by" not in file_data
@pytest.mark.integration
def test_upload_pdf_to_card(client, auth_headers, test_card, test_pdf_file):
"""Test uploading a PDF to a card"""
data = {
"file": (test_pdf_file, "test.pdf", "application/pdf"),
}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "test.pdf"
assert file_data["file_type"] == "pdf" # PDFProcessor returns "pdf" as file_type
# PDFs don't have thumbnails
assert file_data.get("thumbnail_url") is None
@pytest.mark.integration
def test_upload_file_to_comment(client, auth_headers, test_comment, test_image_file):
"""Test uploading a file to a comment"""
data = {
"file": (test_image_file, "comment_image.png", "image/png"),
}
response = client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 201
file_data = response.get_json()
assert file_data["original_name"] == "comment_image.png"
assert file_data["file_type"] == "image"
@pytest.mark.integration
def test_get_card_attachments(client, auth_headers, test_card, test_image_file):
"""Test getting all attachments for a card"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
assert attachments_data["files"][0]["original_name"] == "test.png"
# Verify only public fields are exposed
assert "id" not in attachments_data["files"][0]
assert "attachable_id" not in attachments_data["files"][0]
@pytest.mark.integration
def test_get_comment_attachments(client, auth_headers, test_comment, test_image_file):
"""Test getting all attachments for a comment"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
client.post(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
# Get attachments
response = client.get(
f"/api/comments/{test_comment.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 1
assert len(attachments_data["files"]) == 1
@pytest.mark.integration
def test_get_file_by_uuid(client, auth_headers, test_card, test_image_file):
"""Test getting file info by UUID"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Get file by UUID
response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
file_data = response.get_json()
assert file_data["uuid"] == file_uuid
assert file_data["original_name"] == "test.png"
assert "download_url" in file_data
# Verify only public fields
assert "id" not in file_data
assert "attachable_id" not in file_data
@pytest.mark.integration
def test_delete_file(client, auth_headers, test_card, test_image_file):
"""Test deleting a file"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Delete file
response = client.delete(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert response.status_code == 200
delete_data = response.get_json()
assert delete_data["message"] == "File deleted"
# Verify file is deleted
get_response = client.get(
f"/api/files/{file_uuid}",
headers=auth_headers,
)
assert get_response.status_code == 404
@pytest.mark.integration
def test_download_file(client, auth_headers, test_card, test_image_file):
"""Test downloading a file through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download file
response = client.get(
f"/api/files/{file_uuid}/download",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "Content-Disposition" in response.headers
assert "test.png" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_view_file(client, auth_headers, test_card, test_image_file):
"""Test viewing a file inline through proxy"""
# Upload a file first
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# View file inline
response = client.get(
f"/api/files/{file_uuid}/view",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/png"
assert "inline" in response.headers["Content-Disposition"]
@pytest.mark.integration
def test_download_thumbnail(client, auth_headers, test_card, test_image_file):
"""Test downloading a file thumbnail"""
# Upload an image file
data = {"file": (test_image_file, "test.png", "image/png")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 200
assert response.content_type == "image/jpeg"
@pytest.mark.integration
def test_thumbnail_not_available_for_pdf(
client, auth_headers, test_card, test_pdf_file
):
"""Test that PDF files don't have thumbnails"""
# Upload a PDF file
data = {"file": (test_pdf_file, "test.pdf", "application/pdf")}
upload_response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
file_uuid = upload_response.get_json()["uuid"]
# Try to download thumbnail
response = client.get(
f"/api/files/{file_uuid}/thumbnail",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_upload_without_authentication(client, test_card, test_image_file):
"""Test uploading a file without authentication"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 401
@pytest.mark.integration
def test_get_card_attachments_empty(client, auth_headers, test_card):
"""Test getting attachments when card has none"""
response = client.get(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
)
assert response.status_code == 200
attachments_data = response.get_json()
assert attachments_data["count"] == 0
assert len(attachments_data["files"]) == 0
@pytest.mark.integration
def test_upload_without_file(client, auth_headers, test_card):
"""Test uploading without providing a file"""
data = {}
response = client.post(
f"/api/cards/{test_card.id}/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 400
@pytest.mark.integration
def test_upload_with_invalid_card_id(client, auth_headers, test_image_file):
"""Test uploading to a non-existent card"""
data = {"file": (test_image_file, "test.png", "image/png")}
response = client.post(
"/api/cards/99999/attachments",
headers=auth_headers,
data=data,
content_type="multipart/form-data",
)
assert response.status_code == 404
@pytest.mark.integration
def test_get_file_with_invalid_uuid(client, auth_headers):
"""Test getting a file with an invalid UUID"""
response = client.get(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.integration
def test_delete_file_with_invalid_uuid(client, auth_headers):
"""Test deleting a file with an invalid UUID"""
response = client.delete(
"/api/files/invalid-uuid-12345",
headers=auth_headers,
)
assert response.status_code == 404

View file

@ -23,8 +23,30 @@ services:
- "6381:6379" - "6381:6379"
restart: unless-stopped restart: unless-stopped
minio:
image: minio/minio:latest
container_name: kanban-app-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- kanban-app-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
volumes: volumes:
postgres-dev-data: postgres-dev-data:
minio-data:
networks: networks:
kanban-app-network: kanban-app-network:

View file

@ -55,6 +55,27 @@ services:
- crafting-shop-network - crafting-shop-network
restart: unless-stopped restart: unless-stopped
minio:
image: minio/minio:latest
container_name: crafting-shop-minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio-data:/data
networks:
- crafting-shop-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
celery_worker: celery_worker:
build: build:
context: ./backend context: ./backend
@ -163,6 +184,7 @@ volumes:
grafana-data: grafana-data:
backend-data: backend-data:
celery-beat-data: celery-beat-data:
minio-data:
networks: networks:
crafting-shop-network: crafting-shop-network:

View file

@ -17,12 +17,16 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",
@ -1054,6 +1058,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1614,6 +1623,15 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "dev": true
}, },
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -2766,6 +2784,11 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3057,6 +3080,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/direction": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
"integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
"bin": {
"direction": "cli.js"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@ -4486,6 +4521,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-map": { "node_modules/is-map": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@ -4544,6 +4584,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -4921,6 +4969,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -6210,6 +6263,14 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
"dependencies": {
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -6390,6 +6451,59 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/slate": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate/-/slate-0.123.0.tgz",
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg=="
},
"node_modules/slate-dom": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.123.0.tgz",
"integrity": "sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==",
"peer": true,
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"slate": ">=0.121.0"
}
},
"node_modules/slate-history": {
"version": "0.113.1",
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/slate-react": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.123.0.tgz",
"integrity": "sha512-nQwXL1FEacrY9ZFmatRhoBnsySNUX2x6qB77V3oNHd7wWxBJWuzz4GMrBXcVoRE8Gac7Angf8xaNGzb6zcPlHg==",
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"direction": "^1.0.4",
"is-hotkey": "^0.2.0",
"lodash": "^4.17.21",
"scroll-into-view-if-needed": "^3.1.0",
"tiny-invariant": "1.3.1"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0",
"slate": ">=0.121.0",
"slate-dom": ">=0.119.1"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -6734,6 +6848,11 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -7018,6 +7137,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",

View file

@ -23,12 +23,16 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"slate": "^0.123.0",
"slate-history": "^0.113.1",
"slate-react": "^0.123.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^25.5.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/eslint-plugin": "^8.56.1",

View file

@ -1,4 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useApp } from './context/AppContext';
import { ModalProvider } from './context/modals/useModal'; import { ModalProvider } from './context/modals/useModal';
import { ModalRoot } from './context/modals/ModalRoot'; import { ModalRoot } from './context/modals/ModalRoot';
import { ToastProvider } from './context/toasts/useToast'; import { ToastProvider } from './context/toasts/useToast';
@ -14,18 +16,43 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate'; import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit'; import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail'; import { BoardDetail } from './pages/BoardDetail';
import { BoardEpics } from './pages/BoardEpics';
import CreateEpic from './pages/CreateEpic';
import { EpicDetail } from './pages/EpicDetail';
import { BoardWikis } from './pages/BoardWikis';
import CreateWiki from './pages/CreateWiki';
import { WikiDetail } from './pages/WikiDetail';
import { CardDetail } from './pages/CardDetail'; import { CardDetail } from './pages/CardDetail';
const App = () => { const App = () => {
const { token } = useApp();
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
setIsAuthenticated(!!token);
}, [token]);
if (isAuthenticated === null) {
return null;
}
return ( return (
<LoaderProvider> <LoaderProvider>
<ToastProvider> <ToastProvider>
<ModalProvider> <ModalProvider>
<div className="min-h-screen bg-gray-900 text-gray-100"> <div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar /> <Navbar />
<main className="flex-1 p-8 max-w-7xl mx-auto w-full"> <main>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/boards" replace />} /> <Route
path="/"
element={
isAuthenticated ? (
<Navigate to="/boards" replace />
) : (
<Navigate to="/home" replace />
)
}
/>
<Route path="/home" element={<Home />} /> <Route path="/home" element={<Home />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
@ -63,6 +90,54 @@ const App = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/boards/:id/epics"
element={
<ProtectedRoute>
<BoardEpics />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/new"
element={
<ProtectedRoute>
<CreateEpic />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/epics/:epicId"
element={
<ProtectedRoute>
<EpicDetail />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis"
element={
<ProtectedRoute>
<BoardWikis />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/new"
element={
<ProtectedRoute>
<CreateWiki />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/wikis/:wikiId"
element={
<ProtectedRoute>
<WikiDetail />
</ProtectedRoute>
}
/>
<Route <Route
path="/boards/:id/cards/:cardId" path="/boards/:id/cards/:cardId"
element={ element={

View file

@ -0,0 +1,40 @@
import { Link, useLocation } from 'react-router-dom';
interface BoardSidebarProps {
boardId: string;
}
export function BoardSidebar({ boardId }: BoardSidebarProps) {
const location = useLocation();
const menuItems = [
{ id: 'epics', label: 'Epics', icon: '📋', path: `/boards/${boardId}/epics` },
{ id: 'wikis', label: 'Wikis', icon: '📚', path: `/boards/${boardId}/wikis` },
{ id: 'history', label: 'History', icon: '📜', path: `/boards/${boardId}/history` },
];
return (
<div className="w-64 bg-gray-800 rounded-lg p-4 border border-gray-700 h-fit sticky top-4">
<h3 className="text-white font-bold text-lg mb-4">Board Menu</h3>
<nav className="space-y-2">
{menuItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.id}
to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
>
<span className="text-lg">{item.icon}</span>
<span className="font-medium">{item.label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { useFileAttachments } from '../hooks/useFileAttachments';
import Trash2Icon from './icons/Trash2Icon';
import FileIcon from './icons/FileIcon';
import DownloadIcon from './icons/DownloadIcon';
import EyeIcon from './icons/EyeIcon';
import { SecureImage } from './SecureImage';
interface CardAttachmentsProps {
cardId?: number;
}
export function CardAttachments({ cardId }: CardAttachmentsProps) {
const { attachments, uploadFile, deleteAttachment } = useFileAttachments(cardId);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await uploadFile(file);
} catch {
// Error already handled in hook with toast
}
// Reset input
e.target.value = '';
};
const handleDelete = async (fileUuid: string, fileName: string) => {
if (!window.confirm(`Are you sure you want to delete "${fileName}"?`)) {
return;
}
await deleteAttachment(fileUuid);
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const isImage = (mimeType: string) => mimeType.startsWith('image/');
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<FileIcon />
</span>
Attachments
</h2>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">{attachments.length} files</span>
<label className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm">
Add Attachment
<input
type="file"
className="hidden"
onChange={handleFileUpload}
accept="image/*,.pdf"
/>
</label>
</div>
</div>
{attachments.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p>No attachments yet</p>
<p className="text-sm mt-2">Upload images or PDFs to this card</p>
</div>
) : (
<div className="space-y-3">
{attachments.map((attachment) => (
<div
key={attachment.uuid}
className="bg-gray-700 rounded-lg p-4 hover:bg-gray-650 transition-colors"
>
<div className="flex items-start gap-4">
{/* Thumbnail or file icon */}
<div className="flex-shrink-0">
{isImage(attachment.mime_type) && attachment.thumbnail_url ? (
<SecureImage
url={attachment.thumbnail_url}
alt={attachment.original_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-gray-600 rounded flex items-center justify-center">
<span className="w-8 h-8 text-gray-400">
<FileIcon />
</span>
</div>
)}
</div>
{/* File info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{attachment.original_name}</p>
<p className="text-gray-400 text-sm mt-1">
{formatFileSize(attachment.file_size)} {formatDate(attachment.created_at)}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{isImage(attachment.mime_type) && attachment.view_url && (
<a
href={attachment.view_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="View"
>
<span className="w-4 h-4">
<EyeIcon />
</span>
</a>
)}
{attachment.download_url && (
<a
href={attachment.download_url}
className="text-blue-400 hover:text-blue-300 transition-colors p-2"
title="Download"
>
<span className="w-4 h-4">
<DownloadIcon />
</span>
</a>
)}
<button
onClick={() => handleDelete(attachment.uuid, attachment.original_name)}
className="text-red-400 hover:text-red-300 transition-colors p-2"
title="Delete"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
const ELEMENT_TAGS: Record<string, (el: HTMLElement) => Partial<any>> = {
A: (el: HTMLElement) => ({
type: 'link',
url: el.getAttribute('href') || '',
children: [],
}),
BLOCKQUOTE: () => ({ type: 'block-quote', children: [] }),
H1: () => ({ type: 'heading-one', children: [] }),
H2: () => ({ type: 'heading-two', children: [] }),
H3: () => ({ type: 'heading-three', children: [] }),
H4: () => ({ type: 'heading-four', children: [] }),
H5: () => ({ type: 'heading-five', children: [] }),
H6: () => ({ type: 'heading-six', children: [] }),
IMG: (el: HTMLElement) => ({
type: 'image',
url: el.getAttribute('src') || '',
children: [],
}),
LI: () => ({ type: 'list-item', children: [] }),
OL: () => ({ type: 'numbered-list', children: [] }),
P: () => ({ type: 'paragraph', children: [] }),
PRE: () => ({ type: 'code-block', children: [] }),
CODE: () => ({ type: 'code-line bg-gray-900', children: [] }),
UL: () => ({ type: 'bulleted-list', children: [] }),
};
const TEXT_TAGS: Record<string, () => Record<string, boolean>> = {
CODE: () => ({ code: true }),
DEL: () => ({ strikethrough: true }),
EM: () => ({ italic: true }),
I: () => ({ italic: true }),
S: () => ({ strikethrough: true }),
STRONG: () => ({ bold: true }),
U: () => ({ underline: true }),
};
export { ELEMENT_TAGS, TEXT_TAGS };

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const NarrowPageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -1,27 +1,31 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useState } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { TaskboardLogo } from './TaskboardLogo'; import { TaskboardLogo } from './TaskboardLogo';
import MenuIcon from './icons/MenuIcon';
import CloseIcon from './icons/CloseIcon';
export function Navbar() { export function Navbar() {
const { user } = useApp(); const { user } = useApp();
const { logout } = useAuth(); const { logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return ( return (
<nav className="bg-gray-800 border-b border-gray-700 shadow-md"> <nav className="bg-gray-800 border-b border-gray-700 shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3"> <div className="flex items-center">
<Link to="/boards" className="hover:opacity-80 transition-opacity"> <Link to="/boards" className="hover:opacity-80 transition-opacity flex-shrink-0">
<TaskboardLogo className="h-8 w-auto" /> <TaskboardLogo className="h-8 w-auto" />
</Link> </Link>
<Link <Link
to="/boards" to="/boards"
className="text-xl font-bold text-white hover:text-blue-400 transition-colors" className="text-xl font-bold text-white hover:text-blue-400 transition-colors ml-2"
> >
Taskboard Taskboard
</Link> </Link>
<div className="ml-10 flex items-baseline space-x-4"> <div className="hidden md:flex items-baseline ml-10 space-x-4">
<Link <Link
to="/home" to="/home"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
@ -39,7 +43,7 @@ export function Navbar() {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
{user ? ( {user ? (
<> <>
<span className="text-gray-300 px-3 py-2">{user.username}</span> <span className="text-gray-300 px-3 py-2">{user.username}</span>
@ -67,8 +71,76 @@ export function Navbar() {
</> </>
)} )}
</div> </div>
<div className="md:hidden flex items-center">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-gray-300 hover:text-white p-2 rounded-md"
aria-label="Toggle menu"
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
</button>
</div>
</div> </div>
</div> </div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden bg-gray-800 border-t border-gray-700">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<Link
to="/home"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Home
</Link>
{user && (
<Link
to="/boards"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Boards
</Link>
)}
<div className="border-t border-gray-700 my-2"></div>
{user ? (
<>
<div className="px-3 py-2 text-gray-300 text-base font-medium">{user.username}</div>
<button
onClick={() => {
logout();
setMobileMenuOpen(false);
}}
className="text-gray-300 hover:text-white block w-full text-left px-3 py-2 rounded-md text-base font-medium"
>
Logout
</button>
</>
) : (
<>
<Link
to="/login"
className="text-gray-300 hover:text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Login
</Link>
<Link
to="/register"
className="bg-blue-600 hover:bg-blue-700 text-white block px-3 py-2 rounded-md text-base font-medium"
onClick={() => setMobileMenuOpen(false)}
>
Register
</Link>
</>
)}
</div>
</div>
)}
</nav> </nav>
); );
} }

View file

@ -0,0 +1,33 @@
import { useCallback, useMemo } from 'react';
import { createEditor, Descendant } from 'slate';
import { Slate, Editable, withReact, RenderElementProps, RenderLeafProps } from 'slate-react';
import { Leaf, SlateRenderElement, withHtml } from './slate-editor-components';
export interface RichTextContentProps {
content: Descendant[] | null;
className?: string;
}
const RichTextContent = ({ content }: any) => {
const editor = useMemo(() => withHtml(withReact(createEditor())), []);
// const editor = useMemo(() => withHtml(withHistory(withReact(createEditor()))), []);
const renderElement = useCallback(
(props: RenderElementProps) => <SlateRenderElement {...props} />,
[]
);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<Slate editor={editor} initialValue={content}>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
readOnly
placeholder="Enter some plain text..."
/>
</Slate>
);
};
export default RichTextContent;

View file

@ -0,0 +1,126 @@
import React, { useMemo, useCallback } from 'react';
import { createEditor, Descendant, Editor } from 'slate';
import {
Slate,
Editable,
withReact as withReactPlugin,
useSlate,
RenderElementProps,
RenderLeafProps,
} from 'slate-react';
import { withHistory } from 'slate-history';
import { CustomEditor, CustomTextKey } from './custom-types';
import {
BlockButton,
Button,
Icon,
Leaf,
SlateRenderElement,
Toolbar,
withHtml,
} from './slate-editor-components';
export interface RichTextEditorProps {
value: Descendant[];
onChange: (value: Descendant[]) => void;
placeholder?: string;
readOnly?: boolean;
className?: string;
}
interface MarkButtonProps {
format: CustomTextKey;
icon: string;
}
const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
const toggleMark = (editor: CustomEditor, format: CustomTextKey) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
const MarkButton = ({ format, icon }: MarkButtonProps) => {
const editor = useSlate();
return (
<Button
active={isMarkActive(editor, format)}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleMark(editor, format)}
>
<Icon>{icon}</Icon>
</Button>
);
};
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = 'Type something...',
readOnly = false,
className = '',
}) => {
const editor = useMemo(() => withHtml(withHistory(withReactPlugin(createEditor()))), []);
const renderElement = useCallback(
(props: RenderElementProps) => <SlateRenderElement {...props} />,
[]
);
// const renderElement = useCallback((props: any) => {
// switch (props.element.type) {
// case "block-quote":
// return <blockquote {...props.attributes}>{props.children}</blockquote>;
// case "bulleted-list":
// return <ul {...props.attributes}>{props.children}</ul>;
// case "list-item":
// return <li {...props.attributes}>{props.children}</li>;
// case "numbered-list":
// return <ol {...props.attributes}>{props.children}</ol>;
// default:
// return <p {...props.attributes}>{props.children}</p>;
// }
// }, []);
const renderLeaf = useCallback((props: RenderLeafProps) => <Leaf {...props} />, []);
return (
<div className={`bg-gray-800 rounded-lg border border-gray-700 ${className}`}>
<Slate editor={editor} initialValue={value} onChange={onChange}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
<BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" />
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
<BlockButton format="left" icon="format_align_left" />
<BlockButton format="center" icon="format_align_center" />
<BlockButton format="right" icon="format_align_right" />
<BlockButton format="justify" icon="format_align_justify" />
</Toolbar>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}
readOnly={readOnly}
className="min-h-[200px] p-4 text-gray-100 focus:outline-none"
spellCheck
/>
</Slate>
</div>
);
};
export default RichTextEditor;

View file

@ -0,0 +1,46 @@
import { useSecureImage } from '../hooks/useSecureImage';
interface SecureImageProps {
url: string;
alt: string;
className?: string;
fallback?: React.ReactNode;
}
export function SecureImage({ url, alt, className = '', fallback }: SecureImageProps) {
const { imageUrl, loading, error } = useSecureImage(url);
// Show loading state
if (loading) {
return <div className={`bg-gray-600 animate-pulse ${className}`}></div>;
}
// Show error or fallback
if (error || !imageUrl) {
return fallback ? (
<>{fallback}</>
) : (
<div className={`bg-gray-600 flex items-center justify-center ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-400"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
);
}
// Show image
return <img src={imageUrl} alt={alt} className={className} />;
}

View file

@ -0,0 +1,5 @@
import { ReactNode } from 'react';
export const WidePageLayout = ({ children }: { children: ReactNode }) => {
return <div className="flex-1 p-8 mx-auto w-full max-w-7xl">{children}</div>;
};

View file

@ -0,0 +1,196 @@
import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate';
import { ReactEditor } from 'slate-react';
import { HistoryEditor } from 'slate-history';
export type BlockQuoteElement = {
type: 'block-quote';
align?: string;
children: Descendant[];
};
export type BulletedListElement = {
type: 'bulleted-list';
align?: string;
children: Descendant[];
};
export type CheckListItemElement = {
type: 'check-list-item';
checked: boolean;
children: Descendant[];
};
export type EditableVoidElement = {
type: 'editable-void';
children: EmptyText[];
};
export type HeadingElement = {
type: 'heading-one';
align?: string;
children: Descendant[];
};
export type HeadingTwoElement = {
type: 'heading-two';
align?: string;
children: Descendant[];
};
export type HeadingThreeElement = {
type: 'heading-three';
align?: string;
children: Descendant[];
};
export type HeadingFourElement = {
type: 'heading-four';
align?: string;
children: Descendant[];
};
export type HeadingFiveElement = {
type: 'heading-five';
align?: string;
children: Descendant[];
};
export type HeadingSixElement = {
type: 'heading-six';
align?: string;
children: Descendant[];
};
export type ImageElement = {
type: 'image';
url: string;
children: EmptyText[];
};
export type LinkElement = { type: 'link'; url: string; children: Descendant[] };
export type ButtonElement = { type: 'button'; children: Descendant[] };
export type BadgeElement = { type: 'badge'; children: Descendant[] };
export type ListItemElement = { type: 'list-item'; children: Descendant[] };
export type NumberedListItemElement = {
type: 'numbered-list';
children: Descendant[];
};
export type MentionElement = {
type: 'mention';
character: string;
children: CustomText[];
};
export type ParagraphElement = {
type: 'paragraph';
align?: string;
children: Descendant[];
};
export type TableElement = { type: 'table'; children: TableRow[] };
export type TableCellElement = { type: 'table-cell'; children: CustomText[] };
export type TableRowElement = { type: 'table-row'; children: TableCell[] };
export type TitleElement = { type: 'title'; children: Descendant[] };
export type VideoElement = { type: 'video'; url: string; children: EmptyText[] };
export type CodeBlockElement = {
type: 'code-block';
language: string;
children: Descendant[];
};
export type CodeLineElement = {
type: 'code-line';
children: Descendant[];
};
export type CustomElementWithAlign =
| ParagraphElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| BlockQuoteElement
| BulletedListElement;
type CustomElement =
| BlockQuoteElement
| BulletedListElement
| CheckListItemElement
| EditableVoidElement
| HeadingElement
| HeadingTwoElement
| HeadingThreeElement
| HeadingFourElement
| HeadingFiveElement
| HeadingSixElement
| ImageElement
| LinkElement
| ButtonElement
| BadgeElement
| ListItemElement
| NumberedListItemElement
| MentionElement
| ParagraphElement
| TableElement
| TableRowElement
| TableCellElement
| TitleElement
| VideoElement
| CodeBlockElement
| CodeLineElement;
export type CustomElementType = CustomElement['type'];
export type CustomText = {
bold?: boolean;
italic?: boolean;
code?: boolean;
underline?: boolean;
strikethrough?: boolean;
// MARKDOWN PREVIEW SPECIFIC LEAF
underlined?: boolean;
title?: boolean;
list?: boolean;
hr?: boolean;
blockquote?: boolean;
text: string;
};
export type CustomTextKey = keyof Omit<CustomText, 'text'>;
export type EmptyText = {
text: string;
};
export type RenderElementPropsFor<T> = RenderElementProps & {
element: T;
};
export type CustomEditor = BaseEditor &
ReactEditor &
HistoryEditor & {
nodeToDecorations?: Map<Element, Range[]>;
};
declare module 'slate' {
interface CustomTypes {
Editor: CustomEditor;
Element: CustomElement;
Text: CustomText;
Range: BaseRange & {
[key: string]: unknown;
};
}
}

View file

@ -0,0 +1,18 @@
const CloseIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
export default CloseIcon;

View file

@ -0,0 +1,19 @@
const DownloadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
);
export default DownloadIcon;

View file

@ -0,0 +1,18 @@
const EyeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
);
export default EyeIcon;

View file

@ -0,0 +1,18 @@
const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
);
export default FileIcon;

View file

@ -0,0 +1,19 @@
const MenuIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
);
export default MenuIcon;

View file

@ -0,0 +1,16 @@
const isImageUrl = (url: string): boolean => {
if (!url) return false;
try {
new URL(url); // Validate URL format
} catch {
return false;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif'];
const ext = url.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.includes(ext) : false;
};
export default isImageUrl;

View file

@ -0,0 +1,302 @@
import React, { ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { RenderElementProps, RenderLeafProps, useSlate } from 'slate-react';
import {
CustomEditor,
CustomElement,
CustomElementType,
CustomElementWithAlign,
} from '../custom-types';
import { Editor, Node, Transforms, Element as SlateElement } from 'slate';
import { deserialize } from '../useRichTextEditor';
const LIST_TYPES = ['numbered-list', 'bulleted-list'] as const;
const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify'] as const;
type AlignType = (typeof TEXT_ALIGN_TYPES)[number];
// --- Interfaces ---
// Extend standard HTML attributes to include className, id, etc. automatically
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
active: boolean;
reversed?: boolean;
}
type ListType = (typeof LIST_TYPES)[number];
type CustomElementFormat = CustomElementType | AlignType | ListType;
const isAlignType = (format: CustomElementFormat): format is AlignType => {
return TEXT_ALIGN_TYPES.includes(format as AlignType);
};
const isListType = (format: CustomElementFormat): format is ListType => {
return LIST_TYPES.includes(format as ListType);
};
// --- Components ---
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, active, reversed, ...props }, ref) => {
// Determine color based on props logic from original CSS
let colorClass = '';
if (reversed) {
colorClass = active ? 'text-white' : 'text-[#aaa]';
} else {
colorClass = active ? 'text-black' : 'text-[#ccc]';
}
return (
<button
{...props}
type="button"
ref={ref}
className={`border-none bg-transparent p-0 cursor-pointer ${colorClass} ${className ?? ''}`}
/>
);
}
);
Button.displayName = 'Button';
export const Icon = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
({ className, ...props }, ref) => (
<span
{...props}
ref={ref}
// 'material-icons' is kept as it likely comes from a global CSS link
className={`material-icons text-[18px] align-text-bottom ${className ?? ''}`}
/>
)
);
Icon.displayName = 'Icon';
export const Instruction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={`whitespace-pre-wrap my-0 mx-[-20px] mb-[10px] p-[10px] px-[20px] text-[14px] bg-[#f8f8e8] ${className ?? ''}`}
/>
)
);
Instruction.displayName = 'Instruction';
export const Menu = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
{...props}
data-test-id="menu"
ref={ref}
// Replaced inline-block + margin-left logic with flex + gap
className={`flex flex-wrap items-center gap-[15px] ${className ?? ''}`}
/>
)
);
Menu.displayName = 'Menu';
export const Portal = ({ children }: { children?: ReactNode }) => {
return typeof document === 'object' ? ReactDOM.createPortal(children, document.body) : null;
};
export const Toolbar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<Menu
{...props}
ref={ref}
className={`relative px-[18px] py-[1px] pb-[17px] mx-[-20px] mb-5 border-b-2 border-[#eee] ${className ?? ''}`}
/>
)
);
Toolbar.displayName = 'Toolbar';
export const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
if (leaf.bold) {
children = <strong className="font-bold">{children}</strong>;
}
if (leaf.code) {
children = (
<code className="bg-gray-200 text-gray-800 px-1 py-0.5 rounded font-mono text-sm border border-gray-300">
{children}
</code>
);
}
if (leaf.italic) {
children = <em className="italic">{children}</em>;
}
if (leaf.underline) {
children = <u className="underline">{children}</u>;
}
return <span {...attributes}>{children}</span>;
};
const isAlignElement = (element: CustomElement): element is CustomElementWithAlign => {
return 'align' in element;
};
export const SlateRenderElement = ({ attributes, children, element }: RenderElementProps) => {
console.log('element.type', element);
switch (element.type) {
case 'block-quote':
return (
<blockquote
className="border-l-4 border-gray-300 pl-4 italic my-4 text-gray-700"
{...attributes}
>
{children}
</blockquote>
);
case 'code-block':
return (
<pre
className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto my-4 font-mono text-sm border border-gray-300"
{...attributes}
>
<code>{children}</code>
</pre>
);
case 'bulleted-list':
return (
<ul className="list-disc pl-5 my-4" {...attributes}>
{children}
</ul>
);
case 'heading-one':
return (
<h1 className="text-4xl font-bold my-4" {...attributes}>
{children}
</h1>
);
case 'heading-two':
return (
<h2 className="text-3xl font-bold my-3" {...attributes}>
{children}
</h2>
);
case 'heading-three':
return (
<h2 className="text-1xl font-bold my-2" {...attributes}>
{children}
</h2>
);
case 'list-item':
return (
<li className="my-1" {...attributes}>
{children}
</li>
);
case 'numbered-list':
return (
<ol className="list-decimal pl-5 my-4" {...attributes}>
{children}
</ol>
);
default:
return (
<div className="inline my-2 mr-2 leading-relaxed prose prose-sm" {...attributes}>
{children}
</div>
);
}
};
const isBlockActive = (
editor: CustomEditor,
format: CustomElementFormat,
blockType: 'type' | 'align' = 'type'
) => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) => {
if (Node.isElement(n)) {
if (blockType === 'align' && isAlignElement(n)) {
return n.align === format;
}
return n.type === format;
}
return false;
},
})
);
return !!match;
};
const toggleBlock = (editor: CustomEditor, format: CustomElementFormat) => {
const isActive = isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type');
const isList = isListType(format);
Transforms.unwrapNodes(editor, {
match: (n) => Node.isElement(n) && isListType(n.type) && !isAlignType(format),
split: true,
});
let newProperties: Partial<SlateElement>;
if (isAlignType(format)) {
newProperties = {
align: isActive ? undefined : format,
};
} else {
newProperties = {
type: isActive ? 'paragraph' : isList ? 'list-item' : format,
};
}
Transforms.setNodes<SlateElement>(editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] } as any;
Transforms.wrapNodes(editor, block);
}
};
interface BlockButtonProps {
format: CustomElementFormat;
icon: string;
}
export const BlockButton = ({ format, icon }: BlockButtonProps) => {
const editor = useSlate();
return (
<Button
active={isBlockActive(editor, format, isAlignType(format) ? 'align' : 'type')}
onPointerDown={(event: any) => event.preventDefault()}
onClick={() => toggleBlock(editor, format)}
data-test-id={`block-button-${format}`}
>
<Icon>{icon}</Icon>
</Button>
);
};
export const withHtml = (editor: CustomEditor) => {
const { insertData, isInline, isVoid } = editor;
editor.isInline = (element: any) => {
return element.type === 'link' ? true : isInline(element);
};
editor.isVoid = (element: any) => {
return element.type === 'image' ? true : isVoid(element);
};
editor.insertData = (data) => {
const html = data.getData('text/html');
if (html) {
const parsed = new DOMParser().parseFromString(html, 'text/html');
const fragment = deserialize(parsed.body);
Transforms.insertFragment(editor, fragment);
return;
}
insertData(data);
};
return editor;
};

View file

@ -267,6 +267,87 @@ export function useApi() {
deleteComment: async (id: number): Promise<void> => { deleteComment: async (id: number): Promise<void> => {
await api.delete(`/comments/${id}`); await api.delete(`/comments/${id}`);
}, },
// File Attachments
getCardAttachments: async (cardId: number): Promise<any> => {
const response = await api.get(`/cards/${cardId}/attachments`);
return response.data;
},
uploadCardAttachment: async (
cardId: number,
file: File,
entityType: string = 'Card'
): Promise<any> => {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
formData.append('entity_id', cardId.toString());
const response = await api.post(`/cards/${cardId}/attachments`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
deleteFile: async (fileUuid: string): Promise<void> => {
await api.delete(`/files/${fileUuid}`);
},
getSecureImage: async (url: string): Promise<Blob> => {
const response = await api.get(url, {
responseType: 'blob',
});
return response.data;
},
// Epics
getEpics: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/epics`);
return response.data;
},
createEpic: async (boardId: number, epicData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/epics`, epicData);
return response.data;
},
getEpic: async (epicId: number): Promise<any> => {
const response = await api.get(`/epics/${epicId}`);
return response.data;
},
updateEpic: async (epicId: number, epicData: any): Promise<any> => {
const response = await api.put(`/epics/${epicId}`, epicData);
return response.data;
},
deleteEpic: async (epicId: number): Promise<void> => {
await api.delete(`/epics/${epicId}`);
},
addEpicToCard: async (cardId: number, epicId: number): Promise<any> => {
const response = await api.post(`/cards/${cardId}/epics`, { epic_id: epicId });
return response.data;
},
removeEpicFromCard: async (cardId: number, epicId: number): Promise<void> => {
await api.delete(`/cards/${cardId}/epics/${epicId}`);
},
// Wikis
getWikis: async (boardId: number): Promise<any> => {
const response = await api.get(`/boards/${boardId}/wikis`);
return response.data;
},
createWiki: async (boardId: number, wikiData: any): Promise<any> => {
const response = await api.post(`/boards/${boardId}/wikis`, wikiData);
return response.data;
},
getWiki: async (wikiId: number): Promise<any> => {
const response = await api.get(`/wikis/${wikiId}`);
return response.data;
},
updateWiki: async (wikiId: number, wikiData: any): Promise<any> => {
const response = await api.put(`/wikis/${wikiId}`, wikiData);
return response.data;
},
deleteWiki: async (wikiId: number): Promise<void> => {
await api.delete(`/wikis/${wikiId}`);
},
}), }),
[] []
); );

View file

@ -45,7 +45,6 @@ export function useAuth() {
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
err.response?.data.error || err.message || 'Login failed. Please try again.'; err.response?.data.error || err.message || 'Login failed. Please try again.';
// debugger
// Show error toast // Show error toast
addNotification({ addNotification({
type: 'error', type: 'error',
@ -76,7 +75,6 @@ export function useAuth() {
}; };
// Store in localStorage first // Store in localStorage first
// debugger
localStorage.setItem('token', response.access_token); localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Epic, UpdateEpicRequest } from '../types/epic';
function useEpicDetail(epicId: string) {
const [epic, setEpic] = useState<Epic | null>(null);
const [error, setError] = useState<Error | null>(null);
const { getEpic, updateEpic: apiUpdateEpic, deleteEpic: apiDeleteEpic } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchEpic = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getEpic(Number(epicId)), 'Loading epic...');
setEpic(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Epic',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [epicId, getEpic, withLoader, addNotification]);
const updateEpic = useCallback(
async (epicData: UpdateEpicRequest) => {
try {
setError(null);
const updatedEpic = await withLoader(
() => apiUpdateEpic(Number(epicId), epicData),
'Updating epic...'
);
setEpic(updatedEpic);
addNotification({
type: 'success',
title: 'Epic Updated',
message: 'Epic updated successfully.',
duration: 3000,
});
return updatedEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[epicId, apiUpdateEpic, withLoader, addNotification]
);
const deleteEpic = useCallback(async () => {
try {
setError(null);
await withLoader(() => apiDeleteEpic(Number(epicId)), 'Deleting epic...');
addNotification({
type: 'success',
title: 'Epic Deleted',
message: 'Epic deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
}, [epicId, apiDeleteEpic, withLoader, addNotification]);
useEffect(() => {
if (epicId) {
fetchEpic();
}
}, [epicId, fetchEpic]);
return {
epic,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
updateEpic,
deleteEpic,
refetch: fetchEpic,
};
}
export default useEpicDetail;

View file

@ -0,0 +1,144 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Epic, CreateEpicRequest, UpdateEpicRequest } from '../types/epic';
function useEpics(boardId: string) {
const [epics, setEpics] = useState<Epic[]>([]);
const [error, setError] = useState<Error | null>(null);
const {
getEpics,
createEpic: apiCreateEpic,
updateEpic: apiUpdateEpic,
deleteEpic: apiDeleteEpic,
} = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchEpics = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getEpics(Number(boardId)), 'Loading epics...');
setEpics(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load epics';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Epics',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getEpics, withLoader, addNotification]);
const createEpic = useCallback(
async (epicData: CreateEpicRequest) => {
try {
setError(null);
const newEpic = await withLoader(
() => apiCreateEpic(Number(boardId), epicData),
'Creating epic...'
);
setEpics((prev) => [...prev, newEpic]);
addNotification({
type: 'success',
title: 'Epic Created',
message: `Epic "${epicData.name}" created successfully.`,
duration: 3000,
});
return newEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[boardId, apiCreateEpic, withLoader, addNotification]
);
const updateEpic = useCallback(
async (epicId: number, epicData: UpdateEpicRequest) => {
try {
setError(null);
const updatedEpic = await withLoader(
() => apiUpdateEpic(epicId, epicData),
'Updating epic...'
);
setEpics((prev) => prev.map((epic) => (epic.id === epicId ? updatedEpic : epic)));
addNotification({
type: 'success',
title: 'Epic Updated',
message: 'Epic updated successfully.',
duration: 3000,
});
return updatedEpic;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiUpdateEpic, withLoader, addNotification]
);
const deleteEpic = useCallback(
async (epicId: number) => {
try {
setError(null);
await withLoader(() => apiDeleteEpic(epicId), 'Deleting epic...');
setEpics((prev) => prev.filter((epic) => epic.id !== epicId));
addNotification({
type: 'success',
title: 'Epic Deleted',
message: 'Epic deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete epic';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Epic',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiDeleteEpic, withLoader, addNotification]
);
useEffect(() => {
fetchEpics();
}, [fetchEpics]);
return {
epics,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchEpics,
createEpic,
updateEpic,
deleteEpic,
refetch: fetchEpics,
};
}
export default useEpics;

View file

@ -0,0 +1,129 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { FileAttachment } from '../types/kanban';
export function useFileAttachments(cardId?: number) {
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getCardAttachments, uploadCardAttachment, deleteFile } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchAttachments = useCallback(async () => {
if (!cardId) {
return [];
}
try {
setError(null);
const data = await withLoader(() => getCardAttachments(cardId), 'Loading attachments...');
setAttachments(data.files || []);
return data.files || [];
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load attachments';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Attachments',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [getCardAttachments, cardId, withLoader, addNotification]);
const uploadFile = useCallback(
async (file: File) => {
if (!cardId) {
return null;
}
try {
setError(null);
// Validate file size (10MB for images, 50MB for documents)
const maxSize = file.type.startsWith('image/') ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeMB = file.type.startsWith('image/') ? '10MB' : '50MB';
throw new Error(`File size exceeds ${maxSizeMB} limit`);
}
const data = await withLoader(
() => uploadCardAttachment(cardId, file, 'Card'),
'Uploading file...'
);
// Add new attachment to state
setAttachments((prev) => [...prev, data]);
addNotification({
type: 'success',
title: 'File Uploaded',
message: `Successfully uploaded ${file.name}`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to upload file';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Upload Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[uploadCardAttachment, cardId, withLoader, addNotification]
);
const deleteAttachment = useCallback(
async (fileUuid: string) => {
try {
setError(null);
await withLoader(() => deleteFile(fileUuid), 'Deleting file...');
// Remove attachment from state
setAttachments((prev) => prev.filter((a) => a.uuid !== fileUuid));
addNotification({
type: 'success',
title: 'File Deleted',
message: 'File deleted successfully',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete file';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Delete Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[deleteFile, withLoader, addNotification]
);
useEffect(() => {
if (cardId) {
fetchAttachments();
}
}, [cardId, fetchAttachments]);
return {
attachments,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
refetch: fetchAttachments,
uploadFile,
deleteAttachment,
};
}

View file

@ -0,0 +1,51 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
interface UseSecureImageReturn {
imageUrl: string | null;
loading: boolean;
error: Error | null;
}
export function useSecureImage(url: string): UseSecureImageReturn {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const { getSecureImage } = useApi();
const fetchImage = useCallback(async () => {
if (!url) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// Fetch image with authentication
const blob = await getSecureImage(url);
// Create blob URL
const blobUrl = URL.createObjectURL(blob);
setImageUrl(blobUrl);
} catch (err) {
console.error('Error fetching image:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to load image';
setError(new Error(errorMessage));
} finally {
setLoading(false);
}
}, [url, getSecureImage]);
useEffect(() => {
fetchImage();
}, [fetchImage]);
return {
imageUrl,
loading,
error, // For debugging
};
}

View file

@ -0,0 +1,105 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Wiki, UpdateWikiRequest } from '../types/epic';
function useWikiDetail(wikiId: string) {
const [wiki, setWiki] = useState<Wiki | null>(null);
const [error, setError] = useState<Error | null>(null);
const { getWiki, updateWiki: apiUpdateWiki, deleteWiki: apiDeleteWiki } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchWiki = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getWiki(Number(wikiId)), 'Loading wiki...');
setWiki(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Wiki',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [wikiId, getWiki, withLoader, addNotification]);
const updateWiki = useCallback(
async (wikiData: UpdateWikiRequest) => {
try {
setError(null);
const updatedWiki = await withLoader(
() => apiUpdateWiki(Number(wikiId), wikiData),
'Updating wiki...'
);
setWiki(updatedWiki);
addNotification({
type: 'success',
title: 'Wiki Updated',
message: 'Wiki updated successfully.',
duration: 3000,
});
return updatedWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[wikiId, apiUpdateWiki, withLoader, addNotification]
);
const deleteWiki = useCallback(async () => {
try {
setError(null);
await withLoader(() => apiDeleteWiki(Number(wikiId)), 'Deleting wiki...');
addNotification({
type: 'success',
title: 'Wiki Deleted',
message: 'Wiki deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
}, [wikiId, apiDeleteWiki, withLoader, addNotification]);
useEffect(() => {
if (wikiId) {
fetchWiki();
}
}, [wikiId, fetchWiki]);
return {
wiki,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
updateWiki,
deleteWiki,
refetch: fetchWiki,
};
}
export default useWikiDetail;

View file

@ -0,0 +1,144 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import type { Wiki, CreateWikiRequest, UpdateWikiRequest } from '../types/epic';
function useWikis(boardId: string) {
const [wikis, setWikis] = useState<Wiki[]>([]);
const [error, setError] = useState<Error | null>(null);
const {
getWikis,
createWiki: apiCreateWiki,
updateWiki: apiUpdateWiki,
deleteWiki: apiDeleteWiki,
} = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchWikis = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getWikis(Number(boardId)), 'Loading wikis...');
setWikis(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load wikis';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Wikis',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getWikis, withLoader, addNotification]);
const createWiki = useCallback(
async (wikiData: CreateWikiRequest) => {
try {
setError(null);
const newWiki = await withLoader(
() => apiCreateWiki(Number(boardId), wikiData),
'Creating wiki...'
);
setWikis((prev) => [...prev, newWiki]);
addNotification({
type: 'success',
title: 'Wiki Created',
message: `Wiki "${wikiData.name}" created successfully.`,
duration: 3000,
});
return newWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[boardId, apiCreateWiki, withLoader, addNotification]
);
const updateWiki = useCallback(
async (wikiId: number, wikiData: UpdateWikiRequest) => {
try {
setError(null);
const updatedWiki = await withLoader(
() => apiUpdateWiki(wikiId, wikiData),
'Updating wiki...'
);
setWikis((prev) => prev.map((wiki) => (wiki.id === wikiId ? updatedWiki : wiki)));
addNotification({
type: 'success',
title: 'Wiki Updated',
message: 'Wiki updated successfully.',
duration: 3000,
});
return updatedWiki;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiUpdateWiki, withLoader, addNotification]
);
const deleteWiki = useCallback(
async (wikiId: number) => {
try {
setError(null);
await withLoader(() => apiDeleteWiki(wikiId), 'Deleting wiki...');
setWikis((prev) => prev.filter((wiki) => wiki.id !== wikiId));
addNotification({
type: 'success',
title: 'Wiki Deleted',
message: 'Wiki deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete wiki';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Wiki',
message: errorMessage,
duration: 5000,
});
throw err;
}
},
[apiDeleteWiki, withLoader, addNotification]
);
useEffect(() => {
fetchWikis();
}, [fetchWikis]);
return {
wikis,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchWikis,
createWiki,
updateWiki,
deleteWiki,
refetch: fetchWikis,
};
}
export default useWikis;

View file

@ -5,6 +5,7 @@ import { useListMutations } from '../hooks/useListMutations';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn'; import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal'; import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal'; import { CardPreviewModal } from '../components/CardPreviewModal';
import { BoardSidebar } from '../components/BoardSidebar';
import { useModal } from '../context/modals/useModal'; import { useModal } from '../context/modals/useModal';
import { import {
DndContext, DndContext,
@ -20,6 +21,7 @@ import {
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { Card as CardType, ListWithCards } from '../types/kanban'; import { Card as CardType, ListWithCards } from '../types/kanban';
import { useState } from 'react'; import { useState } from 'react';
import { WidePageLayout } from '@/components/WidePageLayout';
export function BoardDetail() { export function BoardDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -65,12 +67,8 @@ export function BoardDetail() {
const handleDragOver = (event: DragOverEvent) => { const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
// console.log('---handleDragOver', event);
if (!over) return; if (!over) return;
// const activeId = parseInt(active.id as string);
// const overId = parseInt(over.id as string);
const overIdStr = (over.id as string).split('_')[1]; const overIdStr = (over.id as string).split('_')[1];
const overId = parseInt(overIdStr, 10); const overId = parseInt(overIdStr, 10);
const activeIdStr = (active.id as string).split('_')[1]; const activeIdStr = (active.id as string).split('_')[1];
@ -105,8 +103,6 @@ export function BoardDetail() {
// Handle column reordering // Handle column reordering
if (activeType === 'COLUMN') { if (activeType === 'COLUMN') {
// todo find over column id,
let overListIndex = -1; let overListIndex = -1;
const activeList = board.lists.find((l) => l.id === activeId); const activeList = board.lists.find((l) => l.id === activeId);
if (overType === 'CARD') { if (overType === 'CARD') {
@ -118,12 +114,7 @@ export function BoardDetail() {
overListIndex = board.lists.findIndex((l) => l.id === overId); overListIndex = board.lists.findIndex((l) => l.id === overId);
} }
// console.log('-------active.id', active.id)
// console.log('-------overType.id', overType)
// console.log('-------overListIndex', overListIndex)
const activeListIndex = board.lists.findIndex((l) => l.id === activeId); const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
// overListIndex = board.lists.findIndex((l) => l.id === overId);
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return; if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
@ -133,15 +124,6 @@ export function BoardDetail() {
reorderedLists.splice(overListIndex, 0, movedList); reorderedLists.splice(overListIndex, 0, movedList);
await updateList(activeList.id, { name: activeList.name, pos: overListIndex }); await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
// // Update all list positions
// for (let i = 0; i < reorderedLists.length; i++) {
// const list = reorderedLists[i];
// if (list.pos !== i) {
// await updateList(list.id, { name: list.name, pos: i });
// }
// }
return; return;
} }
@ -236,71 +218,81 @@ export function BoardDetail() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-start"> <WidePageLayout>
<div> <div className="flex justify-between items-start">
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm"> <div>
Back to Boards <Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
</Link> Back to Boards
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1> </Link>
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>} <h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
</div>
<div className="flex gap-2">
<Link
to={`/boards/${id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Edit Board
</Link>
<button
onClick={handleAddList}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
+ Add List
</button>
</div>
</div> </div>
<div className="flex gap-2"> </WidePageLayout>
<Link
to={`/boards/${id}/edit`} <div className="px-4 sm:px-6 lg:px-8 max-h-[calc(100vh-280px)] overflow-auto">
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors" <div className="flex justify-between items-start gap-6">
> <div className="flex-1">
Edit Board <DndContext
</Link> sensors={sensors}
<button collisionDetection={closestCenter}
onClick={handleAddList} onDragStart={handleDragStart}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors" onDragOver={handleDragOver}
> onDragEnd={handleDragEnd}
+ Add List >
</button> <SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
<BoardSidebar boardId={id || ''} />
</div> </div>
</div> </div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
); );
} }

View file

@ -0,0 +1,236 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import useEpics from '../hooks/useEpics';
import RichTextContent from '../components/RichTextContent';
import type { Epic } from '../types/epic';
const PlusIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
);
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
const Trash2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
export function BoardEpics() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { epics, deleteEpic } = useEpics(id || '0');
const handleDeleteEpic = async (epicId: number, epicName: string) => {
if (confirm(`Are you sure you want to delete epic "${epicName}"?`)) {
try {
await deleteEpic(epicId);
} catch {
// Error is handled by the hook
}
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div className="flex items-center justify-between">
<div>
<Link
to={`/boards/${id}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Board
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Epics</h1>
<p className="text-gray-400 mt-1">
Manage and view epics for this board ({epics.length} epics)
</p>
</div>
<Link
to={`/boards/${id}/epics/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create Epic
</Link>
</div>
</WidePageLayout>
<WidePageLayout>
{epics.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div className="text-center py-12">
<div className="text-6xl mb-4">📋</div>
<h2 className="text-xl font-bold text-white mb-2">No Epics Yet</h2>
<p className="text-gray-400 mb-6">
Create your first epic to start organizing your work.
</p>
<Link
to={`/boards/${id}/epics/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg inline-flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create First Epic
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{epics.map((epic) => (
<EpicCard
key={epic.id}
epic={epic}
onClick={() => navigate(`/boards/${id}/epics/${epic.id}`)}
onDelete={() => handleDeleteEpic(epic.id, epic.name)}
onEdit={() => navigate(`/boards/${id}/epics/${epic.id}/edit`)}
/>
))}
</div>
)}
</WidePageLayout>
</div>
);
}
interface EpicCardProps {
epic: Epic;
onClick: () => void;
onDelete: () => void;
onEdit: () => void;
}
function EpicCard({ epic, onClick, onDelete, onEdit }: EpicCardProps) {
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div
className="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-all cursor-pointer overflow-hidden group"
onClick={onClick}
>
{/* Color header */}
<div className="h-2" style={{ backgroundColor: color }} />
<div className="p-4">
{/* Header with actions */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-white flex-1">{epic.name}</h3>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit epic"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete epic"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
</div>
{/* Description */}
{epic.description && (
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{epic.description}</p>
)}
{/* Rich text content preview */}
{epic.content && Array.isArray(epic.content) && epic.content.length > 0 && (
<div className="mb-3">
<RichTextContent
content={epic.content}
className="text-sm text-gray-400 line-clamp-3"
/>
</div>
)}
{/* Footer with metrics */}
<div className="flex items-center justify-between text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<span>📊</span>
{epic.metrics?.card_count || 0} cards
</span>
{epic.parent_epic_id && (
<span className="flex items-center gap-1">
<span>🔄</span>
Sub-epic
</span>
)}
</div>
{epic.date_last_activity && (
<span>{new Date(epic.date_last_activity).toLocaleDateString()}</span>
)}
</div>
{/* Closed badge */}
{epic.closed && (
<div className="mt-3">
<span className="inline-block px-2 py-1 bg-gray-700 text-gray-400 text-xs rounded">
Closed
</span>
</div>
)}
</div>
</div>
);
}
export default BoardEpics;

View file

@ -0,0 +1,211 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useWikis from '../hooks/useWikis';
import type { Wiki } from '../types/epic';
const PlusIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
);
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
const Trash2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
export function BoardWikis() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { wikis, deleteWiki } = useWikis(id || '0');
const handleDeleteWiki = async (wikiId: number, wikiName: string) => {
if (confirm(`Are you sure you want to delete wiki "${wikiName}"?`)) {
try {
await deleteWiki(wikiId);
} catch {
// Error is handled by the hook
}
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div className="flex items-center justify-between">
<div>
<Link
to={`/boards/${id}`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Board
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Wikis</h1>
<p className="text-gray-400 mt-1">
Manage and view wikis for this board ({wikis.length} wikis)
</p>
</div>
<Link
to={`/boards/${id}/wikis/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create Wiki
</Link>
</div>
</WidePageLayout>
<WidePageLayout>
{wikis.length === 0 ? (
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<h2 className="text-xl font-bold text-white mb-2">No Wikis Yet</h2>
<p className="text-gray-400 mb-6">
Create your first wiki to start documenting your knowledge.
</p>
<Link
to={`/boards/${id}/wikis/new`}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg inline-flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create First Wiki
</Link>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{wikis.map((wiki) => (
<WikiCard
key={wiki.id}
wiki={wiki}
onClick={() => navigate(`/boards/${id}/wikis/${wiki.id}`)}
onDelete={() => handleDeleteWiki(wiki.id, wiki.name)}
onEdit={() => navigate(`/boards/${id}/wikis/${wiki.id}/edit`)}
/>
))}
</div>
)}
</WidePageLayout>
</div>
);
}
interface WikiCardProps {
wiki: Wiki;
onClick: () => void;
onDelete: () => void;
onEdit: () => void;
}
function WikiCard({ wiki, onClick, onDelete, onEdit }: WikiCardProps) {
return (
<div
className="bg-gray-800 rounded-lg border border-gray-700 hover:border-gray-600 transition-all cursor-pointer overflow-hidden group"
onClick={onClick}
>
<div className="p-4">
{/* Header with actions */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-white flex-1">{wiki.name}</h3>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit wiki"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete wiki"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
</div>
{/* Summary */}
{wiki.summary && <p className="text-gray-400 text-sm mb-3 line-clamp-2">{wiki.summary}</p>}
{/* Rich text content preview */}
{wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && (
<div className="mb-3">
<RichTextContent
content={wiki.content}
className="text-sm text-gray-400 line-clamp-3"
/>
</div>
)}
{/* Footer with metadata */}
<div className="flex items-center justify-between text-xs text-gray-500 mt-3 pt-3 border-t border-gray-700">
<div className="flex items-center gap-4">
{wiki.category && (
<span className="px-2 py-1 bg-gray-700 text-gray-400 rounded">{wiki.category}</span>
)}
{wiki.tags && wiki.tags.length > 0 && <span>{wiki.tags.length} tag(s)</span>}
</div>
{wiki.updated_at && <span>{new Date(wiki.updated_at).toLocaleDateString()}</span>}
</div>
</div>
</div>
);
}
export default BoardWikis;

View file

@ -1,42 +1,45 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useBoards } from '../hooks/useBoards'; import { useBoards } from '../hooks/useBoards';
import { BoardCard } from '../components/kanban/BoardCard'; import { BoardCard } from '../components/kanban/BoardCard';
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
export function Boards() { export function Boards() {
const { boards, deleteBoard } = useBoards(); const { boards, deleteBoard } = useBoards();
return ( return (
<div className="space-y-6"> <NarrowPageLayout>
<div className="flex justify-between items-center"> <div className="space-y-6">
<div> <div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1> <div>
<p className="text-gray-400">Manage your Kanban boards</p> <h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
</div> <p className="text-gray-400">Manage your Kanban boards</p>
<Link </div>
to="/boards/new"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
+ Create Board
</Link>
</div>
{boards.length === 0 ? (
<div className="text-center py-16">
<p className="text-gray-400 text-lg mb-4">No boards yet</p>
<Link <Link
to="/boards/new" to="/boards/new"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block" className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
> >
Create your first board + Create Board
</Link> </Link>
</div> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {boards.length === 0 ? (
{boards.map((board) => ( <div className="text-center py-16">
<BoardCard key={board.id} board={board} onDelete={deleteBoard} /> <p className="text-gray-400 text-lg mb-4">No boards yet</p>
))} <Link
</div> to="/boards/new"
)} className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block"
</div> >
Create your first board
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{boards.map((board) => (
<BoardCard key={board.id} board={board} onDelete={deleteBoard} />
))}
</div>
)}
</div>
</NarrowPageLayout>
); );
} }

View file

@ -9,11 +9,13 @@ import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments'; import { CardComments } from '../components/CardComments';
import { CardChecklists } from '../components/CardChecklists'; import { CardChecklists } from '../components/CardChecklists';
import { CardLabels } from '../components/CardLabels'; import { CardLabels } from '../components/CardLabels';
import { CardAttachments } from '../components/CardAttachments';
import { EditCardModal } from '../components/EditCardModal'; import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal'; import { DeleteCardModal } from '../components/DeleteCardModal';
import Trash2Icon from '../components/icons/Trash2Icon'; import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon'; import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon'; import Edit2Icon from '../components/icons/Edit2Icon';
import { NarrowPageLayout } from '@/components/NarrowPageLayout';
export function CardDetail() { export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>(); const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
@ -84,7 +86,7 @@ export function CardDetail() {
}; };
return ( return (
<div className="space-y-6"> <NarrowPageLayout>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<Link <Link
@ -164,6 +166,8 @@ export function CardDetail() {
removeCheckItem={checklistMutations.removeCheckItem} removeCheckItem={checklistMutations.removeCheckItem}
/> />
<CardAttachments cardId={cardId ? parseInt(cardId) : undefined} />
<CardComments <CardComments
card={card} card={card}
addComment={addComment} addComment={addComment}
@ -175,6 +179,6 @@ export function CardDetail() {
<CardSidebar card={card} /> <CardSidebar card={card} />
</div> </div>
</div> </NarrowPageLayout>
); );
} }

View file

@ -0,0 +1,203 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useEpics from '../hooks/useEpics';
import type { CreateEpicRequest } from '../types/epic';
const CheckSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 11 12 13 22 2"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
);
const COLOR_PRESETS = [
'#ef4444', // Red
'#f97316', // Orange
'#eab308', // Yellow
'#22c55e', // Green
'#14b8a6', // Teal
'#3b82f6', // Blue
'#8b5cf6', // Purple
'#ec4899', // Pink
'#6b7280', // Gray
];
export function CreateEpic() {
const { id: boardId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { createEpic } = useEpics(boardId || '0');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState(COLOR_PRESETS[5]); // Default to blue
const [content, setContent] = useState<any[]>([{ children: [{ text: '' }] }]);
const [parentEpicId, setParentEpicId] = useState<number | undefined>(undefined);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
const epicData: CreateEpicRequest = {
name: name.trim(),
description: description.trim() || undefined,
content:
content.length > 1 || (content[0] as any).children[0].text !== ''
? (content as unknown as Record<string, unknown>)
: undefined,
color,
parent_epic_id: parentEpicId,
};
try {
const newEpic = await createEpic(epicData);
navigate(`/boards/${boardId}/epics/${newEpic.id}`);
} catch {
// Error is handled by hook
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/epics`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Epics
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Create Epic</h1>
<p className="text-gray-400 mt-1">Create a new epic for this board</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg border border-gray-700 p-6">
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Epic Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter epic name..."
required
/>
</div>
{/* Description */}
<div className="mb-6">
<label htmlFor="description" className="block text-white font-medium mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief description of this epic..."
/>
</div>
{/* Color */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Color</label>
<div className="flex flex-wrap gap-3">
{COLOR_PRESETS.map((presetColor) => (
<button
key={presetColor}
type="button"
onClick={() => setColor(presetColor)}
className={`w-10 h-10 rounded-lg border-2 transition-all ${
color === presetColor
? 'border-white ring-2 ring-white ring-opacity-50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: presetColor }}
title={presetColor}
/>
))}
</div>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Epic Details</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Add detailed information about this epic..."
className="border-0"
/>
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Parent Epic */}
<div className="mb-6">
<label htmlFor="parentEpic" className="block text-white font-medium mb-2">
Parent Epic
</label>
<select
id="parentEpic"
value={parentEpicId || ''}
onChange={(e) =>
setParentEpicId(e.target.value ? parseInt(e.target.value, 10) : undefined)
}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
>
<option value="">No parent (top-level epic)</option>
{/* Parent epics would be loaded here */}
</select>
<p className="text-gray-500 text-xs mt-2">
Select a parent epic to create a hierarchy. Leave empty for a top-level epic.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/epics`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Create Epic
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default CreateEpic;

View file

@ -0,0 +1,200 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextEditor from '../components/RichTextEditor';
import useWikis from '../hooks/useWikis';
import type { CreateWikiRequest } from '../types/epic';
const CheckSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 11 12 13 22 2"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
);
export function CreateWiki() {
const { id: boardId } = useParams<{ id: string }>();
const navigate = useNavigate();
const { createWiki } = useWikis(boardId || '0');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [summary, setSummary] = useState('');
const [category, setCategory] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState<any[]>([{ children: [{ text: '' }] }]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
return;
}
const wikiData: CreateWikiRequest = {
name: name.trim(),
slug: slug.trim() || undefined,
summary: summary.trim() || undefined,
content:
content.length > 1 || (content[0] as any).children[0].text !== ''
? (content as unknown as Record<string, unknown>)
: { children: [{ text: '' }] },
category: category.trim() || undefined,
tags: tags.trim() ? tags.split(',').map((t) => t.trim()) : undefined,
};
try {
const newWiki = await createWiki(wikiData);
navigate(`/boards/${boardId}/wikis/${newWiki.id}`);
} catch {
// Error is handled by hook
}
};
return (
<div className="space-y-6">
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/wikis`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Wikis
</Link>
<h1 className="text-3xl font-bold text-white mt-2">Create Wiki</h1>
<p className="text-gray-400 mt-1">Create a new wiki for this board</p>
</div>
</WidePageLayout>
<WidePageLayout>
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-lg border border-gray-700 p-6">
{/* Name */}
<div className="mb-6">
<label htmlFor="name" className="block text-white font-medium mb-2">
Wiki Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="Enter wiki name..."
required
/>
</div>
{/* Slug */}
<div className="mb-6">
<label htmlFor="slug" className="block text-white font-medium mb-2">
URL Slug (optional)
</label>
<input
type="text"
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="url-friendly-slug"
/>
<p className="text-gray-500 text-xs mt-2">
Leave empty to auto-generate from name. Used in URLs.
</p>
</div>
{/* Summary */}
<div className="mb-6">
<label htmlFor="summary" className="block text-white font-medium mb-2">
Summary
</label>
<textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500 resize-none"
rows={3}
placeholder="Brief summary of this wiki..."
/>
</div>
{/* Category */}
<div className="mb-6">
<label htmlFor="category" className="block text-white font-medium mb-2">
Category
</label>
<input
type="text"
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="e.g., Documentation, Guides, Tutorials"
/>
</div>
{/* Tags */}
<div className="mb-6">
<label htmlFor="tags" className="block text-white font-medium mb-2">
Tags
</label>
<input
type="text"
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
placeholder="tag1, tag2, tag3"
/>
<p className="text-gray-500 text-xs mt-2">Separate multiple tags with commas.</p>
</div>
{/* Rich Text Content */}
<div className="mb-6">
<label className="block text-white font-medium mb-2">Wiki Content</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Add detailed information to your wiki..."
className="border-0"
/>
<p className="text-gray-500 text-xs mt-2">
Use the rich text editor to add formatted content, lists, and more.
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t border-gray-700">
<Link
to={`/boards/${boardId}/wikis`}
className="px-6 py-2 rounded-lg border border-gray-600 text-gray-300 hover:text-white hover:border-gray-500 transition-colors"
>
Cancel
</Link>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Create Wiki
</button>
</div>
</form>
</WidePageLayout>
</div>
);
}
export default CreateWiki;

View file

@ -0,0 +1,262 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useEpicDetail from '../hooks/useEpicDetail';
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
const Trash2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
const TagIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
);
export function EpicDetail() {
const { id: boardId, epicId } = useParams<{ id: string; epicId: string }>();
const navigate = useNavigate();
const { epic, deleteEpic } = useEpicDetail(epicId || '0');
const handleDelete = async () => {
if (!epic) return;
if (confirm(`Are you sure you want to delete epic "${epic.name}"?`)) {
try {
await deleteEpic();
navigate(`/boards/${boardId}/epics`);
} catch {
// Error is already handled by the hook with toast
}
}
};
if (!epic) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading epic...</div>
</div>
</WidePageLayout>
</div>
);
}
const defaultColor = '#3b82f6';
const color = epic.color || defaultColor;
return (
<div className="space-y-6">
{/* Header */}
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/epics`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Epics
</Link>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3">
<div className="w-1 h-12 rounded" style={{ backgroundColor: color }} />
<div>
<h1 className="text-3xl font-bold text-white">{epic.name}</h1>
{epic.closed && (
<span className="inline-block px-2 py-1 bg-gray-700 text-gray-400 text-xs rounded mt-1">
Closed
</span>
)}
</div>
</div>
<div className="flex gap-2">
<Link
to={`/boards/${boardId}/epics/${epic.id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit
</Link>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete
</button>
</div>
</div>
</div>
</WidePageLayout>
{/* Content */}
<WidePageLayout>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
{/* Color header bar */}
<div className="h-2" style={{ backgroundColor: color }} />
<div className="p-6">
{/* Description */}
{epic.description && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-white mb-2">Description</h2>
<p className="text-gray-300">{epic.description}</p>
</div>
)}
{/* Rich Text Content */}
{epic.content && Array.isArray(epic.content) && epic.content.length > 0 && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-white mb-2">Details</h2>
<div className="bg-gray-900 rounded-lg p-4 border border-gray-700">
<RichTextContent content={epic.content} />
</div>
</div>
)}
{/* Metrics */}
<div className="mb-6 pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metrics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>📊</span>
<span className="text-sm">Cards</span>
</div>
<div className="text-2xl font-bold text-white">
{epic.metrics?.card_count || 0}
</div>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>📈</span>
<span className="text-sm">Progress</span>
</div>
<div className="text-2xl font-bold text-white">
{epic.metrics?.progress || 0}%
</div>
</div>
{epic.depth_limit !== null && epic.depth_limit !== undefined && (
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>🔄</span>
<span className="text-sm">Max Depth</span>
</div>
<div className="text-2xl font-bold text-white">{epic.depth_limit}</div>
</div>
)}
{epic.parent_epic_id && (
<div className="bg-gray-900 rounded-lg p-4">
<div className="flex items-center gap-2 text-gray-400 mb-1">
<span>🔗</span>
<span className="text-sm">Parent</span>
</div>
<div className="text-lg font-bold text-white">Yes</div>
</div>
)}
</div>
</div>
{/* Metadata */}
<div className="pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metadata</h2>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created:</span>
<span className="text-gray-300">
{epic.created_at ? new Date(epic.created_at).toLocaleString() : 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated:</span>
<span className="text-gray-300">
{epic.updated_at ? new Date(epic.updated_at).toLocaleString() : 'N/A'}
</span>
</div>
{epic.date_last_activity && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Last Activity:</span>
<span className="text-gray-300">
{new Date(epic.date_last_activity).toLocaleString()}
</span>
</div>
)}
</div>
</div>
{/* Links to Cards */}
<div className="pt-6 border-t border-gray-700 mt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">Linked Cards</h2>
<Link to={`/boards/${boardId}`} className="text-blue-500 hover:underline text-sm">
View in Board
</Link>
</div>
{epic.metrics?.card_count === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">📭</div>
<p>No cards linked to this epic yet</p>
</div>
) : (
<div className="text-center py-8 text-gray-400">
<div className="flex items-center justify-center gap-2">
<span className="w-4 h-4">
<TagIcon />
</span>
<span>{epic.metrics?.card_count} card(s) linked to this epic</span>
</div>
</div>
)}
</div>
</div>
</div>
</WidePageLayout>
</div>
);
}
export default EpicDetail;

View file

@ -6,7 +6,7 @@ import { TaskboardLogo } from '../components/TaskboardLogo';
export function Home() { export function Home() {
return ( return (
<div className="space-y-12"> <div className="space-y-12 flex-1 p-8 max-w-7xl mx-auto w-full">
<div className="text-center py-12"> <div className="text-center py-12">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<TaskboardLogo className="h-16 w-auto" /> <TaskboardLogo className="h-16 w-auto" />

View file

@ -0,0 +1,202 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { WidePageLayout } from '../components/WidePageLayout';
import RichTextContent from '../components/RichTextContent';
import useWikiDetail from '../hooks/useWikiDetail';
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
const Trash2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
export function WikiDetail() {
const { id: boardId, wikiId } = useParams<{ id: string; wikiId: string }>();
const navigate = useNavigate();
const { wiki, deleteWiki } = useWikiDetail(wikiId || '0');
const handleDelete = async () => {
if (!wiki) return;
if (confirm(`Are you sure you want to delete wiki "${wiki.name}"?`)) {
try {
await deleteWiki();
navigate(`/boards/${boardId}/wikis`);
} catch {
// Error is already handled by hook with toast
}
}
};
if (!wiki) {
return (
<div className="space-y-6">
<WidePageLayout>
<div className="text-center py-12">
<div className="text-gray-400">Loading wiki...</div>
</div>
</WidePageLayout>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<WidePageLayout>
<div>
<Link
to={`/boards/${boardId}/wikis`}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Back to Wikis
</Link>
<div className="flex items-center justify-between mt-2">
<div>
<h1 className="text-3xl font-bold text-white">{wiki.name}</h1>
{wiki.slug && <span className="text-gray-400 text-sm">/{wiki.slug}</span>}
</div>
<div className="flex gap-2">
<Link
to={`/boards/${boardId}/wikis/${wiki.id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
Edit
</Link>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete
</button>
</div>
</div>
</div>
</WidePageLayout>
{/* Content */}
<WidePageLayout>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div className="p-6">
{/* Summary */}
{wiki.summary && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-2">Summary</h2>
<p className="text-gray-300">{wiki.summary}</p>
</div>
)}
{/* Rich Text Content */}
{wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-2">Content</h2>
<div className="bg-gray-900 rounded-lg p-6 border border-gray-700">
<RichTextContent content={wiki.content} />
</div>
</div>
)}
{/* Tags */}
{wiki.tags && wiki.tags.length > 0 && (
<div className="mb-6 pb-6 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Tags</h2>
<div className="flex flex-wrap gap-2">
{wiki.tags.map((tag, index) => (
<span
key={index}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="pt-6 border-t border-gray-700">
<h2 className="text-lg font-semibold text-white mb-3">Metadata</h2>
<div className="space-y-2 text-sm">
{wiki.category && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Category:</span>
<span className="px-2 py-1 bg-gray-700 text-gray-300 rounded text-sm">
{wiki.category}
</span>
</div>
)}
{wiki.created_by && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created By:</span>
<span className="text-gray-300">User #{wiki.created_by}</span>
</div>
)}
{wiki.updated_by && (
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated By:</span>
<span className="text-gray-300">User #{wiki.updated_by}</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Created:</span>
<span className="text-gray-300">
{wiki.created_at ? new Date(wiki.created_at).toLocaleString() : 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 w-24">Updated:</span>
<span className="text-gray-300">
{wiki.updated_at ? new Date(wiki.updated_at).toLocaleString() : 'N/A'}
</span>
</div>
</div>
</div>
{/* Linked Entities Section */}
<div className="pt-6 border-t border-gray-700 mt-6">
<h2 className="text-lg font-semibold text-white mb-3">Linked Entities</h2>
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">🔗</div>
<p>This wiki can be linked to cards and other entities</p>
</div>
</div>
</div>
</div>
</WidePageLayout>
</div>
);
}
export default WikiDetail;

View file

@ -0,0 +1,81 @@
export interface Epic {
id: number;
name: string;
description: string | null;
content: Record<string, unknown> | null;
color: string | null;
closed: boolean;
pos: number | null;
depth_limit: number | null;
board_id: number;
parent_epic_id: number | null;
date_last_activity: string | null;
created_at: string | null;
updated_at: string | null;
metrics: {
card_count?: number;
progress?: number;
} | null;
}
export interface Wiki {
id: number;
name: string;
slug: string | null;
content: Record<string, unknown>;
summary: string | null;
category: string | null;
board_id: number;
created_by: number | null;
updated_by: number | null;
created_at: string | null;
updated_at: string | null;
tags: string[] | null;
}
export interface CreateEpicRequest {
name: string;
description?: string;
content?: Record<string, unknown>;
color?: string;
pos?: number;
depth_limit?: number;
parent_epic_id?: number | null;
}
export interface UpdateEpicRequest {
name?: string;
description?: string;
content?: Record<string, unknown>;
color?: string;
closed?: boolean;
pos?: number;
depth_limit?: number;
parent_epic_id?: number | null;
}
export interface CreateWikiRequest {
name: string;
slug?: string;
content: Record<string, unknown>;
summary?: string;
category?: string;
tags?: string[];
}
export interface UpdateWikiRequest {
name?: string;
slug?: string;
content?: Record<string, unknown>;
summary?: string;
category?: string;
tags?: string[];
}
export interface WikiEntityLink {
wiki_id: number;
entity_type: string;
entity_id: number;
created_at: string | null;
linked_by: number | null;
}

View file

@ -183,3 +183,16 @@ export interface User {
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
} }
// File Attachment types
export interface FileAttachment {
uuid: string;
original_name: string;
file_type: string;
file_size: number;
mime_type: string;
created_at: string;
download_url: string;
thumbnail_url: string | null;
view_url: string;
}

View file

@ -4,7 +4,10 @@
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View file

@ -1,9 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {