Compare commits

..

10 commits

Author SHA1 Message Date
090090b353 reorder list column 2026-02-28 00:07:45 +03:00
78ce18f4d3 add delete and edit list 2026-02-27 22:38:38 +03:00
8a33e1c644 improve card component 2026-02-27 22:25:34 +03:00
e69dd6a56d add labels logic 2026-02-27 21:14:02 +03:00
d295a00e2f add checklists 2026-02-27 20:34:44 +03:00
799e089deb add card detail page 2026-02-27 17:03:47 +03:00
f819ada0f0 fix backend card sorting bug 2026-02-27 13:54:34 +03:00
c53742d470 lint frontend 2026-02-27 13:15:50 +03:00
1628677222 lint frontend, fix drag and drop bug 2026-02-27 10:53:36 +03:00
1bcad71f23 add board detail and clean up login flow 2026-02-26 17:12:19 +03:00
66 changed files with 5113 additions and 964 deletions

View file

@ -57,17 +57,6 @@ jobs:
cd backend cd backend
flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source
# - name: Run migrations
# env:
# TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
# DATABASE_URL: postgresql://test:test@postgres:5432/test_db
# SECRET_KEY: test-secret-key
# JWT_SECRET_KEY: test-jwt-secret
# FLASK_ENV: test
# run: |
# cd backend
# flask db upgrade
- name: Run tests - name: Run tests
env: env:
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db
@ -78,4 +67,3 @@ jobs:
run: | run: |
cd backend cd backend
pytest --cov=app --cov-report=xml --cov-report=term pytest --cov=app --cov-report=xml --cov-report=term

View file

@ -8,7 +8,7 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
@ -56,8 +56,8 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing # Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 20, # Only one connection in the pool "pool_size": 1, # Only one connection in the pool
"max_overflow": 10, # No overflow connections allowed "max_overflow": 0, # No overflow connections allowed
"pool_timeout": 30, "pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour "pool_recycle": 3600, # Recycle after 1 hour
"pool_pre_ping": True, # Verify connections before using "pool_pre_ping": True, # Verify connections before using

View file

@ -27,12 +27,15 @@ def get_boards():
@load_board_owned @load_board_owned
def get_board(board_id, board): def get_board(board_id, board):
"""Get a single board with all its details""" """Get a single board with all its details"""
from app.models import User
# Get all lists for this board # Get all lists for this board
lists_data = [] lists_data = []
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
cards_data = [] cards_data = []
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all(): for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
card_dict = card.to_dict() card_dict = card.to_dict()
# Add labels for this card # Add labels for this card
card_dict["labels"] = [ card_dict["labels"] = [
label.to_dict() label.to_dict()
@ -43,6 +46,24 @@ def get_board(board_id, board):
.all() .all()
) )
] ]
# Add comments for this card
card_dict["comments"] = []
for comment in card.comments.all():
comment_dict = comment.to_dict()
user = db.session.get(User, comment.user_id)
comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict)
# Add checklists with items for this card
card_dict["checklists"] = [
{
**checklist.to_dict(),
"items": [item.to_dict() for item in checklist.check_items.all()],
}
for checklist in card.checklists.all()
]
cards_data.append(card_dict) cards_data.append(card_dict)
lists_data.append({**lst.to_dict(), "cards": cards_data}) lists_data.append({**lst.to_dict(), "cards": cards_data})

View file

@ -9,6 +9,7 @@ from app.decorators import load_card_owned, load_list_owned
from app.models import Board, Card, CardLabel, Label, List from app.models import Board, Card, CardLabel, Label, List
from app.schemas import (CardCreateRequest, CardResponse, from app.schemas import (CardCreateRequest, CardResponse,
CardWithDetailsResponse) CardWithDetailsResponse)
from app.services.card_position_service import CardPositionService
from . import kanban_bp from . import kanban_bp
@ -86,12 +87,18 @@ def get_card(card_id, card):
@validate(body=CardCreateRequest) @validate(body=CardCreateRequest)
def update_card(card_id, card, body: CardCreateRequest): def update_card(card_id, card, body: CardCreateRequest):
"""Update a card""" """Update a card"""
# Track if position or list is changing
old_position = card.pos
old_list_id = card.list_id
new_position = body.pos
new_list_id = card.list_id
# Update basic card fields
card.name = body.name card.name = body.name
if body.description is not None: if body.description is not None:
card.description = body.description card.description = body.description
if request.json.get("closed") is not None: if request.json.get("closed") is not None:
card.closed = request.json.get("closed") card.closed = request.json.get("closed")
card.pos = body.pos
card.due = body.due card.due = body.due
card.due_complete = body.due_complete card.due_complete = body.due_complete
if body.badges is not None: if body.badges is not None:
@ -108,6 +115,19 @@ def update_card(card_id, card, body: CardCreateRequest):
if new_list and new_list.board_id == card.board_id: if new_list and new_list.board_id == card.board_id:
card.list_id = new_list_id card.list_id = new_list_id
# Handle position reordering
if old_list_id != new_list_id or old_position != new_position:
if old_list_id != new_list_id:
# Card moved to different list
CardPositionService.reorder_cards_between_lists(
old_list_id, new_list_id, card_id, new_position
)
else:
# Card moved within same list
CardPositionService.reorder_cards_in_list(
new_list_id, card_id, new_position
)
card.date_last_activity = datetime.now(UTC) card.date_last_activity = datetime.now(UTC)
board = db.session.get(Board, card.board_id) board = db.session.get(Board, card.board_id)
board.date_last_activity = datetime.now(UTC) board.date_last_activity = datetime.now(UTC)

View file

@ -1,11 +1,14 @@
from datetime import UTC, datetime
from flask import request from flask import request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from flask_pydantic import validate from flask_pydantic import validate
from app import db from app import db
from app.decorators import load_board_owned, load_list_owned from app.decorators import load_board_owned, load_list_owned
from app.models import List from app.models import Board, List
from app.schemas import ListCreateRequest from app.schemas import ListCreateRequest
from app.services.list_position_service import ListPositionService
from . import kanban_bp from . import kanban_bp
@ -37,9 +40,21 @@ def update_list(list_id, lst, body: ListCreateRequest):
lst.name = body.name lst.name = body.name
if request.json.get("closed") is not None: if request.json.get("closed") is not None:
lst.closed = request.json.get("closed") lst.closed = request.json.get("closed")
lst.pos = body.pos
db.session.commit() # Track if position is changing
old_position = lst.pos
new_position = body.pos
if old_position != new_position:
# Use ListPositionService to reorder lists
ListPositionService.reorder_lists(lst.board_id, list_id, new_position)
else:
lst.pos = new_position
db.session.commit()
# Update board activity timestamp
board = db.session.get(Board, lst.board_id)
board.date_last_activity = datetime.now(UTC)
return lst.to_dict(), 200 return lst.to_dict(), 200

View file

@ -105,3 +105,5 @@ class BoardWithDetailsResponse(BaseModel):
date_last_activity: Optional[datetime] = None date_last_activity: Optional[datetime] = None
lists: List[Dict[str, Any]] = Field(default_factory=list) lists: List[Dict[str, Any]] = Field(default_factory=list)
labels: List[Dict[str, Any]] = Field(default_factory=list) labels: List[Dict[str, Any]] = Field(default_factory=list)
checklists: List[Dict[str, Any]] = Field(default_factory=list)
comments: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -0,0 +1,120 @@
"""Service for managing card positioning and reordering"""
from app import db
from app.models import Card
class CardPositionService:
"""Service for handling card position management"""
@staticmethod
def reorder_cards_in_list(
list_id: int, moved_card_id: int, new_position: float
) -> None:
"""
Reorder all cards in a list when one card is moved to a new position.
Args:
list_id: The ID of the list containing the cards
moved_card_id: The ID of the card being moved
new_position: The new position for the moved card
"""
# Get all cards in the list, ordered by their current position
all_cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
# Find the moved card in the list
moved_card = None
other_cards = []
for card in all_cards:
if card.id == moved_card_id:
moved_card = card
else:
other_cards.append(card)
if not moved_card:
return # Card not found in this list
# Remove the moved card from other_cards (already done above)
# Insert the moved card at the new position in other_cards
other_cards.insert(int(new_position), moved_card)
# Update positions for all cards to ensure unique, sequential positions
for index, card in enumerate(other_cards):
card.pos = float(index)
db.session.commit()
@staticmethod
def reorder_cards_between_lists(
from_list_id: int, to_list_id: int, moved_card_id: int, new_position: float
) -> None:
"""
Reorder cards when moving a card from one list to another.
Args:
from_list_id: The source list ID
to_list_id: The destination list ID
moved_card_id: The ID of the card being moved
new_position: The new position in the destination list
"""
# Reorder source list (remove the card and compact positions)
source_cards = (
Card.query.filter_by(list_id=from_list_id)
.filter(Card.id != moved_card_id)
.order_by(Card.pos)
.all()
)
for index, card in enumerate(source_cards):
card.pos = float(index)
# Reorder destination list (insert the card at new position)
dest_cards = Card.query.filter_by(list_id=to_list_id).order_by(Card.pos).all()
# Insert moved card at the specified position
dest_cards.insert(int(new_position), None) # Placeholder for moved card
for index, card in enumerate(dest_cards):
if card is None:
# This is where our moved card should go
moved_card = Card.query.get(moved_card_id)
if moved_card:
moved_card.pos = float(index)
else:
card.pos = float(index)
db.session.commit()
@staticmethod
def get_next_position(list_id: int) -> float:
"""
Get the next available position in a list.
Args:
list_id: The ID of the list
Returns:
The next available position (float)
"""
last_card = (
Card.query.filter_by(list_id=list_id).order_by(Card.pos.desc()).first()
)
return float(last_card.pos + 1) if last_card else 0.0
@staticmethod
def ensure_unique_positions(list_id: int) -> None:
"""
Ensure all cards in a list have unique, sequential positions.
Useful for data cleanup.
Args:
list_id: The ID of the list to fix
"""
cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
for index, card in enumerate(cards):
card.pos = float(index)
db.session.commit()

View file

@ -0,0 +1,76 @@
"""Service for managing list positioning and reordering"""
from app import db
from app.models import List
class ListPositionService:
"""Service for handling list position management"""
@staticmethod
def reorder_lists(board_id: int, moved_list_id: int, new_position: float) -> None:
"""
Reorder all lists in a board when one list is moved to a new position.
Args:
board_id: The ID of board containing lists
moved_list_id: The ID of list being moved
new_position: The new position for moved list
"""
# Get all lists in board, ordered by their current position
all_lists = List.query.filter_by(board_id=board_id).order_by(List.pos).all()
# Find moved list in board
moved_list = None
other_lists = []
for lst in all_lists:
if lst.id == moved_list_id:
moved_list = lst
else:
other_lists.append(lst)
if not moved_list:
return # List not found in this board
# Insert moved list at the new position
other_lists.insert(int(new_position), moved_list)
# Update positions for all lists to ensure unique, sequential positions
for index, lst in enumerate(other_lists):
lst.pos = float(index)
db.session.commit()
@staticmethod
def get_next_position(board_id: int) -> float:
"""
Get next available position in a board.
Args:
board_id: The ID of board
Returns:
The next available position (float)
"""
last_list = (
List.query.filter_by(board_id=board_id).order_by(List.pos.desc()).first()
)
return float(last_list.pos + 1) if last_list else 0.0
@staticmethod
def ensure_unique_positions(board_id: int) -> None:
"""
Ensure all lists in a board have unique, sequential positions.
Useful for data cleanup.
Args:
board_id: The ID of board to fix
"""
lists = List.query.filter_by(board_id=board_id).order_by(List.pos).all()
for index, lst in enumerate(lists):
lst.pos = float(index)
db.session.commit()

View file

@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@pytest.fixture(scope="session") @pytest.fixture(scope="function")
def app(): def app():
"""Create application for testing with PostgreSQL database (session scope)""" """Create application for testing with PostgreSQL database (session scope)"""
app = create_app(config_name="test") app = create_app(config_name="test")
@ -29,11 +29,13 @@ def app():
# Create tables once per session # Create tables once per session
with app.app_context(): with app.app_context():
print("--------db.create_all()------")
db.create_all() db.create_all()
yield app yield app
# Cleanup after all tests # Cleanup after all tests
db.session.remove() db.session.remove()
db.drop_all() db.drop_all()
db.engine.dispose()
@pytest.fixture @pytest.fixture

View file

@ -167,3 +167,114 @@ class TestCardRoutes:
response = client.delete("/api/cards/99999", headers=auth_headers) response = client.delete("/api/cards/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_update_card_position_within_same_list(
self, client, db_session, regular_user, auth_headers
):
"""Test updating card position within the same list reorders other cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
# Create 3 cards in sequential positions
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
card3 = Card(name="Card 3", board_id=board.id, list_id=lst.id, pos=2)
db_session.add(card1)
db_session.add(card2)
db_session.add(card3)
db_session.commit()
# Move card3 from position 2 to position 0 (top)
response = client.put(
f"/api/cards/{card3.id}",
headers=auth_headers,
json={"name": "Card 3", "pos": 0},
)
assert response.status_code == 200
# Verify all cards have unique, sequential positions
updated_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(updated_cards) == 3
assert updated_cards[0].id == card3.id
assert updated_cards[0].pos == 0.0
assert updated_cards[1].id == card1.id
assert updated_cards[1].pos == 1.0
assert updated_cards[2].id == card2.id
assert updated_cards[2].pos == 2.0
def test_update_card_position_no_change(
self, client, db_session, regular_user, auth_headers
):
"""Test updating card with same position doesn't reorder others"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add(card1)
db_session.add(card2)
db_session.commit()
original_pos1 = card1.pos
original_pos2 = card2.pos
# Update card2 but keep same position
response = client.put(
f"/api/cards/{card2.id}",
headers=auth_headers,
json={"name": "Updated Card 2", "pos": original_pos2},
)
assert response.status_code == 200
# Verify positions unchanged
updated_card1 = db.session.get(Card, card1.id)
updated_card2 = db.session.get(Card, card2.id)
assert updated_card1.pos == original_pos1
assert updated_card2.pos == original_pos2
def test_create_card_with_position(
self, client, db_session, regular_user, auth_headers
):
"""Test creating card with specific position reorders existing cards"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
# Create existing cards
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add(card1)
db_session.add(card2)
db_session.commit()
# Create new card at position 0 (should push others down)
response = client.post(
f"/api/lists/{lst.id}/cards",
headers=auth_headers,
json={"name": "New Card", "pos": 0},
)
assert response.status_code == 201
# Note: create_card endpoint doesn't use CardPositionService yet
# This test documents current behavior - positions may not be unique
# after creation
# The reordering happens when cards are moved, not when created
all_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(all_cards) == 3

View file

@ -68,7 +68,7 @@ class TestListRoutes:
assert response.status_code == 404 assert response.status_code == 404
def test_update_list_success(self, client, db_session, regular_user, auth_headers): def test_update_list_success(self, client, db_session, regular_user, auth_headers):
"""Test updating a list""" """Test updating a list name (position stays same for single list)"""
board = Board(name="Test Board", user_id=regular_user.id) board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board) db_session.add(board)
db_session.flush() db_session.flush()
@ -80,13 +80,13 @@ class TestListRoutes:
response = client.put( response = client.put(
f"/api/lists/{lst.id}", f"/api/lists/{lst.id}",
headers=auth_headers, headers=auth_headers,
json={"name": "Updated Name", "pos": 1}, json={"name": "Updated Name", "pos": 0},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.get_json() data = response.get_json()
assert data["name"] == "Updated Name" assert data["name"] == "Updated Name"
assert data["pos"] == 1 assert data["pos"] == 0
def test_update_list_not_found(self, client, db_session, auth_headers): def test_update_list_not_found(self, client, db_session, auth_headers):
"""Test updating a non-existent list""" """Test updating a non-existent list"""
@ -149,3 +149,71 @@ class TestListRoutes:
deleted_card = db.session.get(Card, card.id) deleted_card = db.session.get(Card, card.id)
assert deleted_list is None assert deleted_list is None
assert deleted_card is None assert deleted_card is None
def test_update_list_position_reorders_others(
self, client, db_session, regular_user, auth_headers
):
"""Test updating list position reorders other lists in the board"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
# Create 3 lists in sequential positions
list1 = List(name="List 1", board_id=board.id, pos=0)
list2 = List(name="List 2", board_id=board.id, pos=1)
list3 = List(name="List 3", board_id=board.id, pos=2)
db_session.add(list1)
db_session.add(list2)
db_session.add(list3)
db_session.commit()
# Move list3 from position 2 to position 0 (front)
response = client.put(
f"/api/lists/{list3.id}",
headers=auth_headers,
json={"name": "List 3", "pos": 0},
)
assert response.status_code == 200
# Verify all lists have unique, sequential positions
updated_lists = List.query.filter_by(board_id=board.id).order_by(List.pos).all()
assert len(updated_lists) == 3
assert updated_lists[0].id == list3.id
assert updated_lists[0].pos == 0.0
assert updated_lists[1].id == list1.id
assert updated_lists[1].pos == 1.0
assert updated_lists[2].id == list2.id
assert updated_lists[2].pos == 2.0
def test_update_list_position_no_change(
self, client, db_session, regular_user, auth_headers
):
"""Test updating list with same position doesn't reorder others"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
list1 = List(name="List 1", board_id=board.id, pos=0)
list2 = List(name="List 2", board_id=board.id, pos=1)
db_session.add(list1)
db_session.add(list2)
db_session.commit()
original_pos1 = list1.pos
original_pos2 = list2.pos
# Update list2 but keep same position
response = client.put(
f"/api/lists/{list2.id}",
headers=auth_headers,
json={"name": "Updated List 2", "pos": original_pos2},
)
assert response.status_code == 200
# Verify positions unchanged
updated_list1 = db.session.get(List, list1.id)
updated_list2 = db.session.get(List, list2.id)
assert updated_list1.pos == original_pos1
assert updated_list2.pos == original_pos2

View file

@ -1,514 +0,0 @@
# Kanban API Documentation
This document describes all the API endpoints for the Kanban application. All endpoints require authentication unless otherwise noted.
## Base URL
```
http://localhost:5000/api
```
## Authentication
All endpoints (except register and login) require a JWT token in the Authorization header:
```
Authorization: Bearer <your_jwt_token>
```
## Boards
### Get All Boards
```
GET /api/boards
```
Get all boards for the current authenticated user.
**Response:** Array of board objects
### Get Single Board
```
GET /api/boards/<board_id>
```
Get a board with all its details including lists, cards, and labels.
**Response:** Board object with nested lists, cards, and labels
### Create Board
```
POST /api/boards
```
Create a new board.
**Request Body:**
```json
{
"name": "My Project Board",
"description": "Project management board",
"url": "https://example.com/board/123",
"short_link": "abc123",
"short_url": "https://example.com/b/abc123",
"prefs": {},
"label_names": {},
"limits": {}
}
```
**Response:** Created board object
### Update Board
```
PUT /api/boards/<board_id>
```
Update a board.
**Request Body:** Partial board object (only include fields to update)
**Response:** Updated board object
### Delete Board
```
DELETE /api/boards/<board_id>
```
Delete a board and all its associated data.
**Response:** Success message
## Lists
### Create List
```
POST /api/boards/<board_id>/lists
```
Create a new list in a board.
**Request Body:**
```json
{
"name": "To Do",
"pos": 0
}
```
**Response:** Created list object
### Update List
```
PUT /api/lists/<list_id>
```
Update a list.
**Request Body:** Partial list object
**Response:** Updated list object
### Delete List
```
DELETE /api/lists/<list_id>
```
Delete a list and all its cards.
**Response:** Success message
## Cards
### Create Card
```
POST /api/lists/<list_id>/cards
```
Create a new card in a list.
**Request Body:**
```json
{
"name": "Task Name",
"description": "Task description",
"pos": 0,
"due": "2026-12-31T23:59:59Z",
"due_complete": false,
"badges": {},
"cover": {},
"desc_data": {}
}
```
**Response:** Created card object
### Get Single Card
```
GET /api/cards/<card_id>
```
Get a card with full details including labels, checklists, and comments.
**Response:** Card object with nested labels, checklists, items, and comments
### Update Card
```
PUT /api/cards/<card_id>
```
Update a card. Can also move card to different list by providing `list_id`.
**Request Body:** Partial card object
**Response:** Updated card object
### Delete Card
```
DELETE /api/cards/<card_id>
```
Delete a card and all its associated data.
**Response:** Success message
## Labels
### Get Board Labels
```
GET /api/boards/<board_id>/labels
```
Get all labels for a board.
**Response:** Array of label objects
### Create Label
```
POST /api/boards/<board_id>/labels
```
Create a new label in a board.
**Request Body:**
```json
{
"name": "Urgent",
"color": "red"
}
```
**Response:** Created label object
### Add Label to Card
```
POST /api/cards/<card_id>/labels
```
Add a label to a card.
**Request Body:**
```json
{
"label_id": 1
}
```
**Response:** Created card-label association object
### Remove Label from Card
```
DELETE /api/cards/<card_id>/labels/<label_id>
```
Remove a label from a card.
**Response:** Success message
## Checklists
### Create Checklist
```
POST /api/cards/<card_id>/checklists
```
Create a new checklist in a card.
**Request Body:**
```json
{
"name": "Tasks",
"pos": 0
}
```
**Response:** Created checklist object
### Create Check Item
```
POST /api/checklists/<checklist_id>/items
```
Create a new check item in a checklist.
**Request Body:**
```json
{
"name": "Complete task",
"pos": 0,
"state": "incomplete",
"due": "2026-12-31T23:59:59Z"
}
```
**Response:** Created check item object
### Update Check Item
```
PUT /api/check-items/<item_id>
```
Update a check item (typically used to toggle state).
**Request Body:** Partial check item object
**Response:** Updated check item object
### Delete Checklist
```
DELETE /api/checklists/<checklist_id>
```
Delete a checklist and all its items.
**Response:** Success message
### Delete Check Item
```
DELETE /api/check-items/<item_id>
```
Delete a check item.
**Response:** Success message
## Comments
### Get Card Comments
```
GET /api/cards/<card_id>/comments
```
Get all comments for a card, ordered by creation date (newest first).
**Response:** Array of comment objects with user information
### Create Comment
```
POST /api/cards/<card_id>/comments
```
Create a new comment on a card.
**Request Body:**
```json
{
"text": "This is a comment"
}
```
**Response:** Created comment object
### Update Comment
```
PUT /api/comments/<comment_id>
```
Update a comment (only by the comment author).
**Request Body:** Partial comment object
**Response:** Updated comment object
### Delete Comment
```
DELETE /api/comments/<comment_id>
```
Delete a comment (only by the comment author).
**Response:** Success message
## Data Models
### Board
```json
{
"id": 1,
"name": "My Board",
"description": "Board description",
"closed": false,
"url": "https://example.com",
"short_link": "abc123",
"short_url": "https://example.com/b/abc123",
"user_id": 1,
"date_last_activity": "2026-01-01T00:00:00Z",
"date_last_view": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"prefs": {},
"label_names": {},
"limits": {}
}
```
### List
```json
{
"id": 1,
"name": "To Do",
"closed": false,
"pos": 0,
"board_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Card
```json
{
"id": 1,
"name": "Task Name",
"description": "Task description",
"closed": false,
"due": "2026-12-31T23:59:59Z",
"due_complete": false,
"pos": 0,
"id_short": 1,
"board_id": 1,
"list_id": 1,
"date_last_activity": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"badges": {},
"cover": {},
"desc_data": {},
"labels": [],
"checklists": [],
"comments": []
}
```
### Label
```json
{
"id": 1,
"name": "Urgent",
"color": "red",
"uses": 0,
"board_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Checklist
```json
{
"id": 1,
"name": "Tasks",
"pos": 0,
"board_id": 1,
"card_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"items": []
}
```
### Check Item
```json
{
"id": 1,
"name": "Complete task",
"pos": 0,
"state": "incomplete",
"due": "2026-12-31T23:59:59Z",
"checklist_id": 1,
"user_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
### Comment
```json
{
"id": 1,
"text": "Comment text",
"card_id": 1,
"user_id": 1,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"user": {}
}
```
## Common Response Codes
- `200 OK` - Request successful
- `201 Created` - Resource created successfully
- `400 Bad Request` - Invalid request data
- `401 Unauthorized` - Authentication required or invalid
- `403 Forbidden` - Access denied (not your resource)
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
## Usage Examples
### Creating a complete board with lists and cards
```bash
# 1. Login to get token
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password"}'
# 2. Create a board
curl -X POST http://localhost:5000/api/boards \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "My Project"}'
# 3. Create a list
curl -X POST http://localhost:5000/api/boards/1/lists \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "To Do", "pos": 0}'
# 4. Create a card
curl -X POST http://localhost:5000/api/lists/1/cards \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Task 1", "pos": 0}'
```
### Adding labels to a card
```bash
# 1. Create a label
curl -X POST http://localhost:5000/api/boards/1/labels \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Urgent", "color": "red"}'
# 2. Add label to card
curl -X POST http://localhost:5000/api/cards/1/labels \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"label_id": 1}'
```
### Creating a checklist with items
```bash
# 1. Create checklist
curl -X POST http://localhost:5000/api/cards/1/checklists \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Subtasks", "pos": 0}'
# 2. Add check items
curl -X POST http://localhost:5000/api/checklists/1/items \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"name": "Task 1", "pos": 0}'
# 3. Mark item as complete
curl -X PUT http://localhost:5000/api/check-items/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"state": "complete"}'
```
### Adding comments
```bash
curl -X POST http://localhost:5000/api/cards/1/comments \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"text": "This is a comment"}'

View file

@ -1,300 +0,0 @@
# Kanban Backend Implementation Summary
## Overview
This document summarizes the Kanban backend implementation for the project. The backend provides a complete API for managing Kanban boards, lists, cards, labels, checklists, and comments.
## Database Schema
### Tables Created
1. **boards** - Main board entities
- id, name, description, closed, url, short_link, short_url
- user_id (foreign key to users)
- date_last_activity, date_last_view, created_at, updated_at
- JSONB fields: prefs, label_names, limits
- Relationships: lists, cards, labels
2. **lists** - Board columns/lists
- id, name, closed, pos
- board_id (foreign key to boards)
- created_at, updated_at
- Relationships: cards
3. **cards** - Task cards within lists
- id, name, description, closed, due, due_complete, pos, id_short
- board_id (foreign key to boards), list_id (foreign key to lists)
- date_last_activity, created_at, updated_at
- JSONB fields: badges, cover, desc_data
- Relationships: checklists, labels, comments
4. **labels** - Color-coded tags
- id, name, color, uses
- board_id (foreign key to boards)
- created_at, updated_at
- Relationships: card_labels
5. **card_labels** - Many-to-many relationship between cards and labels
- id, card_id, label_id
- Unique constraint on (card_id, label_id)
6. **checklists** - Checklists within cards
- id, name, pos
- board_id (foreign key to boards), card_id (foreign key to cards)
- created_at, updated_at
- Relationships: check_items
7. **check_items** - Items within checklists
- id, name, pos, state, due
- checklist_id (foreign key to checklists), user_id (foreign key to users)
- created_at, updated_at
8. **comments** - Comments on cards
- id, text
- card_id (foreign key to cards), user_id (foreign key to users)
- created_at, updated_at
## API Endpoints
### Boards (`/api/boards`)
- `GET /api/boards` - Get all boards for current user
- `GET /api/boards/<id>` - Get board with full details (lists, cards, labels)
- `POST /api/boards` - Create new board
- `PUT /api/boards/<id>` - Update board
- `DELETE /api/boards/<id>` - Delete board
### Lists (`/api/lists`, `/api/boards/<id>/lists`)
- `POST /api/boards/<board_id>/lists` - Create list in board
- `PUT /api/lists/<id>` - Update list
- `DELETE /api/lists/<id>` - Delete list
### Cards (`/api/cards`, `/api/lists/<id>/cards`)
- `POST /api/lists/<list_id>/cards` - Create card in list
- `GET /api/cards/<id>` - Get card with full details
- `PUT /api/cards/<id>` - Update card (includes moving between lists)
- `DELETE /api/cards/<id>` - Delete card
### Labels (`/api/boards/<id>/labels`, `/api/cards/<id>/labels`)
- `GET /api/boards/<board_id>/labels` - Get all labels for board
- `POST /api/boards/<board_id>/labels` - Create label
- `POST /api/cards/<card_id>/labels` - Add label to card
- `DELETE /api/cards/<card_id>/labels/<label_id>` - Remove label from card
### Checklists (`/api/checklists`, `/api/cards/<id>/checklists`)
- `POST /api/cards/<card_id>/checklists` - Create checklist
- `DELETE /api/checklists/<id>` - Delete checklist
### Check Items (`/api/check-items`, `/api/checklists/<id>/items`)
- `POST /api/checklists/<checklist_id>/items` - Create check item
- `PUT /api/check-items/<id>` - Update check item
- `DELETE /api/check-items/<id>` - Delete check item
### Comments (`/api/comments`, `/api/cards/<id>/comments`)
- `GET /api/cards/<card_id>/comments` - Get all comments for card
- `POST /api/cards/<card_id>/comments` - Create comment
- `PUT /api/comments/<id>` - Update comment
- `DELETE /api/comments/<id>` - Delete comment
## Key Features
### Authentication
- All endpoints (except register/login) require JWT authentication
- User can only access their own boards and related resources
- Comments can only be edited/deleted by their author
### Data Integrity
- Cascading deletes ensure cleanup of related data
- Foreign key constraints maintain referential integrity
- Unique constraints prevent duplicate card-label associations
### Position Tracking
- All orderable entities (lists, cards, checklists, check items) have `pos` field
- Enables flexible sorting and drag-and-drop functionality
### Activity Tracking
- Boards track `date_last_activity` and `date_last_view`
- Cards track `date_last_activity`
- Useful for showing recent activity
### JSONB Fields
- Flexible storage for complex data (prefs, limits, badges, cover, desc_data)
- Allows extensibility without schema changes
- Supports Trello-like feature parity
## File Structure
```
backend/app/
├── models/
│ ├── board.py # Board model
│ ├── list_model.py # List model (named to avoid Python conflict)
│ ├── card.py # Card model
│ ├── label.py # Label model
│ ├── card_label.py # Card-Label junction table
│ ├── checklist.py # Checklist model
│ ├── check_item.py # CheckItem model
│ ├── comment.py # Comment model
│ └── user.py # Updated with boards relationship
├── routes/
│ └── kanban.py # All Kanban API routes
└── __init__.py # Updated to import models and register blueprint
docs/
├── kanban_api.md # Complete API documentation
└── kanban_implementation_summary.md # This file
```
## Migration
Migration file: `backend/migrations/versions/1c0b9dfbd933_add_kanban_models_board_list_card_label_.py`
To apply migrations:
```bash
cd backend && . venv/bin/activate && flask db upgrade
```
## Usage Flow
### Typical User Workflow
1. **Register/Login**
- User registers account or logs in
- Receives JWT token for authentication
2. **Create Board**
- User creates a new board
- Board is associated with their user ID
3. **Add Lists**
- User adds lists (columns) to the board
- Examples: "To Do", "In Progress", "Done"
4. **Add Cards**
- User creates cards within lists
- Cards can have descriptions, due dates, etc.
5. **Enhance Cards**
- Add labels for categorization
- Add checklists for subtasks
- Add comments for collaboration
6. **Manage Work**
- Move cards between lists (drag-and-drop)
- Update card details
- Mark checklist items as complete
- Delete completed items
## Design Decisions
### Single User App
- No organization or membership models (as specified)
- Each board belongs to exactly one user
- Simplifies permissions model
### Trello-Inspired Schema
- Uses similar field names and structure as Trello
- Makes it familiar to users
- Supports importing from Trello JSON exports
### Position-Based Ordering
- Uses float `pos` field for ordering
- Allows inserting items between others
- Supports infinite granularity for drag-and-drop
### Cascading Deletes
- Deleting a board deletes all its lists, cards, labels, etc.
- Deleting a list deletes all its cards
- Ensures no orphaned data
### JSONB for Flexible Data
- Stores complex nested data without normalized tables
- Simplifies schema for optional features
- Maintains flexibility for future enhancements
## Next Steps
### Backend
- [ ] Add validation schemas (Pydantic)
- [ ] Add comprehensive tests
- [ ] Add rate limiting
- [ ] Add file upload support for attachments
- [ ] Add activity logging/history
- [ ] Add search functionality
- [ ] Add filtering and sorting options
### Frontend
- [ ] Create board list view
- [ ] Create board detail view with drag-and-drop
- [ ] Implement card creation/editing
- [ ] Implement label management
- [ ] Implement checklist functionality
- [ ] Implement comments
- [ ] Add real-time updates (WebSocket)
## Testing
Test the API using the examples in `docs/kanban_api.md` or use tools like:
- Postman
- Insomnia
- curl (command line)
Example:
```bash
# Start the backend server
make dev-services # Start postgres & redis
make dev-backend # Start Flask server
# Test endpoints
curl http://localhost:5000/api/health
```
## Security Considerations
- JWT authentication required for all operations
- User isolation: users can only access their own resources
- SQL injection prevention through SQLAlchemy ORM
- Input validation on all endpoints
- CORS configured for frontend integration
## Performance Notes
- Indexed fields: user_id, board_id, list_id, card_id, etc.
- Lazy loading relationships to avoid N+1 queries
- Efficient queries using SQLAlchemy's query builder
- JSONB fields use PostgreSQL's optimized JSON storage
## Troubleshooting
### Migration Issues
```bash
# Reset migrations (WARNING: deletes data)
cd backend && . venv/bin/activate && flask db downgrade base
rm -rf migrations/versions/*.py
flask db migrate -m "Initial migration"
flask db upgrade
```
### Database Connection Issues
```bash
# Restart services
make dev-stop-services
make dev-services
```
### Import Errors
```bash
# Ensure venv is activated
cd backend && . venv/bin/activate
pip install -r requirements/dev.txt
```
## Summary
The Kanban backend is now fully implemented with:
- ✅ Complete database schema
- ✅ RESTful API endpoints
- ✅ Authentication and authorization
- ✅ Data integrity and validation
- ✅ Comprehensive documentation
- ✅ Database migration
The backend is ready for frontend integration and testing.

View file

@ -371,6 +371,62 @@ useEffect(() => {
</div> </div>
``` ```
### Icons
- **ALWAYS** use inline SVG icons
- **NEVER** use icon libraries like lucide-react, react-icons, or font-awesome
- Create reusable SVG icon components when needed
- SVGs should be defined as functional components
```jsx
// ✅ CORRECT - Inline SVG as a component
const TrashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<span className="w-4 h-4"><TrashIcon /></span>
Delete
</button>
);
}
// ❌ WRONG - Using lucide-react
import { Trash2 } from "lucide-react";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<Trash2 className="w-4 h-4" />
Delete
</button>
);
}
// ❌ WRONG - Using react-icons
import { FaTrash } from "react-icons/fa";
function DeleteButton() {
return (
<button className="flex items-center gap-2">
<FaTrash className="w-4 h-4" />
Delete
</button>
);
}
```
**Why inline SVGs?**
1. **No dependencies**: Reduces bundle size and eliminates external dependencies
2. **Full control**: You can customize SVG properties directly in JSX
3. **Performance**: No runtime overhead from library wrappers
4. **Consistency**: All icons follow to same pattern and styling
5. **TypeScript support**: Full type safety without any issues
## Routing Rules ## Routing Rules
### React Router ### React Router
@ -746,6 +802,7 @@ export function useNewContext() {
**NEVER** hardcode API URLs **NEVER** hardcode API URLs
**NEVER** skip PropTypes validation **NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`) **NEVER** use `<a>` for navigation (use `<Link>`)
**NEVER** use icon libraries like lucide-react, react-icons, or font-awesome (always use inline SVGs)
## Checklist Before Committing ## Checklist Before Committing

View file

@ -8,10 +8,16 @@
"name": "crafting-shop-frontend", "name": "crafting-shop-frontend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"axios": "^1.6.2", "axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.1" "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
@ -469,6 +475,55 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -896,6 +951,17 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -1386,6 +1452,11 @@
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
"dev": true "dev": true
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -5785,6 +5856,21 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.71.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -6783,6 +6869,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true "dev": true
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7377,6 +7468,14 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View file

@ -14,10 +14,16 @@
"test:ui": "vitest --ui" "test:ui": "vitest --ui"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"axios": "^1.6.2", "axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.1" "react-hook-form": "^7.71.2",
"react-router-dom": "^6.20.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",

View file

@ -12,6 +12,12 @@ import { Products } from './pages/Products';
import Login from './pages/Login'; import Login from './pages/Login';
import { Register } from './pages/Register'; import { Register } from './pages/Register';
import { Orders } from './pages/Orders'; import { Orders } from './pages/Orders';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail';
import { CardDetail } from './pages/CardDetail';
const App = () => { const App = () => {
return ( return (
@ -23,9 +29,53 @@ const App = () => {
<main className="flex-1 p-8 max-w-7xl mx-auto w-full"> <main className="flex-1 p-8 max-w-7xl mx-auto w-full">
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
{/* Protected Routes */}
<Route
path="/boards"
element={
<ProtectedRoute>
<Boards />
</ProtectedRoute>
}
/>
<Route
path="/boards/new"
element={
<ProtectedRoute>
<BoardCreate />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/edit"
element={
<ProtectedRoute>
<BoardEdit />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id"
element={
<ProtectedRoute>
<BoardDetail />
</ProtectedRoute>
}
/>
<Route
path="/boards/:id/cards/:cardId"
element={
<ProtectedRoute>
<CardDetail />
</ProtectedRoute>
}
/>
{/* Legacy Routes */}
<Route path="/products" element={<Products />} />
<Route path="/cart" element={<Cart />} /> <Route path="/cart" element={<Cart />} />
<Route path="/orders" element={<Orders />} /> <Route path="/orders" element={<Orders />} />
</Routes> </Routes>

View file

@ -0,0 +1,216 @@
import CheckSquareIcon from './icons/CheckSquareIcon';
import Trash2Icon from './icons/Trash2Icon';
import Edit2Icon from './icons/Edit2Icon';
import PlusIcon from './icons/PlusIcon';
import { useModal } from '../context/modals/useModal';
import { CreateChecklistModal } from './CreateChecklistModal';
import { DeleteChecklistModal } from './DeleteChecklistModal';
import { EditCheckItemModal } from './EditCheckItemModal';
import { CheckItem } from '../types/kanban';
interface CardChecklistsProps {
checklists: any[];
cardId: number;
addChecklist: (name: string, pos: number) => Promise<boolean>;
removeChecklist: (id: number) => Promise<boolean>;
addCheckItem: (
checklistId: number,
name: string,
pos: number,
state?: 'incomplete' | 'complete'
) => Promise<boolean>;
toggleCheckItem: (item: CheckItem, currentState: 'incomplete' | 'complete') => Promise<boolean>;
editCheckItem: (
itemId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete'
) => Promise<boolean>;
removeCheckItem: (itemId: number) => Promise<boolean>;
}
export function CardChecklists({
checklists,
addChecklist,
removeChecklist,
addCheckItem,
toggleCheckItem,
editCheckItem,
removeCheckItem,
}: CardChecklistsProps) {
const { openModal } = useModal();
const handleAddChecklist = () => {
openModal((props) => (
<CreateChecklistModal
onSave={async (name) => {
const checklistCount = checklists?.length || 0;
return await addChecklist(name, checklistCount);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteChecklist = (checklist: any) => {
openModal((props) => (
<DeleteChecklistModal
checklistName={checklist.name}
onDelete={async () => {
const success = await removeChecklist(checklist.id);
if (success) {
props.onClose();
}
}}
onClose={props.onClose}
/>
));
};
const handleAddCheckItem = (checklist: any) => {
const itemName = prompt('Enter item name:');
if (itemName && itemName.trim()) {
const itemCount = checklist.items?.length || 0;
addCheckItem(checklist.id, itemName.trim(), itemCount);
}
};
const handleToggleCheckItem = async (item: any) => {
await toggleCheckItem(item, item.state);
};
const handleEditCheckItem = (item: any) => {
openModal((props) => (
<EditCheckItemModal
itemName={item.name}
onSave={async (name) => {
return await editCheckItem(item.id, name, item.pos, item.state);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCheckItem = (item: any) => {
if (confirm('Are you sure you want to delete this item?')) {
removeCheckItem(item.id);
}
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<CheckSquareIcon />
</span>
Checklists
</h2>
<button
onClick={handleAddChecklist}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-1.5 px-3 rounded-lg transition-colors flex items-center gap-1"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Checklist
</button>
</div>
{checklists && checklists.length > 0 ? (
<div className="space-y-4">
{checklists.map((checklist: any) => {
const completedCount =
checklist.items?.filter((item: any) => item.state === 'complete').length || 0;
const totalCount = checklist.items?.length || 0;
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
return (
<div key={checklist.id} className="border border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="font-semibold text-white mb-1">{checklist.name}</h3>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap">
{completedCount}/{totalCount}
</span>
</div>
</div>
<button
onClick={() => handleDeleteChecklist(checklist)}
className="text-gray-400 hover:text-red-400 transition-colors ml-2"
title="Delete checklist"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
</div>
<div className="space-y-2">
{checklist.items && checklist.items.length > 0 ? (
checklist.items.map((item: any) => (
<div
key={item.id}
className="flex items-center gap-3 p-2 bg-gray-700 rounded group hover:bg-gray-600 transition-colors"
>
<input
type="checkbox"
checked={item.state === 'complete'}
onChange={() => handleToggleCheckItem(item)}
className="w-5 h-5 rounded cursor-pointer"
/>
<span
onClick={() => handleToggleCheckItem(item)}
className={`flex-1 text-sm cursor-pointer ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
{item.name}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleEditCheckItem(item)}
className="text-gray-400 hover:text-white transition-colors p-1"
title="Edit item"
>
<span className="w-3.5 h-3.5">
<Edit2Icon />
</span>
</button>
<button
onClick={() => handleDeleteCheckItem(item)}
className="text-gray-400 hover:text-red-400 transition-colors p-1"
title="Delete item"
>
<span className="w-3.5 h-3.5">
<Trash2Icon />
</span>
</button>
</div>
</div>
))
) : (
<p className="text-gray-400 text-sm">No items yet</p>
)}
<button
onClick={() => handleAddCheckItem(checklist)}
className="text-blue-400 hover:text-blue-300 text-sm font-medium mt-2 flex items-center gap-1"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add an item
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-400 text-sm">No checklists yet. Add one to get started!</p>
)}
</div>
);
}

View file

@ -0,0 +1,185 @@
import { FormEvent, useState } from 'react';
import MessageSquareIcon from './icons/MessageSquareIcon';
import PlusIcon from './icons/PlusIcon';
interface CardCommentsProps {
card: any;
addComment: (text: string) => Promise<boolean>;
editComment: (commentId: number, text: string) => Promise<boolean>;
deleteCommentWithConfirmation: (commentId: number, onSuccess: () => void) => void;
openModal: any;
}
export function CardComments({
card,
addComment,
editComment,
deleteCommentWithConfirmation,
openModal,
}: CardCommentsProps) {
const [newCommentText, setNewCommentText] = useState('');
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [editedCommentText, setEditedCommentText] = useState('');
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const handleAddComment = async (e: FormEvent) => {
e.preventDefault();
if (!newCommentText.trim()) return;
const success = await addComment(newCommentText);
if (success) {
setNewCommentText('');
}
};
const handleEditComment = (commentId: number, text: string) => {
setEditingCommentId(commentId);
setEditedCommentText(text);
};
const handleSaveComment = async (commentId: number) => {
if (!editedCommentText.trim()) return;
const success = await editComment(commentId, editedCommentText);
if (success) {
setEditingCommentId(null);
setEditedCommentText('');
}
};
const handleDeleteComment = (commentId: number) => {
openModal((props: any) => (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Delete Comment</h3>
<p className="text-gray-300 mb-6">
Are you sure you want to delete this comment? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => props.onClose()}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => {
deleteCommentWithConfirmation(commentId, () => {
props.onClose();
});
}}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
));
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white flex items-center gap-2 mb-4">
<span className="w-5 h-5">
<MessageSquareIcon />
</span>
Comments ({card.comments?.length || 0})
</h2>
{/* Add Comment Form */}
<form onSubmit={handleAddComment} className="mb-6">
<textarea
value={newCommentText}
onChange={(e) => setNewCommentText(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 min-h-24 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Write a comment..."
/>
<button
type="submit"
disabled={!newCommentText.trim()}
className="mt-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Comment
</button>
</form>
{/* Comments List */}
<div className="space-y-4">
{card.comments && card.comments.length > 0 ? (
card.comments.map((comment: any) => (
<div key={comment.id} className="bg-gray-700 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-sm">
{comment.user?.username.charAt(0).toUpperCase() || '?'}
</div>
<div>
<p className="text-white font-medium">{comment.user?.username || 'Unknown'}</p>
<p className="text-gray-400 text-xs">{formatDate(comment.created_at)}</p>
</div>
</div>
<div className="flex gap-2">
{editingCommentId === comment.id ? (
<button
onClick={() => setEditingCommentId(null)}
className="text-gray-400 hover:text-white text-sm"
>
Cancel
</button>
) : (
<>
<button
onClick={() => handleEditComment(comment.id, comment.text)}
className="text-gray-400 hover:text-white text-sm"
>
Edit
</button>
<button
onClick={() => handleDeleteComment(comment.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Delete
</button>
</>
)}
</div>
</div>
{editingCommentId === comment.id ? (
<div className="space-y-2">
<textarea
value={editedCommentText}
onChange={(e) => setEditedCommentText(e.target.value)}
className="w-full bg-gray-600 text-white rounded-lg p-2 border border-gray-500 focus:border-blue-500 focus:outline-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveComment(comment.id)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium py-1 px-3 rounded transition-colors"
>
Save
</button>
</div>
</div>
) : (
<p className="text-gray-300 whitespace-pre-wrap">{comment.text}</p>
)}
</div>
))
) : (
<p className="text-gray-400 text-sm">No comments yet. Be the first to comment!</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useModal } from '../context/modals/useModal';
import { CreateLabelModal } from './CreateLabelModal';
import { LabelDropdown } from './LabelDropdown';
import TagIcon from './icons/TagIcon';
import { Label } from '../types/kanban';
interface CardLabelsProps {
labels: Label[];
cardLabels: Label[];
addLabel: (name: string, color: string) => Promise<boolean>;
addLabelToCard: (labelId: number) => Promise<boolean>;
removeLabelFromCard: (labelId: number) => Promise<boolean>;
refetchLabels: () => Promise<Label[]>;
refetchCard: () => Promise<any>;
}
export function CardLabels({
labels,
cardLabels,
addLabel,
addLabelToCard,
removeLabelFromCard,
refetchLabels,
refetchCard,
}: CardLabelsProps) {
const { openModal } = useModal();
const handleCreateLabel = () => {
openModal((props) => (
<CreateLabelModal
onSave={async (name, color) => {
const success = await addLabel(name, color);
if (success) {
// Refetch both labels and card data to show new labels
await refetchLabels();
await refetchCard();
}
return success;
}}
onClose={props.onClose}
/>
));
};
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<span className="w-5 h-5">
<TagIcon />
</span>
Labels
</h2>
<LabelDropdown
labels={labels}
cardLabels={cardLabels}
onAddLabel={addLabelToCard}
onRemoveLabel={removeLabelFromCard}
onCreateLabel={handleCreateLabel}
/>
</div>
<div className="flex flex-wrap gap-2">
{cardLabels && cardLabels.length > 0 ? (
cardLabels.map((label) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))
) : (
<p className="text-gray-400 text-sm">No labels added to this card</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,155 @@
import { CardWithDetails } from '../types/kanban';
import { Link } from 'react-router-dom';
interface CardPreviewModalProps {
card: CardWithDetails;
onClose: () => void;
}
export function CardPreviewModal({ card, onClose }: CardPreviewModalProps) {
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
className="text-2xl font-bold text-blue-400 hover:text-blue-300 hover:underline transition-colors"
>
{card.name}
</Link>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors text-2xl leading-none"
>
×
</button>
</div>
{/* Labels */}
{card.labels && card.labels.length > 0 && (
<div className="mb-4">
<div className="flex flex-wrap gap-2">
{card.labels.map((label) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
</div>
</div>
)}
{/* Description */}
{card.description && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white mb-2">Description</h3>
<p className="text-gray-300 whitespace-pre-wrap">{card.description}</p>
</div>
)}
{/* Checklists */}
{card.checklists && card.checklists.length > 0 && (
<div className="mb-4">
<h3 className="text-lg font-semibold text-white mb-2">Checklists</h3>
<div className="space-y-3">
{card.checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.state === 'complete'
).length;
const totalItems = checklist.items.length;
const progress = totalItems > 0 ? (completedItems / totalItems) * 100 : 0;
return (
<div key={checklist.id} className="bg-gray-700 rounded-lg p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="font-semibold text-white">{checklist.name}</h4>
<span className="text-sm text-gray-400">
{completedItems}/{totalItems}
</span>
</div>
<div className="w-full h-2 bg-gray-600 rounded-full overflow-hidden mb-3">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
{/* Checklist Items */}
<div className="space-y-2">
{checklist.items.map((item) => (
<div key={item.id} className="flex items-start gap-2 text-sm">
<div
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
item.state === 'complete'
? 'bg-green-500 border-green-500'
: 'border-gray-500'
}`}
>
{item.state === 'complete' && (
<svg
className="w-3 h-3 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
<span
className={`text-gray-300 ${
item.state === 'complete' ? 'line-through text-gray-500' : ''
}`}
>
{item.name}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Comments Preview */}
{card.comments && card.comments.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-white mb-2">Comments</h3>
<div className="space-y-3 max-h-64 overflow-y-auto">
{card.comments.slice(0, 3).map((comment) => (
<div key={comment.id} className="bg-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold text-xs">
{comment.user?.username.charAt(0).toUpperCase() || '?'}
</div>
<div>
<p className="text-white text-sm font-medium">
{comment.user?.username || 'Unknown'}
</p>
<p className="text-gray-400 text-xs">
{new Date(comment.created_at).toLocaleDateString()}
</p>
</div>
</div>
<p className="text-gray-300 text-sm line-clamp-3">{comment.text}</p>
</div>
))}
{card.comments.length > 3 && (
<p className="text-gray-400 text-sm text-center">
+{card.comments.length - 3} more comments
</p>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,68 @@
interface CardSidebarProps {
card: any;
}
export function CardSidebar({ card }: CardSidebarProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-bold text-white mb-4">Card Details</h3>
<div className="space-y-3">
<div>
<p className="text-gray-400 text-sm">Due Date</p>
<p className="text-white">{card.due ? formatDate(card.due) : 'No due date'}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Last Activity</p>
<p className="text-white">{formatDate(card.date_last_activity)}</p>
</div>
<div>
<p className="text-gray-400 text-sm">Position</p>
<p className="text-white">{card.pos}</p>
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-bold text-white mb-4">Statistics</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Labels</span>
<span className="text-white font-medium">{card.labels?.length || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Checklists</span>
<span className="text-white font-medium">{card.checklists?.length || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Comments</span>
<span className="text-white font-medium">{card.comments?.length || 0}</span>
</div>
{card.checklists && card.checklists.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-400">Completed Items</span>
<span className="text-white font-medium">
{
card.checklists
.flatMap((c: any) => c.items || [])
.filter((i: any) => i.state === 'complete').length
}{' '}
/ {card.checklists.flatMap((c: any) => c.items || []).length}
</span>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { useState } from 'react';
interface CreateChecklistModalProps {
onSave: (name: string) => Promise<boolean>;
onClose: () => void;
}
export function CreateChecklistModal({ onSave, onClose }: CreateChecklistModalProps) {
const [name, setName] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Add Checklist</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Title</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Checklist title..."
autoFocus
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Add
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,97 @@
import { useState } from 'react';
interface CreateLabelModalProps {
onSave: (name: string, color: string) => Promise<boolean>;
onClose: () => void;
}
const COLORS = [
{ name: 'Red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Pink', value: '#ec4899' },
{ name: 'Gray', value: '#6b7280' },
];
export function CreateLabelModal({ onSave, onClose }: CreateLabelModalProps) {
const [name, setName] = useState('');
const [color, setColor] = useState(COLORS[0].value);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name, color);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Create Label</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Label Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Enter label name..."
autoFocus
/>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Color</label>
<div className="grid grid-cols-4 gap-2">
{COLORS.map((colorOption) => (
<button
key={colorOption.value}
type="button"
onClick={() => setColor(colorOption.value)}
className={`h-10 rounded-lg border-2 transition-all ${
color === colorOption.value
? 'border-white ring-2 ring-white/50'
: 'border-transparent hover:border-gray-500'
}`}
style={{ backgroundColor: colorOption.value }}
title={colorOption.name}
/>
))}
</div>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Preview</label>
<div className="flex items-center gap-2">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: color }}
>
{name || 'Label Preview'}
</span>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Create
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,41 @@
import Trash2Icon from './icons/Trash2Icon';
interface DeleteCardModalProps {
cardName: string;
onDelete: () => void;
onClose: () => void;
}
export function DeleteCardModal({ cardName, onDelete, onClose }: DeleteCardModalProps) {
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<Trash2Icon />
</span>
</span>
<h3 className="text-xl font-bold text-white">Delete Card</h3>
</div>
<p className="text-gray-300 mb-6">
Are you sure you want to delete
<span className="text-white font-semibold">&quot;{cardName}&quot;</span>? This action cannot
be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import Trash2Icon from './icons/Trash2Icon';
interface DeleteChecklistModalProps {
checklistName: string;
onDelete: () => void;
onClose: () => void;
}
export function DeleteChecklistModal({
checklistName,
onDelete,
onClose,
}: DeleteChecklistModalProps) {
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<div className="flex items-center gap-3 mb-4">
<span className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
<span className="w-5 h-5">
<Trash2Icon />
</span>
</span>
<h3 className="text-xl font-bold text-white">Delete Checklist</h3>
</div>
<p className="text-gray-300 mb-6">
Are you sure you want to delete
<span className="text-white font-semibold">&quot;{checklistName}&quot;</span>? This action
cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onDelete}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
interface EditCardModalProps {
card: any;
onSave: (name: string, description: string) => Promise<boolean>;
onClose: () => void;
}
export function EditCardModal({ card, onSave, onClose }: EditCardModalProps) {
const [name, setName] = useState(card.name);
const [description, setDescription] = useState(card.description || '');
useEffect(() => {
setName(card.name);
setDescription(card.description || '');
}, [card]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name, description);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Edit Card</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Card Title</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Card title..."
autoFocus
/>
</div>
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 min-h-32 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Add a more detailed description..."
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
interface EditCheckItemModalProps {
itemName: string;
onSave: (name: string) => Promise<boolean>;
onClose: () => void;
}
export function EditCheckItemModal({ itemName, onSave, onClose }: EditCheckItemModalProps) {
const [name, setName] = useState(itemName);
useEffect(() => {
setName(itemName);
}, [itemName]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const success = await onSave(name);
if (success) {
onClose();
}
};
return (
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-white mb-4">Edit Item</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gray-300 text-sm font-medium mb-2">Item Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 text-white rounded-lg p-3 border border-gray-600 focus:border-blue-500 focus:outline-none"
placeholder="Item name..."
autoFocus
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Save
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,129 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from '../types/kanban';
import PlusIcon from './icons/PlusIcon';
interface LabelDropdownProps {
labels: Label[];
cardLabels: Label[];
onAddLabel: (labelId: number) => Promise<boolean>;
onRemoveLabel: (labelId: number) => Promise<boolean>;
onCreateLabel: () => void;
}
export function LabelDropdown({
labels,
cardLabels,
onAddLabel,
onRemoveLabel,
onCreateLabel,
}: LabelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const cardLabelIds = new Set(cardLabels.map((l) => l.id));
const availableLabels = labels.filter((l) => !cardLabelIds.has(l.id));
const handleToggleLabel = async (labelId: number, isAdded: boolean) => {
let success = false;
if (isAdded) {
success = await onRemoveLabel(labelId);
} else {
success = await onAddLabel(labelId);
}
if (success) {
setIsOpen(false);
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white px-3 py-1.5 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Add Label
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-72 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50">
<div className="p-4">
<h4 className="text-white font-medium mb-3">Labels</h4>
{/* Add new label button */}
<button
onClick={() => {
onCreateLabel();
setIsOpen(false);
}}
className="w-full mb-3 flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg transition-colors text-sm"
>
<span className="w-4 h-4">
<PlusIcon />
</span>
Create new label
</button>
{/* Available labels */}
{availableLabels.length > 0 ? (
<div className="space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Available Labels</p>
{availableLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, false)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
</button>
))}
</div>
) : (
<p className="text-gray-400 text-sm">No labels available</p>
)}
{/* Current labels (can be removed) */}
{cardLabels.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-700 space-y-2">
<p className="text-gray-400 text-xs uppercase tracking-wide">Current Labels</p>
{cardLabels.map((label) => (
<button
key={label.id}
onClick={() => handleToggleLabel(label.id, true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-700 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full border-2 border-white"
style={{ backgroundColor: label.color }}
/>
<span className="text-gray-200 text-sm">{label.name}</span>
<span className="ml-auto text-xs text-gray-400">Remove</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -35,12 +35,20 @@ export function Navbar() {
Cart Cart
</Link> </Link>
{user && ( {user && (
<Link <>
to="/orders" <Link
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors" to="/boards"
> className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
Orders >
</Link> Boards
</Link>
<Link
to="/orders"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Orders
</Link>
</>
)} )}
</div> </div>
</div> </div>

View file

@ -0,0 +1,24 @@
import { Navigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { token, loading } = useApp();
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-white text-lg">Loading...</div>
</div>
);
}
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View file

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

View file

@ -0,0 +1,18 @@
const CheckSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
);
export default CheckSquareIcon;

View file

@ -0,0 +1,17 @@
const Edit2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
);
export default Edit2Icon;

View file

@ -0,0 +1,17 @@
const MessageSquareIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
);
export default MessageSquareIcon;

View file

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

View file

@ -0,0 +1,18 @@
const TagIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
);
export default TagIcon;

View file

@ -0,0 +1,20 @@
const Trash2Icon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
);
export default Trash2Icon;

View file

@ -0,0 +1,99 @@
import { Board } from '../../types/kanban';
import { Link, useNavigate } from 'react-router-dom';
interface BoardCardProps {
board: Board;
onDelete: (id: number) => void;
}
export function BoardCard({ board, onDelete }: BoardCardProps) {
const navigate = useNavigate();
const handleDelete = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (window.confirm(`Are you sure you want to delete "${board.name}"?`)) {
onDelete(board.id);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
return (
<div onClick={() => navigate(`/boards/${board.id}`)} className="block group cursor-pointer">
<div className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-all hover:shadow-lg hover:-translate-y-1 border border-gray-700">
<div className="flex justify-between items-start mb-3">
<h3 className="text-xl font-bold text-white group-hover:text-blue-400 transition-colors">
{board.name}
</h3>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
to={`/boards/${board.id}/edit`}
onClick={(e) => e.stopPropagation()}
className="text-gray-400 hover:text-blue-400 transition-colors"
title="Edit board"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</Link>
<button
onClick={handleDelete}
className="text-gray-400 hover:text-red-400 transition-colors"
title="Delete board"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
{board.description && (
<p className="text-gray-400 text-sm mb-4 line-clamp-2">{board.description}</p>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">
Last activity: {formatDate(board.date_last_activity)}
</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ModalContentProps } from '../../types';
import { useToast } from '../../context/toasts/useToast';
const cardSchema = z.object({
name: z
.string()
.min(1, 'Card name is required')
.max(100, 'Card name must be less than 100 characters'),
description: z.string().max(2000, 'Description must be less than 2000 characters').optional(),
});
type CardFormData = z.infer<typeof cardSchema>;
interface CreateCardModalProps extends ModalContentProps {
onCreate: (data: { name: string; description?: string }) => Promise<void>;
}
export function CreateCardModal({ onClose, onCreate }: CreateCardModalProps) {
const { addNotification } = useToast();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CardFormData>({
resolver: zodResolver(cardSchema),
});
const onSubmit = async (data: CardFormData) => {
try {
await onCreate(data);
onClose();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
addNotification({
type: 'error',
title: 'Error Creating Card',
message: errorMessage,
duration: 5000,
});
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Create Card
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6">
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Card Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter card name..."
autoFocus
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div className="mb-6">
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
rows={4}
{...register('description')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Optional card description..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Create Card
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ModalContentProps } from '../../types';
import { useToast } from '../../context/toasts/useToast';
const listSchema = z.object({
name: z
.string()
.min(1, 'List name is required')
.max(100, 'List name must be less than 100 characters'),
});
type ListFormData = z.infer<typeof listSchema>;
interface CreateListModalProps extends ModalContentProps {
onCreate: (name: string) => Promise<void>;
}
export function CreateListModal({ onClose, onCreate }: CreateListModalProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ListFormData>({
resolver: zodResolver(listSchema),
});
const { addNotification } = useToast();
const onSubmit = async (data: ListFormData) => {
try {
await onCreate(data.name);
onClose();
} catch (err) {
// Error is handled by caller
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
addNotification({
type: 'error',
title: 'Error Register',
message: errorMessage,
duration: 5000,
});
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Create List
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6">
<div className="mb-6">
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
List Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., To Do, In Progress, Done"
autoFocus
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Create List
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,81 @@
import { ModalContentProps } from '../../types';
import Trash2Icon from '../icons/Trash2Icon';
interface DeleteListModalProps extends ModalContentProps {
listName: string;
onDelete: () => Promise<void>;
}
export function DeleteListModal({ onClose, onDelete, listName }: DeleteListModalProps) {
const handleDelete = async () => {
try {
await onDelete();
onClose();
} catch (err) {
console.error('Failed to delete list:', err);
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Delete List
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="p-6">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-600/20 rounded-full flex items-center justify-center">
<span className="w-6 h-6">
<Trash2Icon />
</span>
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-2">
Delete &quot;{listName}&quot;?
</h3>
<p className="text-gray-400 text-sm">
This will permanently delete the list and all cards in it. This action cannot be
undone.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Delete List
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ModalContentProps } from '../../types';
import { useToast } from '../../context/toasts/useToast';
const listSchema = z.object({
name: z
.string()
.min(1, 'List name is required')
.max(100, 'List name must be less than 100 characters'),
});
type ListFormData = z.infer<typeof listSchema>;
interface EditListModalProps extends ModalContentProps {
listName: string;
onSave: (name: string) => Promise<void>;
}
export function EditListModal({ onClose, onSave, listName }: EditListModalProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ListFormData>({
resolver: zodResolver(listSchema),
defaultValues: {
name: listName,
},
});
const { addNotification } = useToast();
const onSubmit = async (data: ListFormData) => {
try {
await onSave(data.name);
onClose();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update list';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
return (
<div className="bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-700">
<div className="flex justify-between items-center p-6 border-b border-gray-700">
<h2 id="modal-title" className="text-xl font-bold text-white">
Edit List
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="p-6">
<div className="mb-6">
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
List Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., To Do, In Progress, Done"
autoFocus
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,130 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Card as CardType } from '../../types/kanban';
import MessageSquareIcon from '../icons/MessageSquareIcon';
import CheckSquareIcon from '../icons/CheckSquareIcon';
import { Link } from 'react-router-dom';
interface KanbanCardProps {
card: CardType;
onOpenModal: () => void;
}
export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `CARD_${card.id}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
// Calculate checklist progress
const checklists = (card as any).checklists || [];
const totalItems = checklists.reduce(
(sum: number, checklist: any) => sum + checklist.items.length,
0
);
const completedItems = checklists.reduce(
(sum: number, checklist: any) =>
sum + checklist.items.filter((item: any) => item.state === 'complete').length,
0
);
const hasChecklists = totalItems > 0;
// Calculate comment count
const comments = (card as any).comments || [];
const commentCount = comments.length;
const hasComments = commentCount > 0;
// Get labels
const labels = (card as any).labels || [];
const hasLabels = labels.length > 0;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={(e) => {
// Only open modal if not clicking title link
if (!(e.target as HTMLElement).closest('a')) {
onOpenModal();
}
}}
className="bg-gray-700 rounded-lg p-4 mb-3 cursor-pointer hover:bg-gray-600 transition-colors border border-gray-600 shadow-sm"
>
{/* Labels */}
{hasLabels && (
<div className="flex flex-wrap gap-1 mb-2">
{labels.slice(0, 3).map((label: any) => (
<span
key={label.id}
className="px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: label.color }}
>
{label.name}
</span>
))}
{labels.length > 3 && (
<span className="px-2 py-0.5 rounded text-xs font-medium text-gray-400">
+{labels.length - 3}
</span>
)}
</div>
)}
<h3 className="text-white font-medium mb-2 hover:text-blue-400 transition-colors">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
{card.name}
</Link>
</h3>
{card.description && (
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{card.description}</p>
)}
{/* Badges */}
<div className="flex items-center gap-3 text-xs text-gray-400 mt-3 pt-2 border-t border-gray-600">
{/* Comments Badge */}
{hasComments && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<MessageSquareIcon />
</span>
<span>{commentCount}</span>
</div>
)}
{/* Checklist Badge */}
{hasChecklists && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<CheckSquareIcon />
</span>
<span>
{completedItems}/{totalItems}
</span>
</div>
)}
{/* Due Date Badge */}
{card.due && (
<span className={card.due_complete ? 'text-green-400' : 'text-yellow-400'}>
Due: {new Date(card.due).toLocaleDateString()}
</span>
)}
{/* Card ID */}
<span className="text-gray-500">#{card.id_short}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,166 @@
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ListWithCards, Card as CardType } from '../../types/kanban';
import { KanbanCard } from './KanbanCard';
import { CreateCardModal } from './CreateCardModal';
import { EditListModal } from './EditListModal';
import { DeleteListModal } from './DeleteListModal';
import Edit2Icon from '../icons/Edit2Icon';
import Trash2Icon from '../icons/Trash2Icon';
import { useModal } from '../../context/modals/useModal';
export interface KanbanColumnProps {
list: ListWithCards;
cards: CardType[];
onOpenCardModal: (card: CardType) => void;
onCardCreate: (data: { name: string; description?: string }) => Promise<void>;
onListEdit?: (name: string) => Promise<void>;
onListDelete?: () => Promise<void>;
dragHandleProps?: {
attributes: any;
listeners: any;
};
}
export function KanbanColumn({
list,
cards,
onOpenCardModal,
onCardCreate,
onListEdit,
onListDelete,
dragHandleProps,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: `LIST_${list.id}`,
});
const { openModal } = useModal();
const handleAddCard = () => {
openModal((props) => <CreateCardModal {...props} onCreate={onCardCreate} />);
};
const handleEditList = () => {
if (!onListEdit) return;
openModal((props) => (
<EditListModal
{...props}
listName={list.name}
onSave={async (name) => {
await onListEdit(name);
}}
/>
));
};
const handleDeleteList = () => {
if (!onListDelete) return;
openModal((props) => (
<DeleteListModal
{...props}
listName={list.name}
onDelete={async () => {
await onListDelete();
}}
/>
));
};
return (
<div className="bg-gray-800 rounded-lg p-4 min-w-[300px] max-w-[300px] border border-gray-700 flex flex-col">
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-1">
{/* Drag Handle Icon */}
<div {...dragHandleProps?.attributes} {...dragHandleProps?.listeners}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-500 cursor-grab hover:text-gray-300"
>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="9" cy="5" r="1"></circle>
<circle cx="9" cy="19" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<circle cx="15" cy="5" r="1"></circle>
<circle cx="15" cy="19" r="1"></circle>
</svg>
</div>
<h2 className="text-white font-bold text-lg">{list.name}</h2>
</div>
<div className="flex items-center gap-2">
{onListEdit && (
<button
onClick={handleEditList}
className="text-gray-400 hover:text-blue-400 transition-colors"
title="Edit list"
>
<span className="w-4 h-4">
<Edit2Icon />
</span>
</button>
)}
{onListDelete && (
<button
onClick={handleDeleteList}
className="text-gray-400 hover:text-red-400 transition-colors"
title="Delete list"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
</button>
)}
</div>
</div>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block">
{cards.length} cards
</span>
</div>
<SortableContext
id={list.id.toString()}
items={cards.map((card) => card.id.toString())}
strategy={verticalListSortingStrategy}
>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`}
>
{cards.map((card) => (
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
))}
</div>
</SortableContext>
<button
onClick={handleAddCard}
className="mt-3 w-full py-2 px-4 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white rounded-lg transition-colors flex items-center justify-center gap-2 text-sm font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Card
</button>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { useDraggable } from '@dnd-kit/core';
import { KanbanColumn, KanbanColumnProps } from './KanbanColumn';
export function SortableKanbanColumn(props: KanbanColumnProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `COLUMN_${props.list.id}`,
data: {
type: 'column',
columnId: props.list.id,
},
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
opacity: isDragging ? 0.5 : 1,
}
: undefined;
return (
<div ref={setNodeRef} style={style}>
<KanbanColumn {...props} dragHandleProps={{ attributes, listeners }} />
</div>
);
}

View file

@ -1,5 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types'; import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
import {
Board,
BoardWithDetails,
BoardCreate,
List,
Card,
CardWithDetails,
Label,
Checklist,
CommentWithUser,
} from '../types/kanban';
import { useMemo } from 'react';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
@ -35,57 +47,224 @@ api.interceptors.response.use(
); );
export function useApi() { export function useApi() {
return { return useMemo(
// Auth () => ({
login: async (email: string, password: string): Promise<AuthResponse> => { // Auth
const response = await api.post<AuthResponse>('/auth/login', { login: async (email: string, password: string): Promise<AuthResponse> => {
email, const response = await api.post<AuthResponse>('/auth/login', {
password, email,
}); password,
return response.data; });
}, return response.data;
register: async (userData: RegisterData): Promise<AuthResponse> => { },
const response = await api.post<AuthResponse>('/auth/register', userData); register: async (userData: RegisterData): Promise<AuthResponse> => {
return response.data; const response = await api.post<AuthResponse>('/auth/register', userData);
}, return response.data;
getCurrentUser: async (): Promise<UserData> => { },
const response = await api.get<UserData>('/users/me'); getCurrentUser: async (): Promise<UserData> => {
return response.data; const response = await api.get<UserData>('/users/me');
}, return response.data;
},
// Products // Products
getProducts: async (): Promise<ProductData[]> => { getProducts: async (): Promise<ProductData[]> => {
const response = await api.get<ProductData[]>('/products'); const response = await api.get<ProductData[]>('/products');
return response.data; return response.data;
}, },
getProduct: async (id: string): Promise<ProductData> => { getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`); const response = await api.get<ProductData>(`/products/${id}`);
return response.data; return response.data;
}, },
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => { createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData); const response = await api.post<ProductData>('/products', productData);
return response.data; return response.data;
}, },
updateProduct: async (id: string, productData: Partial<ProductData>): Promise<ProductData> => { updateProduct: async (
const response = await api.put<ProductData>(`/products/${id}`, productData); id: string,
return response.data; productData: Partial<ProductData>
}, ): Promise<ProductData> => {
deleteProduct: async (id: string): Promise<void> => { const response = await api.put<ProductData>(`/products/${id}`, productData);
await api.delete(`/products/${id}`); return response.data;
}, },
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`);
},
// Orders // Orders
getOrders: async (): Promise<OrderData[]> => { getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders'); const response = await api.get<OrderData[]>('/orders');
return response.data; return response.data;
}, },
getOrder: async (id: string): Promise<OrderData> => { getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`); const response = await api.get<OrderData>(`/orders/${id}`);
return response.data; return response.data;
}, },
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => { createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData); const response = await api.post<OrderData>('/orders', orderData);
return response.data; return response.data;
}, },
};
// Boards
getBoards: async (): Promise<Board[]> => {
const response = await api.get<Board[]>('/boards');
return response.data;
},
getBoard: async (id: number): Promise<BoardWithDetails> => {
const response = await api.get<BoardWithDetails>(`/boards/${id}`);
return response.data;
},
createBoard: async (boardData: BoardCreate): Promise<Board> => {
const response = await api.post<Board>('/boards', boardData);
return response.data;
},
updateBoard: async (id: number, boardData: Partial<BoardCreate>): Promise<Board> => {
const response = await api.put<Board>(`/boards/${id}`, boardData);
return response.data;
},
deleteBoard: async (id: number): Promise<void> => {
await api.delete(`/boards/${id}`);
},
// Lists
createList: async (
boardId: number,
listData: { name: string; pos: number }
): Promise<List> => {
const response = await api.post<List>(`/boards/${boardId}/lists`, listData);
return response.data;
},
updateList: async (
id: number,
listData: { name: string; pos: number; closed?: boolean }
): Promise<List> => {
const response = await api.put<List>(`/lists/${id}`, listData);
return response.data;
},
deleteList: async (id: number): Promise<void> => {
await api.delete(`/lists/${id}`);
},
// Cards
createCard: async (
listId: number,
cardData: {
name: string;
description?: string;
pos: number;
due?: string | null;
due_complete?: boolean;
badges?: Record<string, any>;
cover?: Record<string, any>;
desc_data?: Record<string, any>;
}
): Promise<Card> => {
const response = await api.post<Card>(`/lists/${listId}/cards`, cardData);
return response.data;
},
getCard: async (id: number): Promise<CardWithDetails> => {
const response = await api.get<CardWithDetails>(`/cards/${id}`);
return response.data;
},
updateCard: async (
id: number,
cardData: {
name: string;
description?: string;
pos: number;
due?: string | null;
due_complete?: boolean;
closed?: boolean;
list_id?: number;
badges?: Record<string, any>;
cover?: Record<string, any>;
desc_data?: Record<string, any>;
}
): Promise<Card> => {
const response = await api.put<Card>(`/cards/${id}`, cardData);
return response.data;
},
deleteCard: async (id: number): Promise<void> => {
await api.delete(`/cards/${id}`);
},
// Labels
getLabels: async (boardId: number): Promise<Label[]> => {
const response = await api.get<Label[]>(`/boards/${boardId}/labels`);
return response.data;
},
createLabel: async (
boardId: number,
labelData: { name: string; color: string }
): Promise<Label> => {
const response = await api.post<Label>(`/boards/${boardId}/labels`, labelData);
return response.data;
},
addLabelToCard: async (cardId: number, labelId: number): Promise<any> => {
const response = await api.post(`/cards/${cardId}/labels`, { label_id: labelId });
return response.data;
},
removeLabelFromCard: async (cardId: number, labelId: number): Promise<void> => {
await api.delete(`/cards/${cardId}/labels/${labelId}`);
},
// Checklists
createChecklist: async (
cardId: number,
checklistData: { name: string; pos: number }
): Promise<Checklist> => {
const response = await api.post<Checklist>(`/cards/${cardId}/checklists`, checklistData);
return response.data;
},
deleteChecklist: async (id: number): Promise<void> => {
await api.delete(`/checklists/${id}`);
},
// Check Items
createCheckItem: async (
checklistId: number,
itemData: {
name: string;
pos: number;
state: 'incomplete' | 'complete';
due?: string | null;
}
): Promise<any> => {
const response = await api.post(`/checklists/${checklistId}/items`, itemData);
return response.data;
},
updateCheckItem: async (
id: number,
itemData: {
name: string;
pos: number;
state: 'incomplete' | 'complete';
due?: string | null;
}
): Promise<any> => {
const response = await api.put(`/check-items/${id}`, itemData);
return response.data;
},
deleteCheckItem: async (id: number): Promise<void> => {
await api.delete(`/check-items/${id}`);
},
// Comments
getComments: async (cardId: number): Promise<CommentWithUser[]> => {
const response = await api.get<CommentWithUser[]>(`/cards/${cardId}/comments`);
return response.data;
},
createComment: async (cardId: number, commentData: { text: string }): Promise<any> => {
const response = await api.post(`/cards/${cardId}/comments`, commentData);
return response.data;
},
updateComment: async (id: number, commentData: { text: string }): Promise<any> => {
const response = await api.put(`/comments/${id}`, commentData);
return response.data;
},
deleteComment: async (id: number): Promise<void> => {
await api.delete(`/comments/${id}`);
},
}),
[]
);
} }

View file

@ -0,0 +1,139 @@
import { useNavigate } from 'react-router-dom';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { useApp } from '../context/AppContext';
import { User } from '../types';
export function useAuth() {
const navigate = useNavigate();
const { login } = useApp();
const { login: loginApi, register: registerApi } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const handleLogin = async (email: string, password: string) => {
try {
const response = await withLoader(() => loginApi(email, password), 'Logging in...');
// Convert UserData to User type
const user: User = {
id: parseInt(response.user.id),
username: response.user.username,
email: response.user.email,
};
// debugger
// Store in localStorage first
localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(user));
// Then update context
login(user, response.access_token);
// Show success toast
addNotification({
type: 'success',
title: 'Login Successful',
message: `Welcome back, ${user.username}!`,
duration: 3000,
});
// Navigate to boards
navigate('/boards');
return user;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed. Please try again.';
// Show error toast
addNotification({
type: 'error',
title: 'Login Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const handleRegister = async (userData: {
email: string;
password: string;
username: string;
first_name?: string;
last_name?: string;
}) => {
try {
const response = await withLoader(() => registerApi(userData), 'Creating account...');
// Convert UserData to User type
const user: User = {
id: parseInt(response.user.id),
username: response.user.username,
email: response.user.email,
};
// Store in localStorage first
// debugger
localStorage.setItem('token', response.access_token);
localStorage.setItem('user', JSON.stringify(user));
// Then update context
login(user, response.access_token);
// Show success toast
addNotification({
type: 'success',
title: 'Account Created',
message: `Welcome, ${user.username}!`,
duration: 3000,
});
// Navigate to boards
navigate('/boards');
return user;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Registration failed. Please try again.';
// Show error toast
addNotification({
type: 'error',
title: 'Registration Failed',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const handleLogout = () => {
// Show logout toast
addNotification({
type: 'success',
title: 'Logged Out',
message: 'You have been logged out successfully.',
duration: 3000,
});
// Clear local storage
localStorage.removeItem('token');
localStorage.removeItem('user');
// Navigate to login
navigate('/login');
// Trigger page refresh to clear app context
window.location.reload();
};
return {
login: handleLogin,
register: handleRegister,
logout: handleLogout,
};
}

View file

@ -0,0 +1,49 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { BoardWithDetails } from '../types/kanban';
export function useBoard(boardId: number) {
const [board, setBoard] = useState<BoardWithDetails | null>(null);
const [error, setError] = useState<Error | null>(null);
const { getBoard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchBoard = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getBoard(boardId), 'Loading board...');
setBoard(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load board';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Board',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [getBoard, boardId, withLoader, addNotification]);
const updateBoardData = (updatedBoard: BoardWithDetails) => {
setBoard(updatedBoard);
};
useEffect(() => {
fetchBoard();
}, [boardId, fetchBoard]);
return {
board,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchBoard,
updateBoardData,
};
}

View file

@ -0,0 +1,124 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { Board } from '../types/kanban';
export function useBoards() {
const [boards, setBoards] = useState<Board[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getBoards, createBoard, updateBoard, deleteBoard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchBoards = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getBoards(), 'Loading boards...');
setBoards(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load boards';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Boards',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [getBoards, withLoader, addNotification]);
const createNewBoard = async (boardData: { name: string; description?: string }) => {
try {
setError(null);
const data = await withLoader(() => createBoard(boardData), 'Creating board...');
setBoards((prev) => [...prev, data]);
addNotification({
type: 'success',
title: 'Board Created',
message: `Board "${data.name}" created successfully.`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create board';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Creating Board',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const updateExistingBoard = async (
id: number,
boardData: { name: string; description?: string }
) => {
try {
setError(null);
const data = await withLoader(() => updateBoard(id, boardData), 'Updating board...');
setBoards((prev) => prev.map((board) => (board.id === id ? data : board)));
addNotification({
type: 'success',
title: 'Board Updated',
message: `Board "${data.name}" updated successfully.`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update board';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Updating Board',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const removeBoard = async (id: number) => {
try {
setError(null);
await withLoader(() => deleteBoard(id), 'Deleting board...');
setBoards((prev) => prev.filter((board) => board.id !== id));
addNotification({
type: 'success',
title: 'Board Deleted',
message: 'Board deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete board';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Deleting Board',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
useEffect(() => {
fetchBoards();
}, [fetchBoards]);
return {
boards,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchBoards,
createBoard: createNewBoard,
updateBoard: updateExistingBoard,
deleteBoard: removeBoard,
};
}

View file

@ -0,0 +1,48 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useCard(cardId: number) {
const [card, setCard] = useState<any>(null);
const [error, setError] = useState<Error | null>(null);
const { getCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchCard = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getCard(cardId), 'Loading card...');
setCard(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load card';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Card',
message: errorMessage,
duration: 5000,
});
return null;
}
}, [getCard, cardId, withLoader, addNotification]);
const updateCardData = (updatedCard: any) => {
setCard(updatedCard);
};
useEffect(() => {
fetchCard();
}, [cardId, fetchCard]);
return {
card,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
fetchCard,
updateCardData,
};
}

View file

@ -0,0 +1,137 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useCardDetailMutations(cardId: number, currentCard: any, onUpdate: () => void) {
const { updateCard, deleteCard, createComment, updateComment, deleteComment } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const updateCardNameAndDescription = async (name: string, description: string) => {
try {
await withLoader(
() =>
updateCard(cardId, {
name: name,
description: description,
pos: currentCard?.pos || 0,
}),
'Updating card...'
);
onUpdate();
addNotification({
type: 'success',
title: 'Card Updated',
message: 'Card has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update card';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const deleteCardWithConfirmation = async (onSuccess: () => void) => {
try {
await withLoader(() => deleteCard(cardId), 'Deleting card...');
addNotification({
type: 'success',
title: 'Card Deleted',
message: 'Card has been deleted successfully.',
duration: 3000,
});
onSuccess();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete card';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
const addComment = async (text: string) => {
try {
await withLoader(() => createComment(cardId, { text }), 'Adding comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Added',
message: 'Your comment has been added successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const editComment = async (commentId: number, text: string) => {
try {
await withLoader(() => updateComment(commentId, { text }), 'Updating comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Updated',
message: 'Comment has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const deleteCommentWithConfirmation = async (commentId: number, onSuccess: () => void) => {
try {
await withLoader(() => deleteComment(commentId), 'Deleting comment...');
onUpdate();
addNotification({
type: 'success',
title: 'Comment Deleted',
message: 'Comment has been deleted successfully.',
duration: 3000,
});
onSuccess();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete comment';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
}
};
return {
updateCardNameAndDescription,
deleteCardWithConfirmation,
addComment,
editComment,
deleteCommentWithConfirmation,
};
}

View file

@ -0,0 +1,130 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { Card } from '../types/kanban';
export function useCardMutations(boardId: number, onUpdate: () => void) {
const { createCard, updateCard, deleteCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const createNewCard = async (
listId: number,
cardData: {
name: string;
description?: string;
pos: number;
}
) => {
try {
const data = await withLoader(() => createCard(listId, cardData), 'Creating card...');
addNotification({
type: 'success',
title: 'Card Created',
message: `Card "${data.name}" created successfully.`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
addNotification({
type: 'error',
title: 'Error Creating Card',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const updateExistingCard = async (
cardId: number,
cardData: {
name: string;
description?: string;
pos: number;
list_id?: number;
}
) => {
try {
const data = await withLoader(() => updateCard(cardId, cardData), 'Updating card...');
addNotification({
type: 'success',
title: 'Card Updated',
message: `Card "${data.name}" updated successfully.`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update card';
addNotification({
type: 'error',
title: 'Error Updating Card',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const moveCard = async (
card: Card,
fromListId: number,
toListId: number,
newPosition: number
) => {
try {
// Optimistic update - don't show loader for drag operations
await updateCard(card.id, {
name: card.name, // Placeholder, will be filled by server
pos: newPosition,
list_id: toListId,
});
onUpdate();
addNotification({
type: 'success',
title: 'Card Moved',
message: 'Card moved successfully.',
duration: 2000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to move card';
addNotification({
type: 'error',
title: 'Error Moving Card',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const removeCard = async (cardId: number) => {
try {
await withLoader(() => deleteCard(cardId), 'Deleting card...');
addNotification({
type: 'success',
title: 'Card Deleted',
message: 'Card deleted successfully.',
duration: 3000,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete card';
addNotification({
type: 'error',
title: 'Error Deleting Card',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
return {
createCard: createNewCard,
updateCard: updateExistingCard,
moveCard,
deleteCard: removeCard,
};
}

View file

@ -0,0 +1,167 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { CheckItem } from '../types/kanban';
export function useChecklistMutations(cardId: number, onUpdate: () => void) {
const { createChecklist, deleteChecklist, createCheckItem, updateCheckItem, deleteCheckItem } =
useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addChecklist = async (name: string, pos: number) => {
try {
await withLoader(() => createChecklist(cardId, { name, pos }), 'Creating checklist...');
onUpdate();
addNotification({
type: 'success',
title: 'Checklist Created',
message: 'Checklist has been created successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create checklist';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeChecklist = async (checklistId: number) => {
try {
await withLoader(() => deleteChecklist(checklistId), 'Deleting checklist...');
onUpdate();
addNotification({
type: 'success',
title: 'Checklist Deleted',
message: 'Checklist has been deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete checklist';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const addCheckItem = async (
checklistId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete' = 'incomplete'
) => {
try {
await withLoader(() => createCheckItem(checklistId, { name, pos, state }), 'Adding item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Added',
message: 'Check item has been added successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const toggleCheckItem = async (item: CheckItem, currentState: 'incomplete' | 'complete') => {
console.log('item', item);
try {
const newState = currentState === 'incomplete' ? 'complete' : 'incomplete';
await withLoader(
() => updateCheckItem(item.id, { name: item.name, pos: 0, state: newState }),
'Updating item...'
);
onUpdate();
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const editCheckItem = async (
itemId: number,
name: string,
pos: number,
state: 'incomplete' | 'complete'
) => {
try {
await withLoader(() => updateCheckItem(itemId, { name, pos, state }), 'Updating item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Updated',
message: 'Check item has been updated successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeCheckItem = async (itemId: number) => {
try {
await withLoader(() => deleteCheckItem(itemId), 'Deleting item...');
onUpdate();
addNotification({
type: 'success',
title: 'Item Deleted',
message: 'Check item has been deleted successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete item';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addChecklist,
removeChecklist,
addCheckItem,
toggleCheckItem,
editCheckItem,
removeCheckItem,
};
}

View file

@ -0,0 +1,93 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useLabelMutations(boardId: number, onUpdate: () => void) {
const { createLabel } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addLabel = async (name: string, color: string) => {
try {
await withLoader(() => createLabel(boardId, { name, color }), 'Creating label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Created',
message: 'Label has been created successfully.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addLabel,
};
}
export function useCardLabelMutations(cardId: number, onUpdate: () => void) {
const { addLabelToCard, removeLabelFromCard } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const addLabelToCardMutation = async (labelId: number) => {
try {
await withLoader(() => addLabelToCard(cardId, labelId), 'Adding label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Added',
message: 'Label has been added to the card.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
const removeLabelFromCardMutation = async (labelId: number) => {
try {
await withLoader(() => removeLabelFromCard(cardId, labelId), 'Removing label...');
onUpdate();
addNotification({
type: 'success',
title: 'Label Removed',
message: 'Label has been removed from the card.',
duration: 3000,
});
return true;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to remove label';
addNotification({
type: 'error',
title: 'Error',
message: errorMessage,
duration: 5000,
});
return false;
}
};
return {
addLabelToCardMutation,
removeLabelFromCardMutation,
};
}

View file

@ -0,0 +1,46 @@
import { useState, useEffect, useCallback } from 'react';
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
import { Label } from '../types/kanban';
export function useLabels(boardId: number) {
const [labels, setLabels] = useState<Label[]>([]);
const [error, setError] = useState<Error | null>(null);
const { getLabels } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const fetchLabels = useCallback(async () => {
try {
setError(null);
const data = await withLoader(() => getLabels(boardId), 'Loading labels...');
setLabels(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load labels';
setError(err instanceof Error ? err : new Error(errorMessage));
addNotification({
type: 'error',
title: 'Error Loading Labels',
message: errorMessage,
duration: 5000,
});
return [];
}
}, [boardId, getLabels, withLoader, addNotification]);
useEffect(() => {
if (boardId) {
fetchLabels();
}
}, [boardId, fetchLabels]);
return {
labels,
error, // For debugging, not for UI display
loading: false, // Loading is handled by global loader
refetch: fetchLabels,
};
}

View file

@ -0,0 +1,86 @@
import { useApi } from './useApi';
import { useLoader } from '../context/loaders/useLoader';
import { useToast } from '../context/toasts/useToast';
export function useListMutations(boardId: number, onUpdate: () => void) {
const { createList, updateList, deleteList } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
const createNewList = async (name: string, pos: number) => {
try {
const data = await withLoader(() => createList(boardId, { name, pos }), 'Creating list...');
onUpdate();
addNotification({
type: 'success',
title: 'List Created',
message: `List "${data.name}" created successfully.`,
duration: 3000,
});
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create list';
addNotification({
type: 'error',
title: 'Error Creating List',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const updateExistingList = async (listId: number, data: { name: string; pos: number }) => {
try {
const updated = await withLoader(
() => updateList(listId, { ...data, closed: false }),
'Updating list...'
);
onUpdate(); // Refresh board data after update
addNotification({
type: 'success',
title: 'List Updated',
message: `List "${updated.name}" updated successfully.`,
duration: 3000,
});
return updated;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update list';
addNotification({
type: 'error',
title: 'Error Updating List',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
const removeList = async (listId: number) => {
try {
await withLoader(() => deleteList(listId), 'Deleting list...');
addNotification({
type: 'success',
title: 'List Deleted',
message: 'List deleted successfully.',
duration: 3000,
});
onUpdate(); // Refresh board data after delete
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to delete list';
addNotification({
type: 'error',
title: 'Error Deleting List',
message: errorMessage,
duration: 5000,
});
throw err;
}
};
return {
createList: createNewList,
updateList: updateExistingList,
deleteList: removeList,
};
}

View file

@ -0,0 +1,112 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useBoards } from '../hooks/useBoards';
import { useToast } from '../context/toasts/useToast';
const boardSchema = z.object({
name: z
.string()
.min(1, 'Board name is required')
.max(100, 'Board name must be less than 100 characters'),
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
});
type BoardFormData = z.infer<typeof boardSchema>;
export function BoardCreate() {
const navigate = useNavigate();
const { createBoard } = useBoards();
const { addNotification } = useToast();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<BoardFormData>({
resolver: zodResolver(boardSchema),
});
const onSubmit = async (data: BoardFormData) => {
try {
const newBoard = await createBoard(data);
addNotification({
type: 'success',
title: 'Board Created',
message: `Board "${newBoard.name}" created successfully.`,
duration: 3000,
});
navigate(`/boards/${newBoard.id}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create board';
addNotification({
type: 'error',
title: 'Error Creating Board',
message: errorMessage,
duration: 5000,
});
}
};
return (
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors">
Back to Boards
</Link>
</div>
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<h1 className="text-3xl font-bold text-white mb-6">Create New Board</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Board Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Project Alpha, Personal Tasks"
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
rows={4}
{...register('description')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Optional description for your board..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Link
to="/boards"
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</Link>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Create Board
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,306 @@
import { useParams, Link } from 'react-router-dom';
import { useBoard } from '../hooks/useBoard';
import { useCardMutations } from '../hooks/useCardMutations';
import { useListMutations } from '../hooks/useListMutations';
import { SortableKanbanColumn } from '../components/kanban/SortableKanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal';
import { useModal } from '../context/modals/useModal';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DragOverEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { Card as CardType, ListWithCards } from '../types/kanban';
import { useState } from 'react';
export function BoardDetail() {
const { id } = useParams<{ id: string }>();
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
const { createList, updateList, deleteList } = useListMutations(parseInt(id || '0'), fetchBoard);
const { openModal } = useModal();
const [activeCard, setActiveCard] = useState<CardType | null>(null);
const [activeList, setActiveList] = useState<ListWithCards | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const [activeType, activeIdStr] = (active.id as string).split('_');
if (activeType === 'COLUMN') {
// Dragging a column
const listId = parseInt(activeIdStr);
const list = board?.lists.find((l) => l.id === listId);
if (list) {
setActiveList(list);
}
} else if (activeType === 'CARD') {
// Dragging a card
const cardId = parseInt(activeIdStr);
if (board) {
const card = board.lists.flatMap((list) => list.cards).find((c) => c.id === cardId);
if (card) {
setActiveCard(card);
}
}
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
// console.log('---handleDragOver', event);
if (!over) return;
// const activeId = parseInt(active.id as string);
// const overId = parseInt(over.id as string);
const overIdStr = (over.id as string).split('_')[1];
const overId = parseInt(overIdStr, 10);
const activeIdStr = (active.id as string).split('_')[1];
const activeId = parseInt(activeIdStr, 10);
if (activeId === overId) return;
// Find active card and its current list
if (!board) return;
const activeList = board.lists.find((list) => list.cards.some((card) => card.id === activeId));
// If we're hovering over a card in same list, do nothing
if (activeList) {
const overCard = activeList.cards.find((card) => card.id === overId);
if (overCard) return;
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveCard(null);
setActiveList(null);
if (!over || !board) return;
const [activeType, activeIdStr] = (active.id as string).split('_');
const [overType, overIdStr] = (over.id as string).split('_');
const activeId = parseInt(activeIdStr, 10);
const overId = parseInt(overIdStr, 10);
if (active.id === over.id) return;
// Handle column reordering
if (activeType === 'COLUMN') {
// todo find over column id,
let overListIndex = -1;
const activeList = board.lists.find((l) => l.id === activeId);
if (overType === 'CARD') {
overListIndex = board.lists.findIndex((l) => {
const foundIndex = l.cards.findIndex((card) => card.id === overId);
return foundIndex >= 0;
});
} else if (overType === 'LIST') {
overListIndex = board.lists.findIndex((l) => l.id === overId);
}
// console.log('-------active.id', active.id)
// console.log('-------overType.id', overType)
// console.log('-------overListIndex', overListIndex)
const activeListIndex = board.lists.findIndex((l) => l.id === activeId);
// overListIndex = board.lists.findIndex((l) => l.id === overId);
if (activeListIndex === -1 || overListIndex === -1 || !activeList) return;
// Calculate new positions for all lists
const reorderedLists = [...board.lists];
const [movedList] = reorderedLists.splice(activeListIndex, 1);
reorderedLists.splice(overListIndex, 0, movedList);
await updateList(activeList.id, { name: activeList.name, pos: overListIndex });
// // Update all list positions
// for (let i = 0; i < reorderedLists.length; i++) {
// const list = reorderedLists[i];
// if (list.pos !== i) {
// await updateList(list.id, { name: list.name, pos: i });
// }
// }
return;
}
// Handle card reordering (existing logic)
// Find active card
let activeCard: CardType | undefined;
let activeList: (typeof board.lists)[0] | undefined;
for (const list of board.lists) {
const card = list.cards.find((c) => c.id === activeId);
if (card) {
activeCard = card;
activeList = list;
break;
}
}
if (!activeCard || !activeList) return;
// Check if we're dropping on a list or a card
if (overType.toLowerCase() === 'list') {
const overList = board.lists.find((list) => list.id === overId);
// Dropping on a list - append to end
if (!overList || overList.id === activeList.id) return; // Same list, do nothing
await moveCard(activeCard, activeList.id, overList.id, overList.cards.length);
return;
}
// Dropping on a card - find which list it belongs to
let overCard: CardType | undefined;
let overListContainingCard: (typeof board.lists)[0] | undefined;
for (const list of board.lists) {
const card = list.cards.find((c) => c.id === overId);
if (card) {
overCard = card;
overListContainingCard = list;
break;
}
}
if (!overCard || !overListContainingCard) return;
// Calculate new position
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
};
const handleOpenCardModal = (card: CardType) => {
openModal((props) => <CardPreviewModal card={card as any} onClose={props.onClose} />);
};
const handleAddList = () => {
openModal((props) => (
<CreateListModal
{...props}
onCreate={async (name) => {
await createList(name, board ? board.lists.length : 0);
fetchBoard(); // Refresh board data
}}
/>
));
};
const handleEditList = async (listId: number, name: string) => {
const list = board?.lists.find((l) => l.id === listId);
if (list) {
await updateList(listId, { name, pos: list.pos });
}
};
const handleDeleteList = async (listId: number) => {
await deleteList(listId);
};
const handleAddCard =
(listId: number) => async (data: { name: string; description?: string }) => {
await createCard(listId, {
name: data.name,
description: data.description,
pos: board ? board.lists.find((list) => list.id === listId)?.cards.length || 0 : 0,
});
fetchBoard(); // Refresh board data
};
if (!board) {
return <div className="text-white">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<Link to="/boards" className="text-gray-400 hover:text-white transition-colors text-sm">
Back to Boards
</Link>
<h1 className="text-3xl font-bold text-white mt-2">{board.name}</h1>
{board.description && <p className="text-gray-400 mt-1">{board.description}</p>}
</div>
<div className="flex gap-2">
<Link
to={`/boards/${id}/edit`}
className="bg-gray-700 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Edit Board
</Link>
<button
onClick={handleAddList}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
+ Add List
</button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={board.lists.map((list) => `COLUMN_${list.id}`)}
strategy={horizontalListSortingStrategy}
>
<div className="flex gap-4 overflow-x-auto pb-4">
{board.lists.map((list) => (
<SortableKanbanColumn
key={list.id}
list={list}
cards={list.cards}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
onListEdit={(name) => handleEditList(list.id, name)}
onListDelete={() => handleDeleteList(list.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeCard ? (
<div className="bg-gray-700 rounded-lg p-4 cursor-grabbing border border-gray-600 shadow-lg opacity-80">
<h3 className="text-white font-medium">{activeCard.name}</h3>
</div>
) : activeList ? (
<div className="bg-gray-800 rounded-lg p-4 cursor-grabbing border border-gray-700 shadow-lg opacity-80 min-w-[300px] max-w-[300px]">
<h2 className="text-white font-bold text-lg">{activeList.name}</h2>
<span className="bg-gray-600 text-gray-300 text-xs px-2 py-1 rounded-full inline-block mt-2">
{activeList.cards.length} cards
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}

View file

@ -0,0 +1,125 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link, useParams } from 'react-router-dom';
import { useBoard } from '../hooks/useBoard';
import { useBoards } from '../hooks/useBoards';
import { useEffect } from 'react';
import { useToast } from '../context/toasts/useToast';
const boardSchema = z.object({
name: z
.string()
.min(1, 'Board name is required')
.max(100, 'Board name must be less than 100 characters'),
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
});
type BoardFormData = z.infer<typeof boardSchema>;
export function BoardEdit() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { board } = useBoard(parseInt(id || '0'));
const { updateBoard } = useBoards();
const { addNotification } = useToast();
const {
register,
handleSubmit,
formState: { errors },
setValue,
} = useForm<BoardFormData>({
resolver: zodResolver(boardSchema),
});
// Populate form when board data is loaded
useEffect(() => {
if (board) {
setValue('name', board.name);
setValue('description', board.description || '');
}
}, [board, setValue]);
const onSubmit = async (data: BoardFormData) => {
if (!id) return;
try {
await updateBoard(parseInt(id), data);
navigate(`/boards/${id}`);
} catch (err) {
// Error is handled by the hook
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
addNotification({
type: 'error',
title: 'Error Login',
message: errorMessage,
duration: 5000,
});
}
};
if (!board) {
return <div className="text-white">Loading...</div>;
}
return (
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<Link to={`/boards/${id}`} className="text-gray-400 hover:text-white transition-colors">
Back to Board
</Link>
</div>
<div className="bg-gray-800 rounded-lg p-8 border border-gray-700">
<h1 className="text-3xl font-bold text-white mb-6">Edit Board</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Board Name <span className="text-red-400">*</span>
</label>
<input
id="name"
type="text"
{...register('name')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Project Alpha, Personal Tasks"
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
rows={4}
{...register('description')}
className="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Optional description for your board..."
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">{errors.description.message}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4">
<Link
to={`/boards/${id}`}
className="px-6 py-3 text-gray-300 hover:text-white transition-colors"
>
Cancel
</Link>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,180 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useChecklistMutations } from '../hooks/useChecklistMutations';
import { useLabels } from '../hooks/useLabels';
import { useLabelMutations, useCardLabelMutations } from '../hooks/useLabelMutations';
import { useModal } from '../context/modals/useModal';
import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments';
import { CardChecklists } from '../components/CardChecklists';
import { CardLabels } from '../components/CardLabels';
import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal';
import Trash2Icon from '../components/icons/Trash2Icon';
import ArrowLeftIcon from '../components/icons/ArrowLeftIcon';
import Edit2Icon from '../components/icons/Edit2Icon';
export function CardDetail() {
const { id: boardId, cardId } = useParams<{ id: string; cardId: string }>();
const navigate = useNavigate();
const { card, fetchCard } = useCard(parseInt(cardId || '0'));
const {
updateCardNameAndDescription,
deleteCardWithConfirmation,
addComment,
editComment,
deleteCommentWithConfirmation,
} = useCardDetailMutations(parseInt(cardId || '0'), card, fetchCard);
const { openModal } = useModal();
const checklistMutations = useChecklistMutations(parseInt(cardId || '0'), fetchCard);
// Labels functionality
const { labels, refetch: refetchLabels } = useLabels(parseInt(boardId || '0'));
const { addLabel } = useLabelMutations(parseInt(boardId || '0'), refetchLabels);
const { addLabelToCardMutation, removeLabelFromCardMutation } = useCardLabelMutations(
parseInt(cardId || '0'),
fetchCard
);
const handleEditCard = () => {
if (!card) return;
openModal((props) => (
<EditCardModal
card={card}
onSave={async (name, description) => {
return await updateCardNameAndDescription(name, description);
}}
onClose={props.onClose}
/>
));
};
const handleDeleteCard = () => {
if (!card) return;
openModal((props) => (
<DeleteCardModal
cardName={card.name}
onDelete={async () => {
deleteCardWithConfirmation(() => {
props.onClose();
navigate(`/boards/${boardId}`);
});
}}
onClose={props.onClose}
/>
));
};
if (!card) {
return null;
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="space-y-6">
<div className="flex justify-between items-start">
<div>
<Link
to={`/boards/${boardId}`}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center gap-1"
>
<span className="w-4 h-4">
<ArrowLeftIcon />
</span>
Back to Board
</Link>
<div className="flex items-center gap-3 mt-2">
<h1 className="text-3xl font-bold text-white">{card.name}</h1>
<button
onClick={handleEditCard}
className="text-gray-400 hover:text-white transition-colors"
title="Edit card"
>
<span className="w-5 h-5">
<Edit2Icon />
</span>
</button>
</div>
<p className="text-gray-400 text-sm mt-1">
In list Created {formatDate(card.created_at)}
</p>
</div>
<button
onClick={handleDeleteCard}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center gap-2"
>
<span className="w-4 h-4">
<Trash2Icon />
</span>
Delete Card
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Description Section */}
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2">Description</h2>
<button
onClick={handleEditCard}
className="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
Edit
</button>
</div>
<p className="text-gray-300 whitespace-pre-wrap">
{card.description || 'No description added yet.'}
</p>
</div>
{/* Labels Section */}
<CardLabels
labels={labels}
cardLabels={card.labels || []}
addLabel={addLabel}
addLabelToCard={addLabelToCardMutation}
removeLabelFromCard={removeLabelFromCardMutation}
refetchLabels={refetchLabels}
refetchCard={fetchCard}
/>
{/* Checklists Section */}
<CardChecklists
checklists={card.checklists || []}
cardId={parseInt(cardId || '0')}
addChecklist={checklistMutations.addChecklist}
removeChecklist={checklistMutations.removeChecklist}
addCheckItem={checklistMutations.addCheckItem}
toggleCheckItem={checklistMutations.toggleCheckItem}
editCheckItem={checklistMutations.editCheckItem}
removeCheckItem={checklistMutations.removeCheckItem}
/>
<CardComments
card={card}
addComment={addComment}
editComment={editComment}
deleteCommentWithConfirmation={deleteCommentWithConfirmation}
openModal={openModal}
/>
</div>
<CardSidebar card={card} />
</div>
</div>
);
}

View file

@ -1,38 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useApp } from '../context/AppContext'; import { useAuth } from '../hooks/useAuth';
import { useApi } from '../hooks/useApi'; import { useToast } from '../context/toasts/useToast';
import { User } from '../types';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const { login: handleLogin } = useAuth();
const [loading, setLoading] = useState(false);
const navigate = useNavigate(); const { addNotification } = useToast();
const { login } = useApp();
const { login: loginApi } = useApi();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError('');
setLoading(true);
try { try {
const response = await loginApi(email, password); await handleLogin(email, password);
// Convert UserData to User type
const user: User = {
id: parseInt(response.user.id),
username: response.user.username,
email: response.user.email,
};
login(user, response.token);
navigate('/');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); // Error is handled by the hook (toast shown)
} finally { const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
setLoading(false); addNotification({
type: 'error',
title: 'Error Login',
message: errorMessage,
duration: 5000,
});
} }
}; };
@ -41,12 +31,6 @@ export default function Login() {
<h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1> <h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
{error}
</div>
)}
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2"> <label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email Email
@ -77,16 +61,15 @@ export default function Login() {
<button <button
type="submit" type="submit"
disabled={loading} className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? 'Logging in...' : 'Login'} Login
</button> </button>
</form> </form>
<p className="mt-6 text-center text-gray-400"> <p className="mt-6 text-center text-gray-400">
Don&apos;t have an account? Don&apos;t have an account?
<Link to="/register" className="text-blue-400 hover:text-blue-300"> <Link to="/register" className="ml-2 text-blue-400 hover:text-blue-300">
Register Register
</Link> </Link>
</p> </p>

View file

@ -1,6 +1,7 @@
import { useState, FormEvent, ChangeEvent } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useApi } from '../hooks/useApi'; import { useAuth } from '../hooks/useAuth';
import { useToast } from '../context/toasts/useToast';
interface FormData { interface FormData {
email: string; email: string;
@ -20,48 +21,44 @@ export function Register() {
first_name: '', first_name: '',
last_name: '', last_name: '',
}); });
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const navigate = useNavigate(); const { register: handleRegister } = useAuth();
const { register } = useApi(); const { addNotification } = useToast();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value, [e.target.name]: e.target.value,
}); });
}; };
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) { if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return; return;
} }
if (formData.password.length < 6) { if (formData.password.length < 6) {
setError('Password must be at least 6 characters');
return; return;
} }
setLoading(true);
try { try {
await register({ await handleRegister({
email: formData.email, email: formData.email,
username: formData.username, username: formData.username,
password: formData.password, password: formData.password,
first_name: formData.first_name, first_name: formData.first_name,
last_name: formData.last_name, last_name: formData.last_name,
}); });
navigate('/login'); } catch (err) {
} catch (err: any) { const errorMessage = err instanceof Error ? err.message : 'Failed to register';
setError(err.response?.data?.error || 'Registration failed. Please try again.'); addNotification({
} finally { type: 'error',
setLoading(false); title: 'Registration Error',
message: errorMessage,
duration: 5000,
});
} }
}; };
@ -70,12 +67,6 @@ export function Register() {
<h1 className="text-3xl font-bold text-white mb-8 text-center">Register</h1> <h1 className="text-3xl font-bold text-white mb-8 text-center">Register</h1>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-300 mb-2"> <label htmlFor="first_name" className="block text-sm font-medium text-gray-300 mb-2">
@ -166,18 +157,30 @@ export function Register() {
/> />
</div> </div>
{formData.password !== formData.confirmPassword && formData.confirmPassword && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
Passwords do not match
</div>
)}
{formData.password.length < 6 && formData.password && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
Password must be at least 6 characters
</div>
)}
<button <button
type="submit" type="submit"
disabled={loading} disabled={formData.password !== formData.confirmPassword || formData.password.length < 6}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? 'Registering...' : 'Register'} Register
</button> </button>
</form> </form>
<p className="mt-6 text-center text-gray-400"> <p className="mt-6 text-center text-gray-400">
Already have an account?{' '} Already have an account?
<Link to="/login" className="text-blue-400 hover:text-blue-300"> <Link to="/login" className="ml-2 text-blue-400 hover:text-blue-300">
Login Login
</Link> </Link>
</p> </p>

View file

@ -0,0 +1,185 @@
// Board types
export interface Board {
id: number;
name: string;
description: string | null;
closed: boolean;
url: string | null;
short_link: string | null;
short_url: string | null;
user_id: number;
date_last_activity: string;
date_last_view: string;
created_at: string;
updated_at: string;
prefs: Record<string, any>;
label_names: Record<string, any>;
limits: Record<string, any>;
}
export interface BoardWithDetails extends Board {
lists: ListWithCards[];
labels: Label[];
}
export interface BoardCreate {
name: string;
description?: string;
url?: string;
short_link?: string;
short_url?: string;
prefs?: Record<string, any>;
label_names?: Record<string, any>;
limits?: Record<string, any>;
}
export interface BoardUpdate extends Partial<BoardCreate> {
closed?: boolean;
}
// List types
export interface List {
id: number;
name: string;
closed: boolean;
pos: number;
board_id: number;
created_at: string;
updated_at: string;
}
export interface ListWithCards extends List {
cards: Card[];
}
export interface ListCreate {
name: string;
pos: number;
}
export interface ListUpdate extends Partial<ListCreate> {
closed?: boolean;
}
// Card types
export interface Card {
id: number;
name: string;
description: string | null;
closed: boolean;
due: string | null;
due_complete: boolean;
pos: number;
id_short: number;
board_id: number;
list_id: number;
date_last_activity: string;
created_at: string;
updated_at: string;
badges: Record<string, any>;
cover: Record<string, any>;
desc_data: Record<string, any>;
}
export interface CardWithDetails extends Card {
labels: Label[];
checklists: Checklist[];
comments: Comment[];
}
export interface CardCreate {
name: string;
description?: string;
pos: number;
due?: string | null;
due_complete?: boolean;
badges?: Record<string, any>;
cover?: Record<string, any>;
desc_data?: Record<string, any>;
}
export interface CardUpdate extends Partial<CardCreate> {
closed?: boolean;
list_id?: number;
}
// Label types
export interface Label {
id: number;
name: string;
color: string;
uses: number;
board_id: number;
created_at: string;
updated_at: string;
}
export interface LabelCreate {
name: string;
color: string;
}
// Checklist types
export interface Checklist {
id: number;
name: string;
pos: number;
board_id: number;
card_id: number;
created_at: string;
updated_at: string;
items: CheckItem[];
}
export interface ChecklistCreate {
name: string;
pos: number;
}
// CheckItem types
export interface CheckItem {
id: number;
name: string;
pos: number;
state: 'incomplete' | 'complete';
due: string | null;
checklist_id: number;
user_id: number;
created_at: string;
updated_at: string;
}
export interface CheckItemCreate {
name: string;
pos: number;
state: 'incomplete' | 'complete';
due?: string | null;
}
// Comment types
export interface Comment {
id: number;
text: string;
card_id: number;
user_id: number;
created_at: string;
updated_at: string;
user?: User;
}
export interface CommentWithUser extends Comment {
user: User;
}
export interface CommentCreate {
text: string;
}
// User type for comments
export interface User {
id: number;
username: string;
email: string;
first_name?: string;
last_name?: string;
}

View file

@ -20,11 +20,11 @@ export interface RegisterData {
email: string; email: string;
password: string; password: string;
username: string; username: string;
first_name: string; first_name?: string;
last_name: string; last_name?: string;
} }
export interface AuthResponse { export interface AuthResponse {
token: string; access_token: string;
user: UserData; user: UserData;
} }