Compare commits
No commits in common. "d2db3c46303bd2a54a68079315f1e11a8ed54d2f" and "ab2970aa5a22b84a1dca1fe5f878c3af47254a70" have entirely different histories.
d2db3c4630
...
ab2970aa5a
82 changed files with 128 additions and 8026 deletions
10
.env.example
10
.env.example
|
|
@ -17,16 +17,6 @@ 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
4
.gitignore
vendored
|
|
@ -83,6 +83,4 @@ htmlcov/
|
||||||
|
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
|
||||||
backend/app/static
|
backend/app/static
|
||||||
|
|
||||||
.clinerules
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
# 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
|
|
||||||
7
Makefile
7
Makefile
|
|
@ -21,10 +21,6 @@ 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
|
||||||
|
|
@ -66,9 +62,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,3 @@ 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
|
|
||||||
|
|
@ -40,13 +40,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,31 +46,11 @@ 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
|
||||||
|
|
||||||
|
|
@ -83,31 +63,6 @@ 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"""
|
||||||
|
|
@ -120,31 +75,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ 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,
|
from app.models import Board, Card, CheckItem, Checklist, Comment, List
|
||||||
FileAttachment, List)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user_id():
|
def get_current_user_id():
|
||||||
|
|
@ -158,166 +157,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,11 @@ 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",
|
||||||
|
|
@ -26,7 +23,4 @@ __all__ = [
|
||||||
"Checklist",
|
"Checklist",
|
||||||
"CheckItem",
|
"CheckItem",
|
||||||
"Comment",
|
"Comment",
|
||||||
"FileAttachment",
|
|
||||||
"Epic",
|
|
||||||
"Wiki",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,6 @@ 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)
|
||||||
|
|
@ -60,14 +57,6 @@ 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"""
|
||||||
|
|
@ -82,7 +71,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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}>"
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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})>"
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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}>"
|
|
||||||
|
|
@ -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, epics, # noqa: F401 E402
|
from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402
|
||||||
files, labels, lists, wikis)
|
lists)
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
"""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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -7,17 +7,9 @@ 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
|
||||||
|
|
@ -44,25 +36,7 @@ __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",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
"""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",
|
|
||||||
]
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
"""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"
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
"""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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
"""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())
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
"""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 ###
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"""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 ###
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"""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 ###
|
|
||||||
|
|
@ -10,11 +10,9 @@ 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
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
"""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 (Board, Card, Comment, List, Order, OrderItem, Product,
|
from app.models import Order, OrderItem, Product, User
|
||||||
User)
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
@ -228,91 +225,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -23,30 +23,8 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -55,27 +55,6 @@ 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
|
||||||
|
|
@ -184,7 +163,6 @@ 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:
|
||||||
|
|
|
||||||
125
frontend/package-lock.json
generated
125
frontend/package-lock.json
generated
|
|
@ -17,16 +17,12 @@
|
||||||
"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",
|
||||||
|
|
@ -1058,11 +1054,6 @@
|
||||||
"@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",
|
||||||
|
|
@ -1623,15 +1614,6 @@
|
||||||
"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",
|
||||||
|
|
@ -2784,11 +2766,6 @@
|
||||||
"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",
|
||||||
|
|
@ -3080,18 +3057,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4521,11 +4486,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4584,14 +4544,6 @@
|
||||||
"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",
|
||||||
|
|
@ -4969,11 +4921,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6263,14 +6210,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6451,59 +6390,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6848,11 +6734,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7137,12 +7018,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,12 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
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';
|
||||||
|
|
@ -16,43 +14,18 @@ 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>
|
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/" element={<Navigate to="/boards" replace />} />
|
||||||
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 />} />
|
||||||
|
|
@ -90,54 +63,6 @@ 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={
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
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>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
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">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/boards" className="hover:opacity-80 transition-opacity flex-shrink-0">
|
<Link to="/boards" className="hover:opacity-80 transition-opacity">
|
||||||
<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 ml-2"
|
className="text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
Taskboard
|
Taskboard
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden md:flex items-baseline ml-10 space-x-4">
|
<div className="ml-10 flex items-baseline 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"
|
||||||
|
|
@ -43,7 +39,7 @@ export function Navbar() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center gap-3">
|
<div className="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>
|
||||||
|
|
@ -71,76 +67,8 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
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>;
|
|
||||||
};
|
|
||||||
196
frontend/src/components/custom-types.d.ts
vendored
196
frontend/src/components/custom-types.d.ts
vendored
|
|
@ -1,196 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
@ -267,87 +267,6 @@ 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}`);
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ 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',
|
||||||
|
|
@ -75,6 +76,7 @@ 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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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,
|
||||||
|
|
@ -21,7 +20,6 @@ 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 }>();
|
||||||
|
|
@ -67,8 +65,12 @@ 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];
|
||||||
|
|
@ -103,6 +105,8 @@ 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') {
|
||||||
|
|
@ -114,7 +118,12 @@ 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;
|
||||||
|
|
||||||
|
|
@ -124,6 +133,15 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,81 +236,71 @@ export function BoardDetail() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<WidePageLayout>
|
<div className="flex justify-between items-start">
|
||||||
<div className="flex justify-between items-start">
|
<div>
|
||||||
<div>
|
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
||||||
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
|
← Back to Boards
|
||||||
← Back to Boards
|
</Link>
|
||||||
</Link>
|
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
|
||||||
<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>}
|
||||||
{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>
|
||||||
</WidePageLayout>
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
<div className="px-4 sm:px-6 lg:px-8 max-h-[calc(100vh-280px)] overflow-auto">
|
to={`/boards/${id}/edit`}
|
||||||
<div className="flex justify-between items-start gap-6">
|
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
<div className="flex-1">
|
>
|
||||||
<DndContext
|
Edit Board
|
||||||
sensors={sensors}
|
</Link>
|
||||||
collisionDetection={closestCenter}
|
<button
|
||||||
onDragStart={handleDragStart}
|
onClick={handleAddList}
|
||||||
onDragOver={handleDragOver}
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
onDragEnd={handleDragEnd}
|
>
|
||||||
>
|
+ Add List
|
||||||
<SortableContext
|
</button>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,45 +1,42 @@
|
||||||
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 (
|
||||||
<NarrowPageLayout>
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex justify-between items-center">
|
<div>
|
||||||
<div>
|
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">My Boards</h1>
|
<p className="text-gray-400">Manage your Kanban boards</p>
|
||||||
<p className="text-gray-400">Manage your Kanban boards</p>
|
</div>
|
||||||
</div>
|
<Link
|
||||||
|
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-4 rounded-lg transition-colors"
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors inline-block"
|
||||||
>
|
>
|
||||||
+ Create Board
|
Create your first board
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{boards.length === 0 ? (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div className="text-center py-16">
|
{boards.map((board) => (
|
||||||
<p className="text-gray-400 text-lg mb-4">No boards yet</p>
|
<BoardCard key={board.id} board={board} onDelete={deleteBoard} />
|
||||||
<Link
|
))}
|
||||||
to="/boards/new"
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,11 @@ 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 }>();
|
||||||
|
|
@ -86,7 +84,7 @@ export function CardDetail() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NarrowPageLayout>
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -166,8 +164,6 @@ 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}
|
||||||
|
|
@ -179,6 +175,6 @@ export function CardDetail() {
|
||||||
|
|
||||||
<CardSidebar card={card} />
|
<CardSidebar card={card} />
|
||||||
</div>
|
</div>
|
||||||
</NarrowPageLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { TaskboardLogo } from '../components/TaskboardLogo';
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 flex-1 p-8 max-w-7xl mx-auto w-full">
|
<div className="space-y-12">
|
||||||
<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" />
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -183,16 +183,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@
|
||||||
"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"]
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue