add card detail page
This commit is contained in:
parent
f819ada0f0
commit
799e089deb
22 changed files with 998 additions and 93 deletions
|
|
@ -8,7 +8,7 @@ class Config:
|
|||
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
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)
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
"""Service for managing card positioning and reordering"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from app import db
|
||||
from app.models import Card
|
||||
|
||||
|
|
@ -10,7 +8,9 @@ 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:
|
||||
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.
|
||||
|
||||
|
|
@ -20,11 +20,7 @@ class CardPositionService:
|
|||
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()
|
||||
)
|
||||
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
|
||||
|
|
@ -51,10 +47,7 @@ class CardPositionService:
|
|||
|
||||
@staticmethod
|
||||
def reorder_cards_between_lists(
|
||||
from_list_id: int,
|
||||
to_list_id: int,
|
||||
moved_card_id: int,
|
||||
new_position: float
|
||||
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.
|
||||
|
|
@ -77,11 +70,7 @@ class CardPositionService:
|
|||
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()
|
||||
)
|
||||
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
|
||||
|
|
@ -109,9 +98,7 @@ class CardPositionService:
|
|||
The next available position (float)
|
||||
"""
|
||||
last_card = (
|
||||
Card.query.filter_by(list_id=list_id)
|
||||
.order_by(Card.pos.desc())
|
||||
.first()
|
||||
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
|
||||
|
|
@ -125,11 +112,7 @@ class CardPositionService:
|
|||
Args:
|
||||
list_id: The ID of the list to fix
|
||||
"""
|
||||
cards = (
|
||||
Card.query.filter_by(list_id=list_id)
|
||||
.order_by(Card.pos)
|
||||
.all()
|
||||
)
|
||||
cards = Card.query.filter_by(list_id=list_id).order_by(Card.pos).all()
|
||||
|
||||
for index, card in enumerate(cards):
|
||||
card.pos = float(index)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ def app():
|
|||
|
||||
# Create tables once per session
|
||||
with app.app_context():
|
||||
print('--------db.create_all()------')
|
||||
print("--------db.create_all()------")
|
||||
db.create_all()
|
||||
yield app
|
||||
# Cleanup after all tests
|
||||
|
|
|
|||
|
|
@ -168,7 +168,9 @@ class TestCardRoutes:
|
|||
|
||||
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"""
|
||||
board = Board(name="Test Board", user_id=regular_user.id)
|
||||
db_session.add(board)
|
||||
|
|
@ -206,51 +208,9 @@ class TestCardRoutes:
|
|||
assert updated_cards[2].id == card2.id
|
||||
assert updated_cards[2].pos == 2.0
|
||||
|
||||
def test_update_card_position_between_lists(self, client, db_session, regular_user, auth_headers):
|
||||
"""Test moving card between lists reorders both lists"""
|
||||
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):
|
||||
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)
|
||||
|
|
@ -284,7 +244,9 @@ class TestCardRoutes:
|
|||
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):
|
||||
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)
|
||||
|
|
@ -311,7 +273,8 @@ class TestCardRoutes:
|
|||
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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -371,6 +371,62 @@ useEffect(() => {
|
|||
</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
|
||||
|
||||
### React Router
|
||||
|
|
@ -746,6 +802,7 @@ export function useNewContext() {
|
|||
❌ **NEVER** hardcode API URLs
|
||||
❌ **NEVER** skip PropTypes validation
|
||||
❌ **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
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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 = () => {
|
||||
return (
|
||||
|
|
@ -64,6 +65,14 @@ const App = () => {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/boards/:id/cards/:cardId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CardDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Legacy Routes */}
|
||||
<Route path="/products" element={<Products />} />
|
||||
|
|
|
|||
185
frontend/src/components/CardComments.tsx
Normal file
185
frontend/src/components/CardComments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/CardSidebar.tsx
Normal file
68
frontend/src/components/CardSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/DeleteCardModal.tsx
Normal file
41
frontend/src/components/DeleteCardModal.tsx
Normal 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">"{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>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/EditCardModal.tsx
Normal file
71
frontend/src/components/EditCardModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/icons/ArrowLeftIcon.tsx
Normal file
18
frontend/src/components/icons/ArrowLeftIcon.tsx
Normal 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;
|
||||
18
frontend/src/components/icons/CheckSquareIcon.tsx
Normal file
18
frontend/src/components/icons/CheckSquareIcon.tsx
Normal 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;
|
||||
17
frontend/src/components/icons/Edit2Icon.tsx
Normal file
17
frontend/src/components/icons/Edit2Icon.tsx
Normal 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;
|
||||
17
frontend/src/components/icons/MessageSquareIcon.tsx
Normal file
17
frontend/src/components/icons/MessageSquareIcon.tsx
Normal 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;
|
||||
18
frontend/src/components/icons/PlusIcon.tsx
Normal file
18
frontend/src/components/icons/PlusIcon.tsx
Normal 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;
|
||||
18
frontend/src/components/icons/TagIcon.tsx
Normal file
18
frontend/src/components/icons/TagIcon.tsx
Normal 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;
|
||||
20
frontend/src/components/icons/Trash2Icon.tsx
Normal file
20
frontend/src/components/icons/Trash2Icon.tsx
Normal 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;
|
||||
48
frontend/src/hooks/useCard.ts
Normal file
48
frontend/src/hooks/useCard.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
137
frontend/src/hooks/useCardDetailMutations.ts
Normal file
137
frontend/src/hooks/useCardDetailMutations.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ export function BoardDetail() {
|
|||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
console.log('---handleDragOver', event);
|
||||
// console.log('---handleDragOver', event);
|
||||
if (!over) return;
|
||||
|
||||
// const activeId = parseInt(active.id as string);
|
||||
|
|
@ -143,13 +143,19 @@ export function BoardDetail() {
|
|||
// Calculate new position
|
||||
const overCardIndex = overListContainingCard.cards.findIndex((c) => c.id === overId);
|
||||
|
||||
// If dropping on to same list and after of same card, do nothing
|
||||
if (
|
||||
overListContainingCard.id === activeList.id &&
|
||||
overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// // If dropping on to same list and after of same card, do nothing
|
||||
// if (
|
||||
// overListContainingCard.id === activeList.id &&
|
||||
// overCardIndex === activeList.cards.findIndex((c) => c.id === activeId) + 1
|
||||
// ) {
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
|
|
|||
211
frontend/src/pages/CardDetail.tsx
Normal file
211
frontend/src/pages/CardDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue