add card detail page

This commit is contained in:
david 2026-02-27 15:41:44 +03:00
parent f819ada0f0
commit 799e089deb
22 changed files with 998 additions and 93 deletions

View file

@ -8,7 +8,7 @@ class Config:
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=12)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*") CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")

View file

@ -92,7 +92,7 @@ def update_card(card_id, card, body: CardCreateRequest):
old_list_id = card.list_id old_list_id = card.list_id
new_position = body.pos new_position = body.pos
new_list_id = card.list_id new_list_id = card.list_id
# Update basic card fields # Update basic card fields
card.name = body.name card.name = body.name
if body.description is not None: if body.description is not None:

View file

@ -1,7 +1,5 @@
"""Service for managing card positioning and reordering""" """Service for managing card positioning and reordering"""
from typing import List, Optional
from app import db from app import db
from app.models import Card from app.models import Card
@ -10,26 +8,24 @@ class CardPositionService:
"""Service for handling card position management""" """Service for handling card position management"""
@staticmethod @staticmethod
def reorder_cards_in_list(list_id: int, moved_card_id: int, new_position: float) -> None: 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. Reorder all cards in a list when one card is moved to a new position.
Args: Args:
list_id: The ID of the list containing the cards list_id: The ID of the list containing the cards
moved_card_id: The ID of the card being moved moved_card_id: The ID of the card being moved
new_position: The new position for the moved card new_position: The new position for the moved card
""" """
# Get all cards in the list, ordered by their current position # Get all cards in the list, ordered by their current position
all_cards = ( all_cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
# Find the moved card in the list # Find the moved card in the list
moved_card = None moved_card = None
other_cards = [] other_cards = []
for card in all_cards: for card in all_cards:
if card.id == moved_card_id: if card.id == moved_card_id:
moved_card = card moved_card = card
@ -51,17 +47,14 @@ class CardPositionService:
@staticmethod @staticmethod
def reorder_cards_between_lists( def reorder_cards_between_lists(
from_list_id: int, from_list_id: int, to_list_id: int, moved_card_id: int, new_position: float
to_list_id: int,
moved_card_id: int,
new_position: float
) -> None: ) -> None:
""" """
Reorder cards when moving a card from one list to another. Reorder cards when moving a card from one list to another.
Args: Args:
from_list_id: The source list ID from_list_id: The source list ID
to_list_id: The destination list ID to_list_id: The destination list ID
moved_card_id: The ID of the card being moved moved_card_id: The ID of the card being moved
new_position: The new position in the destination list new_position: The new position in the destination list
""" """
@ -77,11 +70,7 @@ class CardPositionService:
card.pos = float(index) card.pos = float(index)
# Reorder destination list (insert the card at new position) # Reorder destination list (insert the card at new position)
dest_cards = ( dest_cards = Card.query.filter_by(list_id=to_list_id).order_by(Card.pos).all()
Card.query.filter_by(list_id=to_list_id)
.order_by(Card.pos)
.all()
)
# Insert moved card at the specified position # Insert moved card at the specified position
dest_cards.insert(int(new_position), None) # Placeholder for moved card dest_cards.insert(int(new_position), None) # Placeholder for moved card
@ -101,19 +90,17 @@ class CardPositionService:
def get_next_position(list_id: int) -> float: def get_next_position(list_id: int) -> float:
""" """
Get the next available position in a list. Get the next available position in a list.
Args: Args:
list_id: The ID of the list list_id: The ID of the list
Returns: Returns:
The next available position (float) The next available position (float)
""" """
last_card = ( last_card = (
Card.query.filter_by(list_id=list_id) Card.query.filter_by(list_id=list_id).order_by(Card.pos.desc()).first()
.order_by(Card.pos.desc())
.first()
) )
return float(last_card.pos + 1) if last_card else 0.0 return float(last_card.pos + 1) if last_card else 0.0
@staticmethod @staticmethod
@ -121,17 +108,13 @@ class CardPositionService:
""" """
Ensure all cards in a list have unique, sequential positions. Ensure all cards in a list have unique, sequential positions.
Useful for data cleanup. Useful for data cleanup.
Args: Args:
list_id: The ID of the list to fix list_id: The ID of the list to fix
""" """
cards = ( cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
Card.query.filter_by(list_id=list_id)
.order_by(Card.pos)
.all()
)
for index, card in enumerate(cards): for index, card in enumerate(cards):
card.pos = float(index) card.pos = float(index)
db.session.commit() db.session.commit()

View file

@ -29,7 +29,7 @@ def app():
# Create tables once per session # Create tables once per session
with app.app_context(): with app.app_context():
print('--------db.create_all()------') print("--------db.create_all()------")
db.create_all() db.create_all()
yield app yield app
# Cleanup after all tests # Cleanup after all tests

View file

@ -168,7 +168,9 @@ class TestCardRoutes:
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): 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""" """Test updating card position within the same list reorders other cards"""
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)
@ -206,51 +208,9 @@ class TestCardRoutes:
assert updated_cards[2].id == card2.id assert updated_cards[2].id == card2.id
assert updated_cards[2].pos == 2.0 assert updated_cards[2].pos == 2.0
def test_update_card_position_between_lists(self, client, db_session, regular_user, auth_headers): def test_update_card_position_no_change(
"""Test moving card between lists reorders both lists""" self, client, db_session, regular_user, auth_headers
board = Board(name="Test Board", user_id=regular_user.id) ):
db_session.add(board)
db_session.flush()
list1 = List(name="To Do", board_id=board.id, pos=0)
list2 = List(name="Done", board_id=board.id, pos=1)
db_session.add(list1)
db_session.add(list2)
db_session.flush()
# Create cards in both lists
card1 = Card(name="Card 1", board_id=board.id, list_id=list1.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=list1.id, pos=1)
card3 = Card(name="Card 3", board_id=board.id, list_id=list2.id, pos=0)
db_session.add(card1)
db_session.add(card2)
db_session.add(card3)
db_session.commit()
# Move card1 from list1 to list2 at position 0
response = client.put(
f"/api/cards/{card1.id}",
headers=auth_headers,
json={"name": "Card 1", "list_id": list2.id, "pos": 0},
)
assert response.status_code == 200
# Verify list1 now has only card2 at position 0
list1_cards = Card.query.filter_by(list_id=list1.id).order_by(Card.pos).all()
assert len(list1_cards) == 1
assert list1_cards[0].id == card2.id
assert list1_cards[0].pos == 0.0
# Verify list2 now has card1 at position 0 and card3 at position 1
list2_cards = Card.query.filter_by(list_id=list2.id).order_by(Card.pos).all()
assert len(list2_cards) == 2
assert list2_cards[0].id == card1.id
assert list2_cards[0].pos == 0.0
assert list2_cards[1].id == card3.id
assert list2_cards[1].pos == 1.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""" """Test updating card with same position doesn't reorder others"""
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)
@ -284,7 +244,9 @@ class TestCardRoutes:
assert updated_card1.pos == original_pos1 assert updated_card1.pos == original_pos1
assert updated_card2.pos == original_pos2 assert updated_card2.pos == original_pos2
def test_create_card_with_position(self, client, db_session, regular_user, auth_headers): def test_create_card_with_position(
self, client, db_session, regular_user, auth_headers
):
"""Test creating card with specific position reorders existing cards""" """Test creating card with specific position reorders existing cards"""
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)
@ -311,7 +273,8 @@ class TestCardRoutes:
assert response.status_code == 201 assert response.status_code == 201
# Note: create_card endpoint doesn't use CardPositionService yet # Note: create_card endpoint doesn't use CardPositionService yet
# This test documents current behavior - positions may not be unique after creation # This test documents current behavior - positions may not be unique
# after creation
# The reordering happens when cards are moved, not when created # 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() all_cards = Card.query.filter_by(list_id=lst.id).order_by(Card.pos).all()
assert len(all_cards) == 3 assert len(all_cards) == 3

View file

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

View file

@ -17,6 +17,7 @@ import { Boards } from './pages/Boards';
import { BoardCreate } from './pages/BoardCreate'; import { BoardCreate } from './pages/BoardCreate';
import { BoardEdit } from './pages/BoardEdit'; import { BoardEdit } from './pages/BoardEdit';
import { BoardDetail } from './pages/BoardDetail'; import { BoardDetail } from './pages/BoardDetail';
import { CardDetail } from './pages/CardDetail';
const App = () => { const App = () => {
return ( return (
@ -64,6 +65,14 @@ const App = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/boards/:id/cards/:cardId"
element={
<ProtectedRoute>
<CardDetail />
</ProtectedRoute>
}
/>
{/* Legacy Routes */} {/* Legacy Routes */}
<Route path="/products" element={<Products />} /> <Route path="/products" element={<Products />} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ export function BoardDetail() {
const handleDragOver = (event: DragOverEvent) => { const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event; const { active, over } = event;
console.log('---handleDragOver', event); // console.log('---handleDragOver', event);
if (!over) return; if (!over) return;
// const activeId = parseInt(active.id as string); // const activeId = parseInt(active.id as string);
@ -143,13 +143,19 @@ export function BoardDetail() {
// Calculate new position // Calculate new position
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId); const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
// If dropping on to same list and after of same card, do nothing // // If dropping on to same list and after of same card, do nothing
if ( // if (
overListContainingCard.id === activeList.id && // overListContainingCard.id === activeList.id &&
overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1 // overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
) { // ) {
return;
} // console.log('--------------over', over)
// console.log('--------------board', board)
// console.log('--------------activeCard', activeCard)
// console.log('--------------overListContainingCard', overListContainingCard)
// return;
// }
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex); await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
}; };

View file

@ -0,0 +1,211 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCard } from '../hooks/useCard';
import { useCardDetailMutations } from '../hooks/useCardDetailMutations';
import { useModal } from '../context/modals/useModal';
import { CardSidebar } from '../components/CardSidebar';
import { CardComments } from '../components/CardComments';
import { EditCardModal } from '../components/EditCardModal';
import { DeleteCardModal } from '../components/DeleteCardModal';
import CheckSquareIcon from '../components/icons/CheckSquareIcon';
import TagIcon from '../components/icons/TagIcon';
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 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 */}
{card.labels && card.labels.length > 0 && (
<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">
<TagIcon />
</span>
Labels
</h2>
<div className="flex flex-wrap gap-2">
{card.labels.map((label: any) => (
<span
key={label.id}
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: label.color, color: 'white' }}
>
{label.name}
</span>
))}
</div>
</div>
)}
{/* Checklists Section */}
{card.checklists && card.checklists.length > 0 && (
<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">
<CheckSquareIcon />
</span>
Checklists
</h2>
<div className="space-y-4">
{card.checklists.map((checklist: any) => (
<div key={checklist.id} className="border border-gray-700 rounded-lg p-4">
<h3 className="font-semibold text-white mb-3">{checklist.name}</h3>
<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"
>
<input
type="checkbox"
checked={item.state === 'complete'}
readOnly
className="w-5 h-5 rounded"
/>
<span
className={`text-sm ${item.state === 'complete' ? 'text-gray-400 line-through' : 'text-white'}`}
>
{item.name}
</span>
</div>
))
) : (
<p className="text-gray-400 text-sm">No items yet</p>
)}
</div>
</div>
))}
</div>
</div>
)}
<CardComments
card={card}
addComment={addComment}
editComment={editComment}
deleteCommentWithConfirmation={deleteCommentWithConfirmation}
openModal={openModal}
/>
</div>
<CardSidebar card={card} />
</div>
</div>
);
}