improve card component

This commit is contained in:
david 2026-02-27 22:25:34 +03:00
parent e69dd6a56d
commit 8a33e1c644
6 changed files with 279 additions and 17 deletions

View file

@ -27,12 +27,15 @@ def get_boards():
@load_board_owned
def get_board(board_id, board):
"""Get a single board with all its details"""
from app.models import User
# Get all lists for this board
lists_data = []
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all():
cards_data = []
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all():
card_dict = card.to_dict()
# Add labels for this card
card_dict["labels"] = [
label.to_dict()
@ -43,6 +46,24 @@ def get_board(board_id, board):
.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)
lists_data.append({**lst.to_dict(), "cards": cards_data})

View file

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

View file

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

View file

@ -1,13 +1,16 @@
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;
onClick: () => void;
onOpenModal: () => void;
}
export function KanbanCard({ card, onClick }: KanbanCardProps) {
export function KanbanCard({ card, onOpenModal }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `CARD_${card.id}`,
});
@ -18,28 +21,109 @@ export function KanbanCard({ card, onClick }: KanbanCardProps) {
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={onClick}
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"
>
<h3 className="text-white font-medium mb-2">{card.name}</h3>
{card.description && (
<p className="text-gray-400 text-sm mb-2 line-clamp-2">{card.description}</p>
{/* 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>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<span>#{card.id_short}</span>
<h3 className="text-white font-medium mb-2 hover:text-blue-400 transition-colors">
<Link
to={`/boards/${card.board_id}/cards/${card.id}`}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
{card.name}
</Link>
</h3>
{card.description && (
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{card.description}</p>
)}
{/* Badges */}
<div className="flex items-center gap-3 text-xs text-gray-400 mt-3 pt-2 border-t border-gray-600">
{/* Comments Badge */}
{hasComments && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<MessageSquareIcon />
</span>
<span>{commentCount}</span>
</div>
)}
{/* Checklist Badge */}
{hasChecklists && (
<div className="flex items-center gap-1">
<span className="w-3.5 h-3.5">
<CheckSquareIcon />
</span>
<span>
{completedItems}/{totalItems}
</span>
</div>
)}
{/* Due Date Badge */}
{card.due && (
<span className={card.due_complete ? 'text-green-400' : 'text-yellow-400'}>
Due: {new Date(card.due).toLocaleDateString()}
</span>
)}
{/* Card ID */}
<span className="text-gray-500">#{card.id_short}</span>
</div>
</div>
);

View file

@ -8,11 +8,11 @@ import { useModal } from '../../context/modals/useModal';
interface KanbanColumnProps {
list: ListWithCards;
cards: CardType[];
onCardClick: (card: CardType) => void;
onOpenCardModal: (card: CardType) => void;
onCardCreate: (data: { name: string; description?: string }) => Promise<void>;
}
export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanColumnProps) {
export function KanbanColumn({ list, cards, onOpenCardModal, onCardCreate }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: `LIST_${list.id}`,
});
@ -42,7 +42,7 @@ export function KanbanColumn({ list, cards, onCardClick, onCardCreate }: KanbanC
className={`min-h-[200px] flex-1 transition-colors ${isOver ? 'bg-gray-750' : ''}`}
>
{cards.map((card) => (
<KanbanCard key={card.id} card={card} onClick={() => onCardClick(card)} />
<KanbanCard key={card.id} card={card} onOpenModal={() => onOpenCardModal(card)} />
))}
</div>
</SortableContext>

View file

@ -1,9 +1,10 @@
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { useBoard } from '../hooks/useBoard';
import { useCardMutations } from '../hooks/useCardMutations';
import { useListMutations } from '../hooks/useListMutations';
import { KanbanColumn } from '../components/kanban/KanbanColumn';
import { CreateListModal } from '../components/kanban/CreateListModal';
import { CardPreviewModal } from '../components/CardPreviewModal';
import { useModal } from '../context/modals/useModal';
import {
DndContext,
@ -21,7 +22,6 @@ import { useState } from 'react';
export function BoardDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { board, fetchBoard } = useBoard(parseInt(id || '0'));
const { createCard, moveCard } = useCardMutations(parseInt(id || '0'), fetchBoard);
const { createList } = useListMutations(parseInt(id || '0'), fetchBoard);
@ -160,8 +160,8 @@ export function BoardDetail() {
await moveCard(activeCard, activeList.id, overListContainingCard.id, overCardIndex);
};
const handleCardClick = (card: CardType) => {
navigate(`/boards/${id}/cards/${card.id}`);
const handleOpenCardModal = (card: CardType) => {
openModal((props) => <CardPreviewModal card={card as any} onClose={props.onClose} />);
};
const handleAddList = () => {
@ -229,7 +229,7 @@ export function BoardDetail() {
key={list.id}
list={list}
cards={list.cards}
onCardClick={handleCardClick}
onOpenCardModal={handleOpenCardModal}
onCardCreate={handleAddCard(list.id)}
/>
))}