From f5166b0fda75fcbdd45a4ec20bf08827e0f3c6b9 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 22 Mar 2026 14:52:33 +0300 Subject: [PATCH] add epics and wikis features --- EPIC_WIKI_IMPLEMENTATION_SUMMARY.md | 338 ++++++++++++++++++ EPIC_WIKI_MODELS_SUMMARY.md | 238 ++++++++++++ backend/app/config.py | 12 + backend/app/models/__init__.py | 4 + backend/app/models/card.py | 4 + backend/app/models/epic.py | 80 +++++ backend/app/models/wiki.py | 88 +++++ backend/app/routes/kanban/__init__.py | 4 +- backend/app/routes/kanban/epics.py | 158 ++++++++ backend/app/routes/kanban/wikis.py | 127 +++++++ backend/app/schemas/__init__.py | 15 + backend/app/schemas/epic.py | 132 +++++++ backend/app/schemas/wiki.py | 167 +++++++++ .../app/services/storage/storage_extension.py | 17 +- .../6fc439155ced_add_epic_and_wiki_models.py | 106 ++++++ frontend/package-lock.json | 109 ++++++ frontend/package.json | 3 + frontend/src/App.tsx | 45 +++ frontend/src/components/BoardSidebar.tsx | 2 +- frontend/src/components/ELEMENT_TAGS.ts | 37 ++ frontend/src/components/RichTextContent.tsx | 33 ++ frontend/src/components/RichTextEditor.tsx | 126 +++++++ frontend/src/components/custom-types.d.ts | 196 ++++++++++ frontend/src/components/isImageUrl.ts | 16 + .../slate-editor-components/index.tsx | 329 +++++++++++++++++ frontend/src/hooks/useApi.ts | 49 +++ frontend/src/hooks/useEpicDetail.ts | 105 ++++++ frontend/src/hooks/useEpics.ts | 144 ++++++++ frontend/src/hooks/useWikiDetail.ts | 105 ++++++ frontend/src/hooks/useWikis.ts | 144 ++++++++ frontend/src/pages/BoardDetail.tsx | 2 +- frontend/src/pages/BoardEpics.tsx | 231 +++++++++++- frontend/src/pages/BoardWikis.tsx | 211 +++++++++++ frontend/src/pages/CreateEpic.tsx | 203 +++++++++++ frontend/src/pages/CreateWiki.tsx | 200 +++++++++++ frontend/src/pages/EpicDetail.tsx | 262 ++++++++++++++ frontend/src/pages/WikiDetail.tsx | 202 +++++++++++ frontend/src/types/epic.ts | 81 +++++ 38 files changed, 4295 insertions(+), 30 deletions(-) create mode 100644 EPIC_WIKI_IMPLEMENTATION_SUMMARY.md create mode 100644 EPIC_WIKI_MODELS_SUMMARY.md create mode 100644 backend/app/models/epic.py create mode 100644 backend/app/models/wiki.py create mode 100644 backend/app/routes/kanban/epics.py create mode 100644 backend/app/routes/kanban/wikis.py create mode 100644 backend/app/schemas/epic.py create mode 100644 backend/app/schemas/wiki.py create mode 100644 backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py create mode 100644 frontend/src/components/ELEMENT_TAGS.ts create mode 100644 frontend/src/components/RichTextContent.tsx create mode 100644 frontend/src/components/RichTextEditor.tsx create mode 100644 frontend/src/components/custom-types.d.ts create mode 100644 frontend/src/components/isImageUrl.ts create mode 100644 frontend/src/components/slate-editor-components/index.tsx create mode 100644 frontend/src/hooks/useEpicDetail.ts create mode 100644 frontend/src/hooks/useEpics.ts create mode 100644 frontend/src/hooks/useWikiDetail.ts create mode 100644 frontend/src/hooks/useWikis.ts create mode 100644 frontend/src/pages/BoardWikis.tsx create mode 100644 frontend/src/pages/CreateEpic.tsx create mode 100644 frontend/src/pages/CreateWiki.tsx create mode 100644 frontend/src/pages/EpicDetail.tsx create mode 100644 frontend/src/pages/WikiDetail.tsx create mode 100644 frontend/src/types/epic.ts diff --git a/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md b/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f31d12e --- /dev/null +++ b/EPIC_WIKI_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,338 @@ +# Epic & Wiki Feature Implementation Summary + +## Overview +This document summarizes the implementation of the Epic and Wiki features for the Kanban application. + +## What Was Implemented + +### Backend (Flask) + +#### 1. Database Models + +**Epic Model** (`backend/app/models/epic.py`) +- Rich text content support (JSON for Slate.js) +- Hierarchical structure (parent_epic_id for nesting) +- Color coding +- Position ordering +- Board-level scoping +- Soft delete support (closed field) +- Depth limit for hierarchy control + +**Wiki Model** (`backend/app/models/wiki.py`) +- Rich text content (JSON for Slate.js) +- URL-friendly slugs +- Summary field +- Categories for organization +- Tags support +- Created by / Updated by tracking +- Board-level scoping + +**Card Model Update** (`backend/app/models/card.py`) +- Added `epic_id` foreign key to link cards to epics + +**Association Table** (`wiki_entity_links`) +- Many-to-many relationship between Wikis and entity types +- Supports linking Wikis to Cards, Epics, and future entity types + +#### 2. Database Migration +- Created migration file: `6fc439155ced_add_epic_and_wiki_models.py` +- Adds all new tables and relationships + +### Frontend (React/TypeScript) + +#### 1. TypeScript Types (`frontend/src/types/epic.ts`) +```typescript +- Epic interface +- Wiki interface +- CreateEpicRequest / UpdateEpicRequest +- CreateWikiRequest / UpdateWikiRequest +- WikiEntityLink interface +``` + +#### 2. Components + +**RichTextEditor** (`frontend/src/components/RichTextEditor.tsx`) +- Slate.js-based rich text editor +- Editable with proper styling +- Dark mode support +- Placeholder support +- Read-only mode option + +**RichTextContent** (`frontend/src/components/RichTextContent.tsx`) +- Read-only renderer for Slate.js content +- Supports paragraphs, lists, blockquotes +- Text formatting (bold, italic, underline, code) +- Dark mode styling + +#### 3. Custom Hook + +**useEpics** (`frontend/src/hooks/useEpics.ts`) +- Fetch all epics for a board +- Create new epic +- Update existing epic +- Delete epic +- Integrated with global loader and toast notifications +- Error handling with user-friendly messages + +#### 4. API Integration + +Updated `useApi` hook with epic methods: +```typescript +- getEpics(boardId) +- createEpic(boardId, epicData) +- getEpic(epicId) +- updateEpic(epicId, epicData) +- deleteEpic(epicId) +- addEpicToCard(cardId, epicId) +- removeEpicFromCard(cardId, epicId) +``` + +## Key Design Decisions + +### 1. Epic Hierarchy +- **Decision**: Self-referential foreign key (`parent_epic_id`) +- **Rationale**: Allows flexible nesting of epics with arbitrary depth +- **Feature**: `depth_limit` field to control maximum nesting depth + +### 2. Rich Text Storage +- **Decision**: Store as JSON (compatible with Slate.js) +- **Rationale**: + - Slate.js natively uses JSON format + - No serialization/deserialization overhead + - Easy to query and modify content structure + +### 3. Wiki vs Document Naming +- **Decision**: Use "Wiki" instead of "Document" +- **Rationale**: Avoids confusion with file attachments +- **Meaning**: Wiki implies knowledge base / documentation repository + +### 4. Entity Linking Strategy +- **Decision**: Polymorphic association table (`wiki_entity_links`) +- **Rationale**: + - Single table handles all entity types + - Easy to add new entity types in future + - Avoids circular imports and complex schema changes + +### 5. Epic-Card Relationship +- **Decision**: One-way reference (Card → Epic) +- **Rationale**: + - Simpler than many-to-many (epics contain cards) + - Cards can belong to one epic at a time + - Consistent with Jira's parent/child pattern + +## Architecture + +### Backend Data Flow + +``` +Board (1) ───────┬─────── (1) Epic + │ │ + │ │─── (0..*) Card (via epic_id) + │ + └───────────────┬─────── (0..*) Wiki + │ + └─── wiki_entity_links ───┬── Card + ├── Epic + └── (future entities) +``` + +### Frontend Component Structure + +``` +BoardEpics (page) +├── EpicList +│ └── EpicCard +└── CreateEpicModal + ├── EpicForm + │ ├── EpicNameInput + │ ├── EpicDescriptionInput + │ └── RichTextEditor (content) + └── ColorPicker +``` + +## Next Steps + +### Immediate Actions Required + +1. **Run Database Migration** + ```bash + cd backend + flask db upgrade + ``` + +2. **Create Backend Routes** (`backend/app/routes/kanban/epics.py`) + - CRUD operations for Epics + - Epic-Card linking endpoints + - Wiki CRUD operations + - Wiki-Entity linking endpoints + +3. **Create Backend Schemas** (`backend/app/schemas/epic.py`, `wiki.py`) + - Marshmallow schemas for serialization + - Input validation + +4. **Update BoardEpics Page** (`frontend/src/pages/BoardEpics.tsx`) + - Implement epic list view + - Add create epic modal + - Add epic detail view + - Include RichTextEditor for epic content + +5. **Update Card Detail Page** (`frontend/src/pages/CardDetail.tsx`) + - Add epic selector dropdown + - Display linked epic information + - Add wiki links + +### Future Enhancements + +1. **Wiki Features** + - Wiki page with sidebar navigation + - Markdown export/import + - Wiki search functionality + - Version history + +2. **Epic Features** + - Epic progress tracking (cards completed vs total) + - Epic metrics dashboard + - Epic drag-and-drop reordering + - Epic timeline view + +3. **UI/UX Improvements** + - Epic color picker with presets + - Epic hierarchy tree view + - Inline card epic assignment + - Epic templates + +4. **Analytics** + - Epic completion rate + - Time spent on epic + - Epic size distribution + - Wiki usage statistics + +## Database Schema + +### Epic Table +```sql +CREATE TABLE epics ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + description TEXT, + content JSONB, + color VARCHAR(7), + closed BOOLEAN DEFAULT FALSE, + pos FLOAT, + depth_limit INTEGER, + board_id INTEGER REFERENCES boards(id), + parent_epic_id INTEGER REFERENCES epics(id), + date_last_activity TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Table +```sql +CREATE TABLE wikis ( + id SERIAL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + slug VARCHAR(200) UNIQUE, + content JSONB NOT NULL, + summary TEXT, + category VARCHAR(100), + board_id INTEGER REFERENCES boards(id), + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Wiki Entity Links Table +```sql +CREATE TABLE wiki_entity_links ( + wiki_id INTEGER REFERENCES wikis(id) ON DELETE CASCADE, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + linked_by INTEGER REFERENCES users(id), + PRIMARY KEY (wiki_id, entity_type, entity_id) +); +``` + +## API Endpoints (To Be Implemented) + +### Epic Endpoints +``` +GET /api/boards//epics +POST /api/boards//epics +GET /api/epics/ +PUT /api/epics/ +DELETE /api/epics/ +``` + +### Epic-Card Linking +``` +POST /api/cards//epics +DELETE /api/cards//epics/ +``` + +### Wiki Endpoints +``` +GET /api/boards//wikis +POST /api/boards//wikis +GET /api/wikis/ +PUT /api/wikis/ +DELETE /api/wikis/ +GET /api/wikis//content +``` + +### Wiki-Entity Linking +``` +POST /api/wikis//links +DELETE /api/wikis//links// +``` + +## 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 \ No newline at end of file diff --git a/EPIC_WIKI_MODELS_SUMMARY.md b/EPIC_WIKI_MODELS_SUMMARY.md new file mode 100644 index 0000000..a0c261b --- /dev/null +++ b/EPIC_WIKI_MODELS_SUMMARY.md @@ -0,0 +1,238 @@ +# Epic and Wiki Models - Implementation Summary + +## Overview +This document summarizes the implementation of Epic and Wiki models for the Kanban application. + +## What Was Implemented + +### 1. Epic Model (`backend/app/models/epic.py`) +- Tracks large features across multiple cards +- Hierarchical structure (parent-child epics) +- Rich text content support (Slate.js JSON) +- Color-coded badges for visual identification +- Metrics tracking (card count) + +**Key Fields:** +- `id`, `name`, `description`, `content` (JSONB) +- `color` (hex code for epic badge) +- `closed`, `pos`, `depth_limit` (default 5) +- `board_id`, `parent_epic_id` +- `date_last_activity`, `created_at`, `updated_at` +- `metrics` (JSONB - stores card_count) + +**Relationships:** +- Board: One-to-many (Board has many Epics) +- Cards: One-to-many (Epic has many Cards) +- Parent Epic: Self-referential (hierarchical) +- File Attachments: Polymorphic (like Cards) + +### 2. Wiki Model (`backend/app/models/wiki.py`) +- Reusable rich text content within a board +- Board-scoped (not global across all boards) +- Polymorphic links to entities (Card, Epic, etc.) +- Categorization and tagging support + +**Key Fields:** +- `id`, `name`, `slug` (URL-friendly) +- `content` (JSONB - rich text) +- `summary`, `category`, `tags` (JSONB) +- `board_id`, `created_by`, `updated_by` +- `created_at`, `updated_at` + +**Relationships:** +- Board: One-to-many (Board has many Wikis) +- Entities: Many-to-many polymorphic (via wiki_entity_links) + +### 3. Card Model Updates (`backend/app/models/card.py`) +- Added `epic_id` foreign key (nullable) +- Updated `to_dict()` to include `epic_id` +- One-to-one relationship: Card belongs to one Epic + +### 4. Association Table (`wiki_entity_links`) +- Polymorphic many-to-many table +- Links wikis to any entity type +- Fields: `wiki_id`, `entity_type`, `entity_id`, `created_at`, `linked_by` + +## Database Schema + +### New Tables +1. **epics** - Epic records +2. **wikis** - Wiki content records +3. **wiki_entity_links** - Wiki-to-entity associations + +### Modified Tables +1. **cards** - Added `epic_id` foreign key + +### Relationships Diagram +``` +Board (1) ----< (N) Epic +Epic (1) ----< (N) Card (each card belongs to one epic) +Epic (1) ----< (N) Epic (parent-child hierarchy) + +Board (1) ----< (N) Wiki +Wiki (M) ----> (M) Entity (polymorphic: Card, Epic) +``` + +## Database Migration + +**File:** `backend/migrations/versions/add_epic_and_wiki_models.py` + +**Creates:** +- `epics` table with indexes on `board_id`, `closed`, `name` +- `wikis` table with indexes on `board_id`, `name`, `slug` +- `wiki_entity_links` table with composite primary key +- `epic_id` column in `cards` table with index and foreign key + +**To apply migration:** +```bash +cd backend +flask db upgrade +``` + +**To rollback:** +```bash +flask db downgrade +``` + +## Model Exports + +Updated `backend/app/models/__init__.py` to include: +- `from app.models.epic import Epic` +- `from app.models.wiki import Wiki` + +Added to `__all__` list: `"Epic"`, `"Wiki"` + +## Design Decisions + +### Why "Wiki" instead of "Document"? +- Avoids confusion with file attachments +- Emphasizes reusable knowledge content +- Better semantic meaning for rich text resources + +### One-to-Many Epic-Card Relationship +- Simpler, clearer ownership +- Each card belongs to one epic +- Easier to query and display + +### Board-Scoped Wikis +- Wikis belong to a specific board +- Not global across all boards +- Better organization and access control + +### Epic Hierarchy Depth +- Default depth limit: 5 levels +- Configurable per epic +- Backend should enforce when creating child epics + +### Simplified Metrics +- Currently only tracks `card_count` +- Stored in JSONB field: `{"card_count": 10}` +- Easy to extend with more metrics later + +## Next Steps + +### Backend Implementation +1. ✅ Create models - **DONE** +2. ✅ Create database migration - **DONE** +3. ⏭️ Create schemas for serialization +4. ⏭️ Create API routes (CRUD operations) +5. ⏭️ Create services for business logic +6. ⏭️ Add validation for epic depth limit +7. ⏭️ Update epic metrics when cards change +8. ⏭️ Write tests for models and routes + +### Frontend Implementation +1. ⏭️ Update TypeScript types +2. ⏭️ Create Epic page/component +3. ⏭️ Create Wiki page/component +4. ⏭️ Add epic dropdown to card detail +5. ⏭️ Create epic list on board detail +6. ⏭️ Implement rich text editor (Slate.js) +7. ⏭️ Add wiki linking UI + +### API Endpoints (Future) + +#### Epic Endpoints +``` +GET /api/boards/{board_id}/epics # List all epics for board +POST /api/boards/{board_id}/epics # Create epic +GET /api/epics/{epic_id} # Get epic details with cards +PUT /api/epics/{epic_id} # Update epic +DELETE /api/epics/{epic_id} # Delete epic +POST /api/epics/{epic_id}/cards # Create card directly in epic +GET /api/epics/{epic_id}/tree # Get epic hierarchy tree +``` + +#### Wiki Endpoints +``` +GET /api/boards/{board_id}/wikis # List all wikis for board +POST /api/boards/{board_id}/wikis # Create wiki +GET /api/wikis/{wiki_id} # Get wiki details +PUT /api/wikis/{wiki_id} # Update wiki +DELETE /api/wikis/{wiki_id} # Delete wiki +POST /api/wikis/{wiki_id}/links # Link wiki to entity +DELETE /api/wikis/{wiki_id}/links/{link_id} # Unlink from entity +GET /api/{entity_type}/{entity_id}/wikis # Get wikis for entity +``` + +#### Card Endpoints (Updated) +``` +PUT /api/cards/{card_id}/epic # Link card to epic (or null to unlink) +GET /api/cards/{card_id}/epic # Get card's epic +``` + +## Files Created/Modified + +### Created +- `backend/app/models/epic.py` +- `backend/app/models/wiki.py` +- `backend/migrations/versions/add_epic_and_wiki_models.py` + +### Modified +- `backend/app/models/card.py` (added epic_id) +- `backend/app/models/__init__.py` (added Epic, Wiki imports) + +## Testing the Implementation + +1. **Apply migration:** +```bash +cd backend +source venv/bin/activate +flask db upgrade +``` + +2. **Verify tables created:** +```bash +flask dbcurrent +# Should show: add_epic_and_wiki_models +``` + +3. **Test in Python shell:** +```python +from app import create_app, db +from app.models import Epic, Wiki, Card, Board + +app = create_app() +with app.app_context(): + # Create an epic + epic = Epic(name="My Epic", board_id=1, color="#3b82f6") + db.session.add(epic) + db.session.commit() + + # Create a wiki + wiki = Wiki(name="Security Guide", board_id=1, content={}) + db.session.add(wiki) + db.session.commit() + + print(f"Epic created: {epic.to_dict()}") + print(f"Wiki created: {wiki.to_dict()}") +``` + +## Notes + +- All models follow the existing project patterns +- Uses `db` from `app` module (not `flask_sqlalchemy` directly) +- Proper foreign key constraints with CASCADE/SET NULL +- Timestamps use UTC timezone +- JSONB fields for flexible data storage +- Indexed for optimal query performance \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 0a2bd23..2ccef83 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,18 @@ class Config: "pool_pre_ping": True, # Verify connections before using } + # 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_REGION = os.environ.get("MINIO_REGION", "garage") + 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" + class DevelopmentConfig(Config): """Development configuration""" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 215755d..83b99b1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,12 +4,14 @@ from app.models.card_label import CardLabel from app.models.check_item import CheckItem from app.models.checklist import Checklist 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.list_model import List from app.models.order import Order, OrderItem from app.models.product import Product from app.models.user import User +from app.models.wiki import Wiki __all__ = [ "User", @@ -25,4 +27,6 @@ __all__ = [ "CheckItem", "Comment", "FileAttachment", + "Epic", + "Wiki", ] diff --git a/backend/app/models/card.py b/backend/app/models/card.py index cbdb2bb..c9c2733 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -32,6 +32,9 @@ class Card(db.Model): nullable=False, index=True, ) + epic_id = db.Column( + db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL"), index=True + ) # Timestamps date_last_activity = db.Column(db.DateTime) @@ -79,6 +82,7 @@ class Card(db.Model): "id_short": self.id_short, "board_id": self.board_id, "list_id": self.list_id, + "epic_id": self.epic_id, "date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity else None, diff --git a/backend/app/models/epic.py b/backend/app/models/epic.py new file mode 100644 index 0000000..ff6af22 --- /dev/null +++ b/backend/app/models/epic.py @@ -0,0 +1,80 @@ +from datetime import UTC, datetime + +from sqlalchemy.dialects.postgresql import JSONB + +from app import db + + +class Epic(db.Model): + """Epic model for tracking large features across multiple cards""" + + __tablename__ = "epics" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + description = db.Column(db.Text) + content = db.Column(JSONB) # Rich text content (Slate.js JSON) + color = db.Column(db.String(7)) # Hex color for epic badge + closed = db.Column(db.Boolean, default=False, index=True) + pos = db.Column(db.Float) # Position for sorting in epic list + depth_limit = db.Column(db.Integer, default=5) # Max nesting depth + + # Foreign keys + board_id = db.Column( + db.Integer, + db.ForeignKey("boards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + parent_epic_id = db.Column( + db.Integer, db.ForeignKey("epics.id", ondelete="SET NULL") + ) + + # Timestamps + date_last_activity = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + # JSON fields for metrics + metrics = db.Column(JSONB) # {"card_count": 10} + + # Relationships + board = db.relationship("Board", backref="epics") + parent_epic = db.relationship("Epic", remote_side=[id], backref="child_epics") + cards = db.relationship("Card", backref="epic", cascade="all, delete-orphan") + attachments = db.relationship( + "FileAttachment", + foreign_keys="FileAttachment.attachable_id", + primaryjoin="""and_(FileAttachment.attachable_id == Epic.id, + FileAttachment.attachable_type == 'Epic')""", + cascade="all, delete-orphan", + lazy="dynamic", + ) + + def to_dict(self): + """Convert epic to dictionary""" + return { + "id": self.id, + "name": self.name, + "description": self.description, + "content": self.content, + "color": self.color, + "closed": self.closed, + "pos": self.pos, + "depth_limit": self.depth_limit, + "board_id": self.board_id, + "parent_epic_id": self.parent_epic_id, + "date_last_activity": self.date_last_activity.isoformat() + if self.date_last_activity + else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "metrics": self.metrics or {"card_count": 0}, + } + + def __repr__(self): + return f"" diff --git a/backend/app/models/wiki.py b/backend/app/models/wiki.py new file mode 100644 index 0000000..7ba1e9a --- /dev/null +++ b/backend/app/models/wiki.py @@ -0,0 +1,88 @@ +from datetime import UTC, datetime + +from sqlalchemy.dialects.postgresql import JSONB + +from app import db + +wiki_entity_links = db.Table( + "wiki_entity_links", + db.Column( + "wiki_id", + db.Integer, + db.ForeignKey("wikis.id", ondelete="CASCADE"), + primary_key=True, + ), + db.Column("entity_type", db.String(50), nullable=False), # 'Card', 'Epic' + db.Column("entity_id", db.Integer, nullable=False), # ID of the linked entity + db.Column("created_at", db.DateTime, default=lambda: datetime.now(UTC)), + db.Column("linked_by", db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")), +) + + +class Wiki(db.Model): + """Wiki model for reusable rich text content within a board""" + + __tablename__ = "wikis" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(255), index=True) # URL-friendly identifier + content = db.Column(JSONB, nullable=False) # Rich text content (Slate.js JSON) + summary = db.Column(db.Text) # Brief description/abstract + category = db.Column(db.String(100)) # Optional categorization + + # Foreign keys + board_id = db.Column( + db.Integer, + db.ForeignKey("boards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + ) + updated_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + ) + + # Timestamps + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + # JSON fields + tags = db.Column(JSONB) # List of tags for organization: ["security", "api"] + + # Relationships + board = db.relationship("Board", backref="wikis") + creator = db.relationship( + "User", foreign_keys=[created_by], backref="created_wikis" + ) + updater = db.relationship( + "User", foreign_keys=[updated_by], backref="updated_wikis" + ) + + def to_dict(self): + """Convert wiki to dictionary""" + return { + "id": self.id, + "name": self.name, + "slug": self.slug, + "content": self.content, + "summary": self.summary, + "category": self.category, + "board_id": self.board_id, + "tags": self.tags or [], + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + def __repr__(self): + return f"" diff --git a/backend/app/routes/kanban/__init__.py b/backend/app/routes/kanban/__init__.py index ea98768..a9fe728 100644 --- a/backend/app/routes/kanban/__init__.py +++ b/backend/app/routes/kanban/__init__.py @@ -6,8 +6,8 @@ kanban_bp = Blueprint("kanban", __name__) # Import all route modules to register their routes to this blueprint # fmt: off -from . import (boards, cards, checklists, comments, files, # noqa: F401 E402 - labels, lists) +from . import (boards, cards, checklists, comments, epics, # noqa: F401 E402 + files, labels, lists, wikis) # fmt: on diff --git a/backend/app/routes/kanban/epics.py b/backend/app/routes/kanban/epics.py new file mode 100644 index 0000000..610f1c8 --- /dev/null +++ b/backend/app/routes/kanban/epics.py @@ -0,0 +1,158 @@ +"""Routes for Epic CRUD operations.""" + +from flask import request +from flask_jwt_extended import jwt_required +from flask_pydantic import validate + +from app import db +from app.models import Board, Card, Epic +from app.schemas import EpicCreateRequest, EpicResponse, EpicUpdateRequest + +from . import kanban_bp + + +@kanban_bp.route("/boards//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//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/", 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/", 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/", 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//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//epics/", 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 diff --git a/backend/app/routes/kanban/wikis.py b/backend/app/routes/kanban/wikis.py new file mode 100644 index 0000000..62a5491 --- /dev/null +++ b/backend/app/routes/kanban/wikis.py @@ -0,0 +1,127 @@ +"""Routes for Wiki CRUD operations.""" + +import re + +from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_pydantic import validate + +from app import db +from app.models import Board, Wiki +from app.schemas import WikiCreateRequest, WikiResponse, WikiUpdateRequest + +from . import kanban_bp + + +def generate_slug(name): + """Generate URL-friendly slug from name.""" + slug = name.lower() + slug = re.sub(r"[^a-z0-9\s-]", "", slug) + slug = re.sub(r"\s+", "-", slug) + slug = slug.strip("-") + return slug + + +@kanban_bp.route("/boards//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//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/", 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/", 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/", 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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 6b2c18e..47c2fe0 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,6 +7,8 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse from app.schemas.comment import (CommentCreateRequest, CommentResponse, CommentWithUserResponse) +from app.schemas.epic import (EpicCreateRequest, EpicResponse, + EpicUpdateRequest, EpicWithCardsResponse) from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, FileDeleteResponse, FileUploadRequest, MultipleFilesResponse, @@ -14,6 +16,8 @@ from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.product import ProductCreateRequest, ProductResponse +from app.schemas.wiki import (WikiCreateRequest, WikiEntityLink, + WikiLinkRequest, WikiResponse, WikiUpdateRequest) __all__ = [ # Product schemas @@ -50,4 +54,15 @@ __all__ = [ "BoardWithDetailsResponse", "CardWithDetailsResponse", "CommentWithUserResponse", + # Epic schemas + "EpicCreateRequest", + "EpicResponse", + "EpicUpdateRequest", + "EpicWithCardsResponse", + # Wiki schemas + "WikiCreateRequest", + "WikiResponse", + "WikiUpdateRequest", + "WikiEntityLink", + "WikiLinkRequest", ] diff --git a/backend/app/schemas/epic.py b/backend/app/schemas/epic.py new file mode 100644 index 0000000..7fb9dc8 --- /dev/null +++ b/backend/app/schemas/epic.py @@ -0,0 +1,132 @@ +"""Pydantic schemas for Epic model""" +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class EpicCreateRequest(BaseModel): + """Schema for creating a new epic""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "User Authentication", + "description": "Implement login and registration", + "content": [ + {"type": "paragraph", "children": [{"text": "Details..."}]} + ], + "color": "#3b82f6", + "pos": 1.0, + "depth_limit": 5, + "parent_epic_id": None, + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Epic name") + description: Optional[str] = Field(None, description="Epic description") + content: Optional[Any] = Field(None, description="Rich text content") + color: Optional[str] = Field(None, max_length=7, description="Hex color code") + pos: Optional[float] = Field(None, description="Position for ordering") + depth_limit: Optional[int] = Field( + None, ge=1, le=10, description="Maximum depth for hierarchy" + ) + parent_epic_id: Optional[int] = Field( + None, description="Parent epic ID for nesting" + ) + + +class EpicResponse(BaseModel): + """Schema for epic response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "User Authentication", + "description": "Implement login and registration", + "content": [ + {"type": "paragraph", "children": [{"text": "Details..."}]} + ], + "color": "#3b82f6", + "closed": False, + "pos": 1.0, + "depth_limit": 5, + "board_id": 1, + "parent_epic_id": None, + "metrics": {"card_count": 5, "progress": 60}, + "date_last_activity": "2024-01-15T10:30:00", + "created_at": "2024-01-01T10:00:00", + "updated_at": "2024-01-15T10:30:00", + } + }, + ) + + id: int + name: str + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = None + closed: bool + pos: Optional[float] = None + depth_limit: Optional[int] = None + board_id: int + parent_epic_id: Optional[int] = None + metrics: Optional[Dict[str, Any]] = None + date_last_activity: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class EpicUpdateRequest(BaseModel): + """Schema for updating an epic""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Epic Name", + "description": "Updated description", + "content": [ + {"type": "paragraph", "children": [{"text": "Updated..."}]} + ], + "color": "#ef4444", + "closed": True, + "pos": 2.0, + "depth_limit": 3, + "parent_epic_id": 2, + } + } + ) + + name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = Field(None, max_length=7) + closed: Optional[bool] = None + pos: Optional[float] = None + depth_limit: Optional[int] = Field(None, ge=1, le=10) + parent_epic_id: Optional[int] = None + + +class EpicWithCardsResponse(BaseModel): + """Schema for epic response with cards""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: Optional[str] = None + content: Optional[Any] = None + color: Optional[str] = None + closed: bool + pos: Optional[float] = None + depth_limit: Optional[int] = None + board_id: int + parent_epic_id: Optional[int] = None + metrics: Optional[Dict[str, Any]] = None + date_last_activity: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + cards: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/schemas/wiki.py b/backend/app/schemas/wiki.py new file mode 100644 index 0000000..c9bf4c1 --- /dev/null +++ b/backend/app/schemas/wiki.py @@ -0,0 +1,167 @@ +"""Pydantic schemas for Wiki model""" +from datetime import datetime +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class WikiCreateRequest(BaseModel): + """Schema for creating a new wiki""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Security Guidelines", + "slug": "security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Security rules..."}]} + ], + "summary": "Brief security overview", + "category": "Documentation", + "tags": ["security", "guidelines"], + } + } + ) + + name: str = Field(..., min_length=1, max_length=200, description="Wiki name") + slug: Optional[str] = Field(None, max_length=200, description="URL-friendly slug") + content: Any = Field(..., description="Rich text content") + summary: Optional[str] = Field(None, description="Brief summary") + category: Optional[str] = Field(None, max_length=100, description="Wiki category") + tags: Optional[List[str]] = Field(None, description="Wiki tags") + + @field_validator("slug") + @classmethod + def validate_slug(cls, v): + """Validate slug is URL-friendly.""" + if v and not v.islower(): + raise ValueError("Slug must be lowercase") + if v and not all(c.isalnum() or c == "-" for c in v): + raise ValueError( + "Slug must contain only lowercase letters, numbers, and hyphens" + ) + return v + + +class WikiResponse(BaseModel): + """Schema for wiki response""" + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "name": "Security Guidelines", + "slug": "security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Security rules..."}]} + ], + "summary": "Brief security overview", + "category": "Documentation", + "tags": ["security", "guidelines"], + "board_id": 1, + "created_by": 1, + "updated_by": 1, + "created_at": "2024-01-01T10:00:00", + "updated_at": "2024-01-15T10:30:00", + } + }, + ) + + id: int + name: str + slug: Optional[str] = None + content: Any + summary: Optional[str] = None + category: Optional[str] = None + tags: Optional[List[str]] = None + board_id: int + created_by: Optional[int] = None + updated_by: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class WikiUpdateRequest(BaseModel): + """Schema for updating a wiki""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "Updated Security Guidelines", + "slug": "updated-security-guidelines", + "content": [ + {"type": "paragraph", "children": [{"text": "Updated..."}]} + ], + "summary": "Updated summary", + "category": "Updated Category", + "tags": ["security", "updated"], + } + } + ) + + name: Optional[str] = Field(None, min_length=1, max_length=200) + slug: Optional[str] = Field(None, max_length=200) + content: Optional[Any] = None + summary: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) + tags: Optional[List[str]] = None + + @field_validator("slug") + @classmethod + def validate_slug(cls, v): + """Validate slug is URL-friendly.""" + if v and not v.islower(): + raise ValueError("Slug must be lowercase") + if v and not all(c.isalnum() or c == "-" for c in v): + raise ValueError( + "Slug must contain only lowercase letters, numbers, and hyphens" + ) + return v + + +class WikiEntityLink(BaseModel): + """Schema for wiki entity link response""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "wiki_id": 1, + "entity_type": "card", + "entity_id": 5, + "created_at": "2024-01-15T10:30:00", + "linked_by": 1, + } + } + ) + + wiki_id: int + entity_type: str + entity_id: int + created_at: Optional[datetime] = None + linked_by: Optional[int] = None + + +class WikiLinkRequest(BaseModel): + """Schema for creating a wiki entity link""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "entity_type": "card", + "entity_id": 5, + } + } + ) + + entity_type: str = Field(..., description="Entity type (card, epic, list, board)") + entity_id: int = Field(..., description="Entity ID") + + @field_validator("entity_type") + @classmethod + def validate_entity_type(cls, v): + """Validate entity type is supported.""" + valid_types = ["card", "epic", "list", "board"] + if v not in valid_types: + raise ValueError(f"entity_type must be one of: {', '.join(valid_types)}") + return v diff --git a/backend/app/services/storage/storage_extension.py b/backend/app/services/storage/storage_extension.py index 432abe8..54d0dfa 100644 --- a/backend/app/services/storage/storage_extension.py +++ b/backend/app/services/storage/storage_extension.py @@ -54,6 +54,7 @@ class StorageExtension: access_key=app.config["MINIO_ACCESS_KEY"], secret_key=app.config["MINIO_SECRET_KEY"], secure=app.config["MINIO_USE_SSL"], + region=app.config["MINIO_REGION"], ) # Validate connection by listing buckets @@ -93,17 +94,11 @@ class StorageExtension: ] 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 + if self.client.bucket_exists(bucket_name): + app.logger.debug(f"MinIO bucket exists: {bucket_name}") + else: + app.logger.error(f"MinIO bucket does not exists: {bucket_name}") + raise RuntimeError(f"Failed to create MinIO bucket '{bucket_name}'") def get_client(self) -> Minio: """ diff --git a/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py b/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py new file mode 100644 index 0000000..b1bbc55 --- /dev/null +++ b/backend/migrations/versions/6fc439155ced_add_epic_and_wiki_models.py @@ -0,0 +1,106 @@ +"""add epic and wiki models + +Revision ID: 6fc439155ced +Revises: e9515e29ef8b +Create Date: 2026-03-22 00:24:26.645867 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '6fc439155ced' +down_revision = 'e9515e29ef8b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('epics', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('closed', sa.Boolean(), nullable=True), + sa.Column('pos', sa.Float(), nullable=True), + sa.Column('depth_limit', sa.Integer(), nullable=True), + sa.Column('board_id', sa.Integer(), nullable=False), + sa.Column('parent_epic_id', sa.Integer(), nullable=True), + sa.Column('date_last_activity', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('metrics', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_epic_id'], ['epics.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_epics_board_id'), ['board_id'], unique=False) + batch_op.create_index(batch_op.f('ix_epics_closed'), ['closed'], unique=False) + batch_op.create_index(batch_op.f('ix_epics_name'), ['name'], unique=False) + + op.create_table('wikis', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('slug', sa.String(length=255), nullable=True), + sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('board_id', sa.Integer(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['board_id'], ['boards.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('wikis', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_wikis_board_id'), ['board_id'], unique=False) + batch_op.create_index(batch_op.f('ix_wikis_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_wikis_slug'), ['slug'], unique=False) + + op.create_table('wiki_entity_links', + sa.Column('wiki_id', sa.Integer(), nullable=False), + sa.Column('entity_type', sa.String(length=50), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('linked_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['linked_by'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['wiki_id'], ['wikis.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('wiki_id') + ) + with op.batch_alter_table('cards', schema=None) as batch_op: + batch_op.add_column(sa.Column('epic_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_cards_epic_id'), ['epic_id'], unique=False) + batch_op.create_foreign_key(None, 'epics', ['epic_id'], ['id'], ondelete='SET NULL') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('cards', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_cards_epic_id')) + batch_op.drop_column('epic_id') + + op.drop_table('wiki_entity_links') + with op.batch_alter_table('wikis', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_wikis_slug')) + batch_op.drop_index(batch_op.f('ix_wikis_name')) + batch_op.drop_index(batch_op.f('ix_wikis_board_id')) + + op.drop_table('wikis') + with op.batch_alter_table('epics', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_epics_name')) + batch_op.drop_index(batch_op.f('ix_epics_closed')) + batch_op.drop_index(batch_op.f('ix_epics_board_id')) + + op.drop_table('epics') + # ### end Alembic commands ### diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7342578..4888103 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,9 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.71.2", "react-router-dom": "^6.20.1", + "slate": "^0.123.0", + "slate-history": "^0.113.1", + "slate-react": "^0.123.0", "zod": "^4.3.6" }, "devDependencies": { @@ -1055,6 +1058,11 @@ "@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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2776,6 +2784,11 @@ "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": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3067,6 +3080,18 @@ "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": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4496,6 +4521,11 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4554,6 +4584,14 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4931,6 +4969,11 @@ "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6220,6 +6263,14 @@ "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6400,6 +6451,59 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6744,6 +6848,11 @@ "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index c176716..bd3397e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,9 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.71.2", "react-router-dom": "^6.20.1", + "slate": "^0.123.0", + "slate-history": "^0.113.1", + "slate-react": "^0.123.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59e60db..b2cabec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,11 @@ import { BoardCreate } from './pages/BoardCreate'; import { BoardEdit } from './pages/BoardEdit'; 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'; const App = () => { @@ -93,6 +98,46 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> Partial> = { + 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 Record> = { + 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 }; diff --git a/frontend/src/components/RichTextContent.tsx b/frontend/src/components/RichTextContent.tsx new file mode 100644 index 0000000..650c56f --- /dev/null +++ b/frontend/src/components/RichTextContent.tsx @@ -0,0 +1,33 @@ +import { useCallback, useMemo } from 'react'; +import { createEditor, Descendant } from 'slate'; +import { Slate, Editable, withReact, RenderElementProps, RenderLeafProps } from 'slate-react'; +import { Leaf, SlateRenderElement, withHtml } from './slate-editor-components'; + +export interface RichTextContentProps { + content: Descendant[] | null; + className?: string; +} + +const RichTextContent = ({ content }: any) => { + const editor = useMemo(() => withHtml(withReact(createEditor())), []); + + // const editor = useMemo(() => withHtml(withHistory(withReact(createEditor()))), []); + const renderElement = useCallback( + (props: RenderElementProps) => , + [] + ); + + const renderLeaf = useCallback((props: RenderLeafProps) => , []); + return ( + + + + ); +}; + +export default RichTextContent; diff --git a/frontend/src/components/RichTextEditor.tsx b/frontend/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..c99aff1 --- /dev/null +++ b/frontend/src/components/RichTextEditor.tsx @@ -0,0 +1,126 @@ +import React, { useMemo, useCallback } from 'react'; +import { createEditor, Descendant, Editor } from 'slate'; +import { + Slate, + Editable, + withReact as withReactPlugin, + useSlate, + RenderElementProps, + RenderLeafProps, +} from 'slate-react'; +import { withHistory } from 'slate-history'; +import { CustomEditor, CustomTextKey } from './custom-types'; +import { + BlockButton, + Button, + Icon, + Leaf, + SlateRenderElement, + Toolbar, + withHtml, +} from './slate-editor-components'; + +export interface RichTextEditorProps { + value: Descendant[]; + onChange: (value: Descendant[]) => void; + placeholder?: string; + readOnly?: boolean; + className?: string; +} + +interface MarkButtonProps { + format: CustomTextKey; + icon: string; +} + +const isMarkActive = (editor: CustomEditor, format: CustomTextKey) => { + const marks = Editor.marks(editor); + return marks ? marks[format] === true : false; +}; + +const toggleMark = (editor: CustomEditor, format: CustomTextKey) => { + const isActive = isMarkActive(editor, format); + + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } +}; + +const MarkButton = ({ format, icon }: MarkButtonProps) => { + const editor = useSlate(); + return ( + + ); +}; + +const RichTextEditor: React.FC = ({ + value, + onChange, + placeholder = 'Type something...', + readOnly = false, + className = '', +}) => { + const editor = useMemo(() => withHtml(withHistory(withReactPlugin(createEditor()))), []); + + const renderElement = useCallback( + (props: RenderElementProps) => , + [] + ); + + // const renderElement = useCallback((props: any) => { + // switch (props.element.type) { + // case "block-quote": + // return
{props.children}
; + // case "bulleted-list": + // return
    {props.children}
; + // case "list-item": + // return
  • {props.children}
  • ; + // case "numbered-list": + // return
      {props.children}
    ; + // default: + // return

    {props.children}

    ; + // } + // }, []); + + const renderLeaf = useCallback((props: RenderLeafProps) => , []); + + return ( +
    + + + + + + + + + + + + + + + + + + +
    + ); +}; + +export default RichTextEditor; diff --git a/frontend/src/components/custom-types.d.ts b/frontend/src/components/custom-types.d.ts new file mode 100644 index 0000000..e8c5f45 --- /dev/null +++ b/frontend/src/components/custom-types.d.ts @@ -0,0 +1,196 @@ +import { Descendant, BaseEditor, BaseRange, Range, Element } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { HistoryEditor } from 'slate-history'; + +export type BlockQuoteElement = { + type: 'block-quote'; + align?: string; + children: Descendant[]; +}; + +export type BulletedListElement = { + type: 'bulleted-list'; + align?: string; + children: Descendant[]; +}; + +export type CheckListItemElement = { + type: 'check-list-item'; + checked: boolean; + children: Descendant[]; +}; + +export type EditableVoidElement = { + type: 'editable-void'; + children: EmptyText[]; +}; + +export type HeadingElement = { + type: 'heading-one'; + align?: string; + children: Descendant[]; +}; + +export type HeadingTwoElement = { + type: 'heading-two'; + align?: string; + children: Descendant[]; +}; + +export type HeadingThreeElement = { + type: 'heading-three'; + align?: string; + children: Descendant[]; +}; + +export type HeadingFourElement = { + type: 'heading-four'; + align?: string; + children: Descendant[]; +}; + +export type HeadingFiveElement = { + type: 'heading-five'; + align?: string; + children: Descendant[]; +}; + +export type HeadingSixElement = { + type: 'heading-six'; + align?: string; + children: Descendant[]; +}; + +export type ImageElement = { + type: 'image'; + url: string; + children: EmptyText[]; +}; + +export type LinkElement = { type: 'link'; url: string; children: Descendant[] }; + +export type ButtonElement = { type: 'button'; children: Descendant[] }; + +export type BadgeElement = { type: 'badge'; children: Descendant[] }; + +export type ListItemElement = { type: 'list-item'; children: Descendant[] }; + +export type NumberedListItemElement = { + type: 'numbered-list'; + children: Descendant[]; +}; + +export type MentionElement = { + type: 'mention'; + character: string; + children: CustomText[]; +}; + +export type ParagraphElement = { + type: 'paragraph'; + align?: string; + children: Descendant[]; +}; + +export type TableElement = { type: 'table'; children: TableRow[] }; + +export type TableCellElement = { type: 'table-cell'; children: CustomText[] }; + +export type TableRowElement = { type: 'table-row'; children: TableCell[] }; + +export type TitleElement = { type: 'title'; children: Descendant[] }; + +export type VideoElement = { type: 'video'; url: string; children: EmptyText[] }; + +export type CodeBlockElement = { + type: 'code-block'; + language: string; + children: Descendant[]; +}; + +export type CodeLineElement = { + type: 'code-line'; + children: Descendant[]; +}; + +export type CustomElementWithAlign = + | ParagraphElement + | HeadingElement + | HeadingTwoElement + | HeadingThreeElement + | HeadingFourElement + | HeadingFiveElement + | HeadingSixElement + | BlockQuoteElement + | BulletedListElement; + +type CustomElement = + | BlockQuoteElement + | BulletedListElement + | CheckListItemElement + | EditableVoidElement + | HeadingElement + | HeadingTwoElement + | HeadingThreeElement + | HeadingFourElement + | HeadingFiveElement + | HeadingSixElement + | ImageElement + | LinkElement + | ButtonElement + | BadgeElement + | ListItemElement + | NumberedListItemElement + | MentionElement + | ParagraphElement + | TableElement + | TableRowElement + | TableCellElement + | TitleElement + | VideoElement + | CodeBlockElement + | CodeLineElement; + +export type CustomElementType = CustomElement['type']; + +export type CustomText = { + bold?: boolean; + italic?: boolean; + code?: boolean; + underline?: boolean; + strikethrough?: boolean; + // MARKDOWN PREVIEW SPECIFIC LEAF + underlined?: boolean; + title?: boolean; + list?: boolean; + hr?: boolean; + blockquote?: boolean; + text: string; +}; + +export type CustomTextKey = keyof Omit; + +export type EmptyText = { + text: string; +}; + +export type RenderElementPropsFor = RenderElementProps & { + element: T; +}; + +export type CustomEditor = BaseEditor & + ReactEditor & + HistoryEditor & { + nodeToDecorations?: Map; + }; + +declare module 'slate' { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + Text: CustomText; + Range: BaseRange & { + [key: string]: unknown; + }; + } +} diff --git a/frontend/src/components/isImageUrl.ts b/frontend/src/components/isImageUrl.ts new file mode 100644 index 0000000..363978e --- /dev/null +++ b/frontend/src/components/isImageUrl.ts @@ -0,0 +1,16 @@ +const isImageUrl = (url: string): boolean => { + if (!url) return false; + + try { + new URL(url); // Validate URL format + } catch { + return false; + } + + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif']; + const ext = url.split('.').pop()?.toLowerCase(); + + return ext ? imageExtensions.includes(ext) : false; +}; + +export default isImageUrl; diff --git a/frontend/src/components/slate-editor-components/index.tsx b/frontend/src/components/slate-editor-components/index.tsx new file mode 100644 index 0000000..ebcf723 --- /dev/null +++ b/frontend/src/components/slate-editor-components/index.tsx @@ -0,0 +1,329 @@ +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 { ELEMENT_TAGS, TEXT_TAGS } from '../ELEMENT_TAGS'; + +export const deserialize = (node: any): any[] => { + if (node.nodeType === 3) { + return [{ text: node.textContent }]; + } else if (node.nodeType !== 1) { + return []; + } + + const el = node as any; + const children = Array.from(node.childNodes).map(deserialize).flat(); + + if (el.nodeName === 'BODY') { + return children; + } + + if (ELEMENT_TAGS[el.nodeName]) { + const attrs = ELEMENT_TAGS[el.nodeName](el); + return [{ ...attrs, children }]; + } + + if (TEXT_TAGS[el.nodeName]) { + const attrs = TEXT_TAGS[el.nodeName](); + return children.map((child) => ({ ...child, ...attrs })); + } + + return children; +}; + +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 { + 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( + ({ 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 ( + + ); +}; + +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; +}; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 3deb170..c2b6a0e 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -299,6 +299,55 @@ export function useApi() { }); return response.data; }, + + // Epics + getEpics: async (boardId: number): Promise => { + const response = await api.get(`/boards/${boardId}/epics`); + return response.data; + }, + createEpic: async (boardId: number, epicData: any): Promise => { + const response = await api.post(`/boards/${boardId}/epics`, epicData); + return response.data; + }, + getEpic: async (epicId: number): Promise => { + const response = await api.get(`/epics/${epicId}`); + return response.data; + }, + updateEpic: async (epicId: number, epicData: any): Promise => { + const response = await api.put(`/epics/${epicId}`, epicData); + return response.data; + }, + deleteEpic: async (epicId: number): Promise => { + await api.delete(`/epics/${epicId}`); + }, + addEpicToCard: async (cardId: number, epicId: number): Promise => { + const response = await api.post(`/cards/${cardId}/epics`, { epic_id: epicId }); + return response.data; + }, + removeEpicFromCard: async (cardId: number, epicId: number): Promise => { + await api.delete(`/cards/${cardId}/epics/${epicId}`); + }, + + // Wikis + getWikis: async (boardId: number): Promise => { + const response = await api.get(`/boards/${boardId}/wikis`); + return response.data; + }, + createWiki: async (boardId: number, wikiData: any): Promise => { + const response = await api.post(`/boards/${boardId}/wikis`, wikiData); + return response.data; + }, + getWiki: async (wikiId: number): Promise => { + const response = await api.get(`/wikis/${wikiId}`); + return response.data; + }, + updateWiki: async (wikiId: number, wikiData: any): Promise => { + const response = await api.put(`/wikis/${wikiId}`, wikiData); + return response.data; + }, + deleteWiki: async (wikiId: number): Promise => { + await api.delete(`/wikis/${wikiId}`); + }, }), [] ); diff --git a/frontend/src/hooks/useEpicDetail.ts b/frontend/src/hooks/useEpicDetail.ts new file mode 100644 index 0000000..d8dbe28 --- /dev/null +++ b/frontend/src/hooks/useEpicDetail.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Epic, UpdateEpicRequest } from '../types/epic'; + +function useEpicDetail(epicId: string) { + const [epic, setEpic] = useState(null); + const [error, setError] = useState(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; diff --git a/frontend/src/hooks/useEpics.ts b/frontend/src/hooks/useEpics.ts new file mode 100644 index 0000000..bc4a0c6 --- /dev/null +++ b/frontend/src/hooks/useEpics.ts @@ -0,0 +1,144 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Epic, CreateEpicRequest, UpdateEpicRequest } from '../types/epic'; + +function useEpics(boardId: string) { + const [epics, setEpics] = useState([]); + const [error, setError] = useState(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; diff --git a/frontend/src/hooks/useWikiDetail.ts b/frontend/src/hooks/useWikiDetail.ts new file mode 100644 index 0000000..b4c2e23 --- /dev/null +++ b/frontend/src/hooks/useWikiDetail.ts @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Wiki, UpdateWikiRequest } from '../types/epic'; + +function useWikiDetail(wikiId: string) { + const [wiki, setWiki] = useState(null); + const [error, setError] = useState(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; diff --git a/frontend/src/hooks/useWikis.ts b/frontend/src/hooks/useWikis.ts new file mode 100644 index 0000000..6a2dce9 --- /dev/null +++ b/frontend/src/hooks/useWikis.ts @@ -0,0 +1,144 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import type { Wiki, CreateWikiRequest, UpdateWikiRequest } from '../types/epic'; + +function useWikis(boardId: string) { + const [wikis, setWikis] = useState([]); + const [error, setError] = useState(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; diff --git a/frontend/src/pages/BoardDetail.tsx b/frontend/src/pages/BoardDetail.tsx index a49d6ee..c32cb58 100644 --- a/frontend/src/pages/BoardDetail.tsx +++ b/frontend/src/pages/BoardDetail.tsx @@ -244,7 +244,7 @@ export function BoardDetail() { -
    +
    ( + + + + +); + +const Edit2Icon = () => ( + + + +); + +const Trash2Icon = () => ( + + + + +); 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 (
    -
    +
    +
    + + ← Back to Board + +

    Epics

    +

    + Manage and view epics for this board ({epics.length} epics) +

    +
    - ← Back to Board + + + + Create Epic -

    Epics

    -

    Manage and view epics for this board

    -
    -
    -
    📋
    -

    Epics Coming Soon

    -

    - This page will allow you to create and manage epics for your board. -

    + {epics.length === 0 ? ( +
    +
    +
    📋
    +

    No Epics Yet

    +

    + Create your first epic to start organizing your work. +

    + + + + + Create First Epic + +
    -
    + ) : ( +
    + {epics.map((epic) => ( + navigate(`/boards/${id}/epics/${epic.id}`)} + onDelete={() => handleDeleteEpic(epic.id, epic.name)} + onEdit={() => navigate(`/boards/${id}/epics/${epic.id}/edit`)} + /> + ))} +
    + )}
    ); } + +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 ( +
    + {/* Color header */} +
    + +
    + {/* Header with actions */} +
    +

    {epic.name}

    +
    + + +
    +
    + + {/* Description */} + {epic.description && ( +

    {epic.description}

    + )} + + {/* Rich text content preview */} + {epic.content && Array.isArray(epic.content) && epic.content.length > 0 && ( +
    + +
    + )} + + {/* Footer with metrics */} +
    +
    + + 📊 + {epic.metrics?.card_count || 0} cards + + {epic.parent_epic_id && ( + + 🔄 + Sub-epic + + )} +
    + {epic.date_last_activity && ( + {new Date(epic.date_last_activity).toLocaleDateString()} + )} +
    + + {/* Closed badge */} + {epic.closed && ( +
    + + Closed + +
    + )} +
    +
    + ); +} + +export default BoardEpics; diff --git a/frontend/src/pages/BoardWikis.tsx b/frontend/src/pages/BoardWikis.tsx new file mode 100644 index 0000000..f75b52f --- /dev/null +++ b/frontend/src/pages/BoardWikis.tsx @@ -0,0 +1,211 @@ +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { WidePageLayout } from '../components/WidePageLayout'; +import RichTextContent from '../components/RichTextContent'; +import useWikis from '../hooks/useWikis'; +import type { Wiki } from '../types/epic'; + +const PlusIcon = () => ( + + + + +); + +const Edit2Icon = () => ( + + + +); + +const Trash2Icon = () => ( + + + + +); + +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 ( +
    + +
    +
    + + ← Back to Board + +

    Wikis

    +

    + Manage and view wikis for this board ({wikis.length} wikis) +

    +
    + + + + + Create Wiki + +
    +
    + + + {wikis.length === 0 ? ( +
    +
    +
    📚
    +

    No Wikis Yet

    +

    + Create your first wiki to start documenting your knowledge. +

    + + + + + Create First Wiki + +
    +
    + ) : ( +
    + {wikis.map((wiki) => ( + navigate(`/boards/${id}/wikis/${wiki.id}`)} + onDelete={() => handleDeleteWiki(wiki.id, wiki.name)} + onEdit={() => navigate(`/boards/${id}/wikis/${wiki.id}/edit`)} + /> + ))} +
    + )} +
    +
    + ); +} + +interface WikiCardProps { + wiki: Wiki; + onClick: () => void; + onDelete: () => void; + onEdit: () => void; +} + +function WikiCard({ wiki, onClick, onDelete, onEdit }: WikiCardProps) { + return ( +
    +
    + {/* Header with actions */} +
    +

    {wiki.name}

    +
    + + +
    +
    + + {/* Summary */} + {wiki.summary &&

    {wiki.summary}

    } + + {/* Rich text content preview */} + {wiki.content && Array.isArray(wiki.content) && wiki.content.length > 0 && ( +
    + +
    + )} + + {/* Footer with metadata */} +
    +
    + {wiki.category && ( + {wiki.category} + )} + {wiki.tags && wiki.tags.length > 0 && {wiki.tags.length} tag(s)} +
    + {wiki.updated_at && {new Date(wiki.updated_at).toLocaleDateString()}} +
    +
    +
    + ); +} + +export default BoardWikis; diff --git a/frontend/src/pages/CreateEpic.tsx b/frontend/src/pages/CreateEpic.tsx new file mode 100644 index 0000000..383da63 --- /dev/null +++ b/frontend/src/pages/CreateEpic.tsx @@ -0,0 +1,203 @@ +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { WidePageLayout } from '../components/WidePageLayout'; +import RichTextEditor from '../components/RichTextEditor'; +import useEpics from '../hooks/useEpics'; +import type { CreateEpicRequest } from '../types/epic'; + +const CheckSquareIcon = () => ( + + + + +); + +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([{ children: [{ text: '' }] }]); + const [parentEpicId, setParentEpicId] = useState(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) + : 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 ( +
    + +
    + + ← Back to Epics + +

    Create Epic

    +

    Create a new epic for this board

    +
    +
    + + +
    + {/* Name */} +
    + + 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 + /> +
    + + {/* Description */} +
    + +