Compare commits
No commits in common. "090090b3531441b2cc273d9985594108874c172e" and "94e157da4b1f6152fe943d652b31ce19d45da3f4" have entirely different histories.
090090b353
...
94e157da4b
66 changed files with 964 additions and 5113 deletions
12
.github/workflows/backend.yml
vendored
12
.github/workflows/backend.yml
vendored
|
|
@ -57,6 +57,17 @@ 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
|
||||||
|
|
@ -67,3 +78,4 @@ 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
|
||||||
|
|
||||||
|
|
@ -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=12)
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||||
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": 1, # Only one connection in the pool
|
"pool_size": 20, # Only one connection in the pool
|
||||||
"max_overflow": 0, # No overflow connections allowed
|
"max_overflow": 10, # 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
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,12 @@ 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()
|
||||||
|
|
@ -46,24 +43,6 @@ 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})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -87,18 +86,12 @@ 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:
|
||||||
|
|
@ -115,19 +108,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
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 Board, List
|
from app.models import 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
|
||||||
|
|
||||||
|
|
@ -40,21 +37,9 @@ 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
|
||||||
|
|
||||||
# Track if position is changing
|
db.session.commit()
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,5 +105,3 @@ 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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
@ -11,7 +11,7 @@ log = logging.getLogger(__name__)
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="session")
|
||||||
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,13 +29,11 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -167,114 +167,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -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 name (position stays same for single list)"""
|
"""Test updating a 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": 0},
|
json={"name": "Updated Name", "pos": 1},
|
||||||
)
|
)
|
||||||
|
|
||||||
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"] == 0
|
assert data["pos"] == 1
|
||||||
|
|
||||||
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,71 +149,3 @@ 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
|
|
||||||
|
|
|
||||||
514
docs/kanban_api.md
Normal file
514
docs/kanban_api.md
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
# 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"}'
|
||||||
300
docs/kanban_implementation_summary.md
Normal file
300
docs/kanban_implementation_summary.md
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -371,62 +371,6 @@ 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
|
||||||
|
|
@ -802,7 +746,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
101
frontend/package-lock.json
generated
101
frontend/package-lock.json
generated
|
|
@ -8,16 +8,10 @@
|
||||||
"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-hook-form": "^7.71.2",
|
"react-router-dom": "^6.20.1"
|
||||||
"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",
|
||||||
|
|
@ -475,55 +469,6 @@
|
||||||
"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",
|
||||||
|
|
@ -951,17 +896,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1452,11 +1386,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5856,21 +5785,6 @@
|
||||||
"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",
|
||||||
|
|
@ -6869,11 +6783,6 @@
|
||||||
"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",
|
||||||
|
|
@ -7468,14 +7377,6 @@
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,10 @@
|
||||||
"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-hook-form": "^7.71.2",
|
"react-router-dom": "^6.20.1"
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,6 @@ 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 (
|
||||||
|
|
@ -29,53 +23,9 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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">"{cardName}"</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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">"{checklistName}"</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -35,20 +35,12 @@ export function Navbar() {
|
||||||
Cart
|
Cart
|
||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<Link
|
||||||
<Link
|
to="/orders"
|
||||||
to="/boards"
|
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
>
|
||||||
>
|
Orders
|
||||||
Boards
|
</Link>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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}</>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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 "{listName}"?
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
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',
|
||||||
|
|
@ -47,224 +35,57 @@ api.interceptors.response.use(
|
||||||
);
|
);
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
return useMemo(
|
return {
|
||||||
() => ({
|
// Auth
|
||||||
// Auth
|
login: async (email: string, password: string): Promise<AuthResponse> => {
|
||||||
login: async (email: string, password: string): Promise<AuthResponse> => {
|
const response = await api.post<AuthResponse>('/auth/login', {
|
||||||
const response = await api.post<AuthResponse>('/auth/login', {
|
email,
|
||||||
email,
|
password,
|
||||||
password,
|
});
|
||||||
});
|
return response.data;
|
||||||
return response.data;
|
},
|
||||||
},
|
register: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||||
register: async (userData: RegisterData): Promise<AuthResponse> => {
|
const response = await api.post<AuthResponse>('/auth/register', userData);
|
||||||
const response = await api.post<AuthResponse>('/auth/register', userData);
|
return response.data;
|
||||||
return response.data;
|
},
|
||||||
},
|
getCurrentUser: async (): Promise<UserData> => {
|
||||||
getCurrentUser: async (): Promise<UserData> => {
|
const response = await api.get<UserData>('/users/me');
|
||||||
const response = await api.get<UserData>('/users/me');
|
return response.data;
|
||||||
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 (
|
updateProduct: async (id: string, productData: Partial<ProductData>): Promise<ProductData> => {
|
||||||
id: string,
|
const response = await api.put<ProductData>(`/products/${id}`, productData);
|
||||||
productData: Partial<ProductData>
|
return response.data;
|
||||||
): Promise<ProductData> => {
|
},
|
||||||
const response = await api.put<ProductData>(`/products/${id}`, productData);
|
deleteProduct: async (id: string): Promise<void> => {
|
||||||
return response.data;
|
await api.delete(`/products/${id}`);
|
||||||
},
|
},
|
||||||
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}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useApi } from './useApi';
|
|
||||||
import { useLoader } from '../context/loaders/useLoader';
|
|
||||||
import { useToast } from '../context/toasts/useToast';
|
|
||||||
import { 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useApi } from './useApi';
|
|
||||||
import { useLoader } from '../context/loaders/useLoader';
|
|
||||||
import { useToast } from '../context/toasts/useToast';
|
|
||||||
import { 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useApi } from './useApi';
|
|
||||||
import { useLoader } from '../context/loaders/useLoader';
|
|
||||||
import { useToast } from '../context/toasts/useToast';
|
|
||||||
import { 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,38 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useApp } from '../context/AppContext';
|
||||||
import { useToast } from '../context/toasts/useToast';
|
import { useApi } from '../hooks/useApi';
|
||||||
|
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 { login: handleLogin } = useAuth();
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { addNotification } = useToast();
|
const navigate = useNavigate();
|
||||||
|
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 {
|
||||||
await handleLogin(email, password);
|
const response = await loginApi(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) {
|
||||||
// Error is handled by the hook (toast shown)
|
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create card';
|
} finally {
|
||||||
addNotification({
|
setLoading(false);
|
||||||
type: 'error',
|
|
||||||
title: 'Error Login',
|
|
||||||
message: errorMessage,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,6 +41,12 @@ 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
|
||||||
|
|
@ -61,15 +77,16 @@ export default function Login() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
disabled={loading}
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
Login
|
{loading ? 'Logging in...' : 'Login'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-gray-400">
|
<p className="mt-6 text-center text-gray-400">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<Link to="/register" className="ml-2 text-blue-400 hover:text-blue-300">
|
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState, FormEvent, ChangeEvent } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useApi } from '../hooks/useApi';
|
||||||
import { useToast } from '../context/toasts/useToast';
|
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -21,44 +20,48 @@ export function Register() {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
});
|
});
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const { register: handleRegister } = useAuth();
|
const navigate = useNavigate();
|
||||||
const { addNotification } = useToast();
|
const { register } = useApi();
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: 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 handleRegister({
|
await register({
|
||||||
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,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
navigate('/login');
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to register';
|
} catch (err: any) {
|
||||||
addNotification({
|
setError(err.response?.data?.error || 'Registration failed. Please try again.');
|
||||||
type: 'error',
|
} finally {
|
||||||
title: 'Registration Error',
|
setLoading(false);
|
||||||
message: errorMessage,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -67,6 +70,12 @@ 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">
|
||||||
|
|
@ -157,30 +166,18 @@ 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={formData.password !== formData.confirmPassword || formData.password.length < 6}
|
disabled={loading}
|
||||||
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"
|
||||||
>
|
>
|
||||||
Register
|
{loading ? 'Registering...' : '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="ml-2 text-blue-400 hover:text-blue-300">
|
<Link to="/login" className="text-blue-400 hover:text-blue-300">
|
||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
access_token: string;
|
token: string;
|
||||||
user: UserData;
|
user: UserData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue